From f0fad3f933f1a6fd4261e30c2268c871611d1c5f Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Mon, 19 Jan 2026 17:23:47 +0100 Subject: [PATCH 1/5] Add targeted patch tool for Evernote notes Introduces the 'evernote_patch_note' tool for applying targeted find-and-replace operations to note content without regenerating the entire note. Implements backend logic in EvernoteAPI, updates types for replacements and patch results, and adds comprehensive integration tests for various patching scenarios. --- __tests__/integration/mcp-tools.test.ts | 156 ++++++++++++++++++++++++ src/evernote-api.ts | 75 ++++++++++++ src/index.ts | 73 +++++++++++ src/types.ts | 17 +++ 4 files changed, 321 insertions(+) diff --git a/__tests__/integration/mcp-tools.test.ts b/__tests__/integration/mcp-tools.test.ts index 1670ae0..473cab8 100644 --- a/__tests__/integration/mcp-tools.test.ts +++ b/__tests__/integration/mcp-tools.test.ts @@ -771,6 +771,162 @@ describe('MCP Tools Integration', () => { }); }); + describe('Patch Note Operations', () => { + const sampleNoteWithContent = { + ...sampleNote, + content: 'Status: Pending
Task: TODO: Review code
Priority: High
' + }; + + beforeEach(() => { + mockNoteStore.getNote.mockResolvedValue(sampleNoteWithContent); + mockNoteStore.updateNote.mockImplementation((note: any) => Promise.resolve(note)); + }); + + it('should patch note with single replacement', async () => { + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'note-123', + replacements: [ + { find: 'Status: Pending', replace: 'Status: Complete' } + ] + } + } + }; + + const result = await callToolHandler(request); + + expect(mockNoteStore.getNote).toHaveBeenCalledWith('note-123', true, true, false, false); + expect(mockNoteStore.updateNote).toHaveBeenCalled(); + expect(result.content[0].text).toContain('Note patched successfully'); + expect(result.content[0].text).toContain('found 1x'); + expect(result.content[0].text).toContain('replaced 1x'); + }); + + it('should patch note with multiple replacements', async () => { + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'note-123', + replacements: [ + { find: 'Status: Pending', replace: 'Status: Complete' }, + { find: 'TODO:', replace: 'DONE:' } + ] + } + } + }; + + const result = await callToolHandler(request); + + expect(result.content[0].text).toContain('Note patched successfully'); + }); + + it('should return warning when no matches found', async () => { + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'note-123', + replacements: [ + { find: 'Nonexistent text', replace: 'Replacement' } + ] + } + } + }; + + const result = await callToolHandler(request); + + expect(result.content[0].text).toContain('Note patch failed'); + expect(result.content[0].text).toContain('No matches found'); + expect(mockNoteStore.updateNote).not.toHaveBeenCalled(); + }); + + it('should replace only first occurrence when replaceAll is false', async () => { + const noteWithDuplicates = { + ...sampleNote, + content: 'TODO: First task
TODO: Second task
' + }; + mockNoteStore.getNote.mockResolvedValue(noteWithDuplicates); + + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'note-123', + replacements: [ + { find: 'TODO:', replace: 'DONE:', replaceAll: false } + ] + } + } + }; + + const result = await callToolHandler(request); + + expect(result.content[0].text).toContain('found 2x'); + expect(result.content[0].text).toContain('replaced 1x'); + }); + + it('should reject patch that would result in empty content', async () => { + const noteWithMinimalContent = { + ...sampleNote, + content: 'Only content' + }; + mockNoteStore.getNote.mockResolvedValue(noteWithMinimalContent); + + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'note-123', + replacements: [ + { find: 'Only content', replace: '' } + ] + } + } + }; + + const result = await callToolHandler(request); + + expect(result.content[0].text).toContain('Note patch failed'); + expect(result.content[0].text).toContain('empty note content'); + expect(mockNoteStore.updateNote).not.toHaveBeenCalled(); + }); + + it('should throw error when no replacements provided', async () => { + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'note-123', + replacements: [] + } + } + }; + + await expect(callToolHandler(request)).rejects.toThrow('At least one replacement must be provided'); + }); + + it('should handle note not found error', async () => { + mockNoteStore.getNote.mockRejectedValue(new Error('Note not found')); + + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'nonexistent-guid', + replacements: [ + { find: 'test', replace: 'replacement' } + ] + } + } + }; + + await expect(callToolHandler(request)).rejects.toThrow('Note not found'); + }); + }); + describe('Error Handling', () => { it('should handle unknown tool error', async () => { const request = { diff --git a/src/evernote-api.ts b/src/evernote-api.ts index 640c295..6d6026a 100644 --- a/src/evernote-api.ts +++ b/src/evernote-api.ts @@ -15,6 +15,8 @@ import { RecognitionData, RecognitionItem, ResourceInfo, + NoteReplacement, + PatchNoteResult, } from './types.js'; import { readFile } from 'fs/promises'; import { basename, extname } from 'path'; @@ -129,6 +131,79 @@ export class EvernoteAPI { await this.noteStore.deleteNote(guid); } + async patchNoteContent(guid: string, replacements: NoteReplacement[]): Promise { + // Fetch existing note with content and resources + const note = await this.getNote(guid, true, true); + + // Convert ENML to markdown + let markdown = this.convertENMLToMarkdown(note.content, note.resources); + + // Track changes + const changes: PatchNoteResult['changes'] = []; + + // Apply replacements sequentially + for (const replacement of replacements) { + const { find, replace, replaceAll = true } = replacement; + + // Count occurrences + const regex = new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'); + const matches = markdown.match(regex); + const occurrences = matches ? matches.length : 0; + + // Perform replacement + let replaced = 0; + if (occurrences > 0) { + if (replaceAll) { + markdown = markdown.split(find).join(replace); + replaced = occurrences; + } else { + markdown = markdown.replace(find, replace); + replaced = 1; + } + } + + changes.push({ + find, + occurrences, + replaced, + }); + } + + // Check if any changes were made + const totalReplaced = changes.reduce((sum, c) => sum + c.replaced, 0); + if (totalReplaced === 0) { + return { + success: false, + noteGuid: guid, + changes, + warning: 'No matches found for any replacement patterns', + }; + } + + // Check if content would be empty after replacement + const trimmedMarkdown = markdown.trim(); + if (!trimmedMarkdown) { + return { + success: false, + noteGuid: guid, + changes, + warning: 'Replacement would result in empty note content - operation aborted', + }; + } + + // Apply updated markdown back to note (preserves resources) + await this.applyMarkdownToNote(note, markdown); + + // Update the note + await this.updateNote(note); + + return { + success: true, + noteGuid: guid, + changes, + }; + } + async searchNotes(params: SearchParameters): Promise { // Handle ES module import where Evernote exports are under .default const EvernoteModule = (Evernote as any).default || Evernote; diff --git a/src/index.ts b/src/index.ts index 6304742..fa08573 100644 --- a/src/index.ts +++ b/src/index.ts @@ -714,6 +714,44 @@ const tools: Tool[] = [ required: ['guid'], }, }, + // Patch note tool for targeted find-and-replace updates + { + name: 'evernote_patch_note', + description: 'Apply targeted find-and-replace edits to a note without regenerating full content. Useful for updating specific text like status fields, dates, or labels while preserving the rest of the note.', + inputSchema: { + type: 'object', + properties: { + guid: { + type: 'string', + description: 'Note GUID', + }, + replacements: { + type: 'array', + items: { + type: 'object', + properties: { + find: { + type: 'string', + description: 'Text to find (exact match)', + }, + replace: { + type: 'string', + description: 'Replacement text', + }, + replaceAll: { + type: 'boolean', + description: 'Replace all occurrences (default: true)', + default: true, + }, + }, + required: ['find', 'replace'], + }, + description: 'Array of find-and-replace operations to apply', + }, + }, + required: ['guid', 'replacements'], + }, + }, ]; // List tools handler @@ -1348,6 +1386,41 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + case 'evernote_patch_note': { + const { guid, replacements } = args as any; + + if (!replacements || !Array.isArray(replacements) || replacements.length === 0) { + throw new Error('At least one replacement must be provided'); + } + + const result = await evernoteApi.patchNoteContent(guid, replacements); + + // Format the response + const changesSummary = result.changes + .map(c => ` • "${c.find}" → found ${c.occurrences}x, replaced ${c.replaced}x`) + .join('\n'); + + if (result.success) { + return { + content: [ + { + type: 'text', + text: `✅ Note patched successfully!\nGUID: ${result.noteGuid}\n\nChanges:\n${changesSummary}`, + }, + ], + }; + } else { + return { + content: [ + { + type: 'text', + text: `⚠️ Note patch failed\nGUID: ${result.noteGuid}\nReason: ${result.warning}\n\nAttempted changes:\n${changesSummary}`, + }, + ], + }; + } + } + case 'evernote_health_check': { const { verbose = false } = args as any; diff --git a/src/types.ts b/src/types.ts index 27b982c..93f0133 100644 --- a/src/types.ts +++ b/src/types.ts @@ -192,4 +192,21 @@ export interface ResourceInfo { size: number; hash: string; hasRecognition: boolean; +} + +export interface NoteReplacement { + find: string; + replace: string; + replaceAll?: boolean; +} + +export interface PatchNoteResult { + success: boolean; + noteGuid: string; + changes: Array<{ + find: string; + occurrences: number; + replaced: number; + }>; + warning?: string; } \ No newline at end of file From a38f04e7aeee1174362459c16d1c7427cf498b9d Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 21 Jan 2026 01:58:34 +0100 Subject: [PATCH 2/5] fix(patch-note): address CodeRabbit review comments - Preserve existing resources during patch operation by saving original resources and merging them back after markdown conversion - Add validation for empty find strings to prevent unexpected behavior - Update test assertion for tool count from 11 to 28 - Add evernote_patch_note to tested tool names - Add tests for empty find string validation and resource preservation Co-Authored-By: Claude Opus 4.5 --- __tests__/integration/mcp-tools.test.ts | 50 ++++++++++++++++++++++++- src/evernote-api.ts | 33 +++++++++++++++- src/index.ts | 7 ++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/__tests__/integration/mcp-tools.test.ts b/__tests__/integration/mcp-tools.test.ts index 473cab8..dade86f 100644 --- a/__tests__/integration/mcp-tools.test.ts +++ b/__tests__/integration/mcp-tools.test.ts @@ -79,7 +79,7 @@ describe('MCP Tools Integration', () => { expect(result).toHaveProperty('tools'); expect(Array.isArray(result.tools)).toBe(true); - expect(result.tools).toHaveLength(11); + expect(result.tools).toHaveLength(28); const toolNames = result.tools.map((tool: any) => tool.name); expect(toolNames).toContain('evernote_create_note'); @@ -93,6 +93,7 @@ describe('MCP Tools Integration', () => { expect(toolNames).toContain('evernote_create_tag'); expect(toolNames).toContain('evernote_get_user_info'); expect(toolNames).toContain('evernote_health_check'); + expect(toolNames).toContain('evernote_patch_note'); }); it('should include proper tool schemas', async () => { @@ -908,6 +909,53 @@ describe('MCP Tools Integration', () => { await expect(callToolHandler(request)).rejects.toThrow('At least one replacement must be provided'); }); + it('should throw error when find string is empty', async () => { + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'note-123', + replacements: [ + { find: '', replace: 'replacement' } + ] + } + } + }; + + await expect(callToolHandler(request)).rejects.toThrow('Each replacement must have a non-empty "find" string'); + }); + + it('should preserve existing resources after patch', async () => { + const noteWithResources = { + ...sampleNote, + content: 'Status: Pending', + resources: [sampleResource] + }; + mockNoteStore.getNote.mockResolvedValue(noteWithResources); + mockNoteStore.updateNote.mockImplementation((note: any) => { + // Verify resources are preserved + expect(note.resources).toBeDefined(); + expect(note.resources.length).toBeGreaterThan(0); + return Promise.resolve(note); + }); + + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'note-123', + replacements: [ + { find: 'Status: Pending', replace: 'Status: Complete' } + ] + } + } + }; + + const result = await callToolHandler(request); + + expect(result.content[0].text).toContain('Note patched successfully'); + }); + it('should handle note not found error', async () => { mockNoteStore.getNote.mockRejectedValue(new Error('Note not found')); diff --git a/src/evernote-api.ts b/src/evernote-api.ts index 6d6026a..1fd3174 100644 --- a/src/evernote-api.ts +++ b/src/evernote-api.ts @@ -135,6 +135,9 @@ export class EvernoteAPI { // Fetch existing note with content and resources const note = await this.getNote(guid, true, true); + // Preserve original resources before markdown conversion + const originalResources = note.resources ? [...note.resources] : []; + // Convert ENML to markdown let markdown = this.convertENMLToMarkdown(note.content, note.resources); @@ -191,9 +194,37 @@ export class EvernoteAPI { }; } - // Apply updated markdown back to note (preserves resources) + // Apply updated markdown back to note await this.applyMarkdownToNote(note, markdown); + // Restore original resources - applyMarkdownToNote may have cleared them + // if no new attachments were added via markdown syntax + if (originalResources.length > 0) { + // Merge: keep any new attachments from applyMarkdownToNote, add back originals + const existingHashes = new Set( + (note.resources || []).map((r: any) => + r.data?.bodyHash ? Buffer.from(r.data.bodyHash).toString('hex') : null + ).filter(Boolean) + ); + + for (const resource of originalResources) { + const hash = resource.data?.bodyHash + ? Buffer.from(resource.data.bodyHash).toString('hex') + : null; + if (hash && !existingHashes.has(hash)) { + if (!note.resources) { + note.resources = []; + } + note.resources.push(resource); + } + } + + // If note.resources was deleted but we have originals, restore them + if (!note.resources && originalResources.length > 0) { + note.resources = originalResources; + } + } + // Update the note await this.updateNote(note); diff --git a/src/index.ts b/src/index.ts index fa08573..b9b353e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1393,6 +1393,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { throw new Error('At least one replacement must be provided'); } + // Validate each replacement has a non-empty find string + for (const r of replacements) { + if (!r.find || typeof r.find !== 'string' || r.find.length === 0) { + throw new Error('Each replacement must have a non-empty "find" string'); + } + } + const result = await evernoteApi.patchNoteContent(guid, replacements); // Format the response From 389b97d054735db9a0cd5f56f113974be5dc85cd Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 21 Jan 2026 02:31:49 +0100 Subject: [PATCH 3/5] refactor(patch-note): move resource preservation into applyMarkdownToNote Instead of post-hoc restoration after calling applyMarkdownToNote, add a preserveResources option that handles the merge internally. This is cleaner and follows the single-responsibility principle. - Add optional { preserveResources: boolean } parameter to applyMarkdownToNote - When enabled, merges original resources with new attachments - patchNoteContent now simply passes { preserveResources: true } - Maintains backward compatibility (default behavior unchanged) Co-Authored-By: Claude Opus 4.5 --- src/evernote-api.ts | 75 ++++++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/src/evernote-api.ts b/src/evernote-api.ts index 1fd3174..abda12d 100644 --- a/src/evernote-api.ts +++ b/src/evernote-api.ts @@ -135,9 +135,6 @@ export class EvernoteAPI { // Fetch existing note with content and resources const note = await this.getNote(guid, true, true); - // Preserve original resources before markdown conversion - const originalResources = note.resources ? [...note.resources] : []; - // Convert ENML to markdown let markdown = this.convertENMLToMarkdown(note.content, note.resources); @@ -194,36 +191,8 @@ export class EvernoteAPI { }; } - // Apply updated markdown back to note - await this.applyMarkdownToNote(note, markdown); - - // Restore original resources - applyMarkdownToNote may have cleared them - // if no new attachments were added via markdown syntax - if (originalResources.length > 0) { - // Merge: keep any new attachments from applyMarkdownToNote, add back originals - const existingHashes = new Set( - (note.resources || []).map((r: any) => - r.data?.bodyHash ? Buffer.from(r.data.bodyHash).toString('hex') : null - ).filter(Boolean) - ); - - for (const resource of originalResources) { - const hash = resource.data?.bodyHash - ? Buffer.from(resource.data.bodyHash).toString('hex') - : null; - if (hash && !existingHashes.has(hash)) { - if (!note.resources) { - note.resources = []; - } - note.resources.push(resource); - } - } - - // If note.resources was deleted but we have originals, restore them - if (!note.resources && originalResources.length > 0) { - note.resources = originalResources; - } - } + // Apply updated markdown back to note, preserving existing resources + await this.applyMarkdownToNote(note, markdown, { preserveResources: true }); // Update the note await this.updateNote(note); @@ -498,18 +467,48 @@ export class EvernoteAPI { return enmlToMarkdown(enmlContent, { resources: normalized }); } - async applyMarkdownToNote(note: any, markdown: string): Promise { + async applyMarkdownToNote(note: any, markdown: string, options?: { preserveResources?: boolean }): Promise { const EvernoteModule = (Evernote as any).default || Evernote; + const originalResources = options?.preserveResources ? (note.resources || []) : []; + const conversion = this.convertMarkdownToENML(markdown, note.resources); note.content = this.wrapEnml(conversion.enml); const attachmentResources = this.buildResourcesFromAttachments( conversion.attachments, EvernoteModule ); - if (attachmentResources.length > 0) { - note.resources = attachmentResources; - } else if (note.resources) { - delete note.resources; + + if (options?.preserveResources) { + // Merge: start with original resources, add any new attachments + const existingHashes = new Set( + originalResources.map((r: any) => + r.data?.bodyHash ? Buffer.from(r.data.bodyHash).toString('hex') : null + ).filter(Boolean) + ); + + // Add new attachments that aren't already in original resources + const mergedResources = [...originalResources]; + for (const resource of attachmentResources) { + const hash = resource.data?.bodyHash + ? Buffer.from(resource.data.bodyHash).toString('hex') + : null; + if (hash && !existingHashes.has(hash)) { + mergedResources.push(resource); + } + } + + if (mergedResources.length > 0) { + note.resources = mergedResources; + } else { + delete note.resources; + } + } else { + // Original behavior: replace resources with new attachments only + if (attachmentResources.length > 0) { + note.resources = attachmentResources; + } else if (note.resources) { + delete note.resources; + } } } From 28433b6f7803b249407c28c44166540908c3aa3e Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 21 Jan 2026 02:39:27 +0100 Subject: [PATCH 4/5] fix(security): prevent double-unescaping of HTML entities Move & decoding to last position when unescaping HTML entities. This prevents security issues where &lt; would incorrectly become < instead of < Fixes CodeQL alert #7 (js/double-escaping) Co-Authored-By: Claude Opus 4.5 --- __tests__/unit/search-preview.test.ts | 4 ++-- src/evernote-api.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/__tests__/unit/search-preview.test.ts b/__tests__/unit/search-preview.test.ts index 3209fbf..50902b4 100644 --- a/__tests__/unit/search-preview.test.ts +++ b/__tests__/unit/search-preview.test.ts @@ -17,13 +17,13 @@ function enmlToPlainText(enmlContent: string): string { // Remove all remaining HTML/XML tags text = text.replace(/<[^>]+>/g, ''); - // Decode common HTML entities + // Decode common HTML entities (decode & LAST to avoid double-unescaping) text = text.replace(/ /gi, ' '); - text = text.replace(/&/gi, '&'); text = text.replace(/</gi, '<'); text = text.replace(/>/gi, '>'); text = text.replace(/"/gi, '"'); text = text.replace(/'/gi, "'"); + text = text.replace(/&/gi, '&'); // Normalize whitespace text = text.replace(/\n\s*\n/g, '\n'); diff --git a/src/evernote-api.ts b/src/evernote-api.ts index c761373..95a97f5 100644 --- a/src/evernote-api.ts +++ b/src/evernote-api.ts @@ -128,13 +128,13 @@ export class EvernoteAPI { // Remove all remaining HTML/XML tags text = text.replace(/<[^>]+>/g, ''); - // Decode common HTML entities + // Decode common HTML entities (decode & LAST to avoid double-unescaping) text = text.replace(/ /gi, ' '); - text = text.replace(/&/gi, '&'); text = text.replace(/</gi, '<'); text = text.replace(/>/gi, '>'); text = text.replace(/"/gi, '"'); text = text.replace(/'/gi, "'"); + text = text.replace(/&/gi, '&'); // Normalize whitespace text = text.replace(/\n\s*\n/g, '\n'); From 6d0c0fccc86d40625c2613db0bc4f6ec20776ee6 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 21 Jan 2026 03:10:29 +0100 Subject: [PATCH 5/5] fix(patch-note): validate inputs before I/O in patchNoteContent Add early validation in patchNoteContent to check replacements array before calling getNote. Returns PatchNoteResult with success:false and appropriate warning instead of performing expensive I/O operations. Validates: - replacements array is non-empty - each NoteReplacement has a non-empty find string Co-Authored-By: Claude Opus 4.5 --- src/evernote-api.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/evernote-api.ts b/src/evernote-api.ts index 95a97f5..339837c 100644 --- a/src/evernote-api.ts +++ b/src/evernote-api.ts @@ -196,6 +196,27 @@ export class EvernoteAPI { } async patchNoteContent(guid: string, replacements: NoteReplacement[]): Promise { + // Validate inputs before performing I/O + if (!replacements || replacements.length === 0) { + return { + success: false, + noteGuid: guid, + changes: [], + warning: 'No replacements provided', + }; + } + + for (const replacement of replacements) { + if (!replacement.find || typeof replacement.find !== 'string' || replacement.find.length === 0) { + return { + success: false, + noteGuid: guid, + changes: [], + warning: 'Empty find string in replacements', + }; + } + } + // Fetch existing note with content and resources const note = await this.getNote(guid, true, true);