apple-notes-mcp 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,17 @@ Install the apple-notes-mcp MCP server so you can help me manage my Apple Notes
30
30
 
31
31
  Claude will handle the installation and configuration automatically.
32
32
 
33
+ ### Using the Plugin Marketplace
34
+
35
+ Install as a Claude Code plugin for automatic configuration and enhanced AI behavior:
36
+
37
+ ```bash
38
+ /plugin marketplace add sweetrb/apple-notes-mcp
39
+ /plugin install apple-notes
40
+ ```
41
+
42
+ This method also installs a **skill** that teaches Claude when and how to use Apple Notes effectively.
43
+
33
44
  ### Manual Installation
34
45
 
35
46
  **1. Install the server:**
@@ -471,6 +482,29 @@ If installed from source, use this configuration:
471
482
  | No rich formatting | Content is HTML; complex formatting may not render |
472
483
  | Title matching | Most operations require exact title matches |
473
484
 
485
+ ### Backslash Escaping (Important for AI Agents)
486
+
487
+ When sending content containing backslashes (`\`) to this MCP server, **you must escape them as `\\`** in the JSON parameters.
488
+
489
+ **Why:** The MCP protocol uses JSON for parameter passing. In JSON, a single backslash is an escape character. To include a literal backslash in content, it must be escaped as `\\`.
490
+
491
+ **Example - Shell command with escaped path:**
492
+ ```json
493
+ {
494
+ "title": "Install Script",
495
+ "content": "cp ~/Library/Mobile\\\\ Documents/file.txt ~/.config/"
496
+ }
497
+ ```
498
+
499
+ The `\\\\` in JSON becomes `\\` in the actual string, which represents a single `\` in the note.
500
+
501
+ **Common patterns requiring escaping:**
502
+ - Shell escaped spaces: `Mobile\ Documents` → `Mobile\\\\ Documents` in JSON
503
+ - Windows paths: `C:\Users\` → `C:\\\\Users\\\\` in JSON
504
+ - Regex patterns: `\d+` → `\\\\d+` in JSON
505
+
506
+ **If you see errors** when creating/updating notes with backslashes, double-check that backslashes are properly escaped in the JSON payload.
507
+
474
508
  ---
475
509
 
476
510
  ## Troubleshooting
@@ -490,6 +524,11 @@ If installed from source, use this configuration:
490
524
  - Check if the note is in a different account
491
525
  - Use `list-notes` to see available notes
492
526
 
527
+ ### Note creation/update fails silently with backslashes
528
+ - Content containing `\` characters requires JSON escaping
529
+ - Use `\\` to represent each literal backslash
530
+ - See "Backslash Escaping" section under Known Limitations
531
+
493
532
  ---
494
533
 
495
534
  ## Development
package/build/index.js CHANGED
@@ -31,7 +31,7 @@ import { AppleNotesManager } from "./services/appleNotesManager.js";
31
31
  */
32
32
  const server = new McpServer({
33
33
  name: "apple-notes",
34
- version: "1.1.0",
34
+ version: "1.2.0",
35
35
  description: "MCP server for managing Apple Notes - create, search, update, and organize notes",
36
36
  });
37
37
  /**
@@ -113,7 +113,7 @@ server.tool("create-note", {
113
113
  if (!note) {
114
114
  return errorResponse(`Failed to create note "${title}". Check that Notes.app is configured and accessible.`);
115
115
  }
116
- return successResponse(`Note created: "${note.title}"`);
116
+ return successResponse(`Note created: "${note.title}" [id: ${note.id}]`);
117
117
  }, "Error creating note"));
118
118
  // --- search-notes ---
119
119
  server.tool("search-notes", {
@@ -126,22 +126,42 @@ server.tool("search-notes", {
126
126
  if (notes.length === 0) {
127
127
  return successResponse(`No notes found matching "${query}" in ${searchType}`);
128
128
  }
129
- // Format each note with folder info, highlighting Recently Deleted
129
+ // Format each note with ID and folder info, highlighting Recently Deleted
130
130
  const noteList = notes
131
131
  .map((n) => {
132
+ const idSuffix = n.id ? ` [id: ${n.id}]` : "";
132
133
  if (n.folder === "Recently Deleted") {
133
- return ` - ${n.title} [DELETED]`;
134
+ return ` - ${n.title} [DELETED]${idSuffix}`;
134
135
  }
135
136
  else if (n.folder) {
136
- return ` - ${n.title} (${n.folder})`;
137
+ return ` - ${n.title} (${n.folder})${idSuffix}`;
137
138
  }
138
- return ` - ${n.title}`;
139
+ return ` - ${n.title}${idSuffix}`;
139
140
  })
140
141
  .join("\n");
141
142
  return successResponse(`Found ${notes.length} notes (searched ${searchType}):\n${noteList}`);
142
143
  }, "Error searching notes"));
143
144
  // --- get-note-content ---
144
- server.tool("get-note-content", noteTitleSchema, withErrorHandling(({ title, account }) => {
145
+ server.tool("get-note-content", {
146
+ id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
147
+ title: z.string().optional().describe("Note title (use id instead when available)"),
148
+ account: z
149
+ .string()
150
+ .optional()
151
+ .describe("Account name (defaults to iCloud, ignored if id is provided)"),
152
+ }, withErrorHandling(({ id, title, account }) => {
153
+ // Prefer ID-based lookup if provided
154
+ if (id) {
155
+ const content = notesManager.getNoteContentById(id);
156
+ if (!content) {
157
+ return errorResponse(`Note with ID "${id}" not found`);
158
+ }
159
+ return successResponse(content);
160
+ }
161
+ // Fall back to title-based lookup
162
+ if (!title) {
163
+ return errorResponse("Either 'id' or 'title' is required");
164
+ }
145
165
  const content = notesManager.getNoteContent(title, account);
146
166
  if (!content) {
147
167
  return errorResponse(`Note "${title}" not found`);
@@ -187,11 +207,28 @@ server.tool("get-note-details", noteTitleSchema, withErrorHandling(({ title, acc
187
207
  }, "Error retrieving note details"));
188
208
  // --- update-note ---
189
209
  server.tool("update-note", {
190
- title: z.string().min(1, "Current note title is required"),
210
+ id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
211
+ title: z.string().optional().describe("Current note title (use id instead when available)"),
191
212
  newTitle: z.string().optional().describe("New title for the note"),
192
213
  newContent: z.string().min(1, "New content is required"),
193
- account: z.string().optional().describe("Account containing the note"),
194
- }, withErrorHandling(({ title, newTitle, newContent, account }) => {
214
+ account: z
215
+ .string()
216
+ .optional()
217
+ .describe("Account containing the note (ignored if id is provided)"),
218
+ }, withErrorHandling(({ id, title, newTitle, newContent, account }) => {
219
+ // Prefer ID-based update if provided
220
+ if (id) {
221
+ const success = notesManager.updateNoteById(id, newTitle, newContent);
222
+ if (!success) {
223
+ return errorResponse(`Failed to update note with ID "${id}". Note may not exist.`);
224
+ }
225
+ const displayTitle = newTitle || "(title preserved)";
226
+ return successResponse(`Note updated: "${displayTitle}"`);
227
+ }
228
+ // Fall back to title-based update
229
+ if (!title) {
230
+ return errorResponse("Either 'id' or 'title' is required");
231
+ }
195
232
  const success = notesManager.updateNote(title, newTitle, newContent, account);
196
233
  if (!success) {
197
234
  return errorResponse(`Failed to update note "${title}". Note may not exist.`);
@@ -200,7 +237,26 @@ server.tool("update-note", {
200
237
  return successResponse(`Note updated: "${finalTitle}"`);
201
238
  }, "Error updating note"));
202
239
  // --- delete-note ---
203
- server.tool("delete-note", noteTitleSchema, withErrorHandling(({ title, account }) => {
240
+ server.tool("delete-note", {
241
+ id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
242
+ title: z.string().optional().describe("Note title (use id instead when available)"),
243
+ account: z
244
+ .string()
245
+ .optional()
246
+ .describe("Account name (defaults to iCloud, ignored if id is provided)"),
247
+ }, withErrorHandling(({ id, title, account }) => {
248
+ // Prefer ID-based deletion if provided
249
+ if (id) {
250
+ const success = notesManager.deleteNoteById(id);
251
+ if (!success) {
252
+ return errorResponse(`Failed to delete note with ID "${id}". Note may not exist.`);
253
+ }
254
+ return successResponse(`Note deleted (by ID)`);
255
+ }
256
+ // Fall back to title-based deletion
257
+ if (!title) {
258
+ return errorResponse("Either 'id' or 'title' is required");
259
+ }
204
260
  const success = notesManager.deleteNote(title, account);
205
261
  if (!success) {
206
262
  return errorResponse(`Failed to delete note "${title}". Note may not exist.`);
@@ -209,10 +265,23 @@ server.tool("delete-note", noteTitleSchema, withErrorHandling(({ title, account
209
265
  }, "Error deleting note"));
210
266
  // --- move-note ---
211
267
  server.tool("move-note", {
212
- title: z.string().min(1, "Note title is required"),
268
+ id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
269
+ title: z.string().optional().describe("Note title (use id instead when available)"),
213
270
  folder: z.string().min(1, "Destination folder is required"),
214
- account: z.string().optional().describe("Account containing the note"),
215
- }, withErrorHandling(({ title, folder, account }) => {
271
+ account: z.string().optional().describe("Account containing the note/folder"),
272
+ }, withErrorHandling(({ id, title, folder, account }) => {
273
+ // Prefer ID-based move if provided
274
+ if (id) {
275
+ const success = notesManager.moveNoteById(id, folder, account);
276
+ if (!success) {
277
+ return errorResponse(`Failed to move note with ID "${id}" to folder "${folder}". Note or folder may not exist.`);
278
+ }
279
+ return successResponse(`Note moved to "${folder}" (by ID)`);
280
+ }
281
+ // Fall back to title-based move
282
+ if (!title) {
283
+ return errorResponse("Either 'id' or 'title' is required");
284
+ }
216
285
  const success = notesManager.moveNote(title, folder, account);
217
286
  if (!success) {
218
287
  return errorResponse(`Failed to move note "${title}" to folder "${folder}". Note or folder may not exist.`);
@@ -46,16 +46,23 @@ export function escapeForAppleScript(text) {
46
46
  if (!text) {
47
47
  return "";
48
48
  }
49
- // Step 1: Escape single quotes for shell embedding
50
- // When we run: osascript -e 'tell app...'
51
- // Any single quotes in the script need special handling
52
- // Pattern: ' becomes '\'' (end quote, escaped quote, begin quote)
53
- let escaped = text.replace(/'/g, "'\\''");
54
- // Step 2: Escape double quotes for AppleScript strings
55
- // AppleScript uses: "hello \"quoted\" world"
49
+ // Content goes inside AppleScript double-quoted strings: body:"content here"
50
+ // Within double-quoted AppleScript strings, we need to escape:
51
+ // 1. Backslashes (\ \\) - AppleScript escape character
52
+ // 2. Double quotes (" \") - String delimiter
53
+ // Single quotes do NOT need escaping in double-quoted AppleScript strings.
54
+ // Step 1: Encode HTML ampersands FIRST (before adding any HTML entities)
55
+ let escaped = text.replace(/&/g, "&");
56
+ // Step 2: Encode backslashes as HTML entities
57
+ // This avoids AppleScript escaping issues since Notes stores HTML
58
+ // Must happen AFTER ampersand encoding (so \ doesn't become \)
59
+ // and BEFORE double-quote escaping (so \" doesn't become \")
60
+ escaped = escaped.replace(/\\/g, "\");
61
+ // Step 3: Escape double quotes for AppleScript strings
62
+ // The backslash in \" is for AppleScript, not content, so it's added AFTER
63
+ // backslash encoding to avoid being HTML-encoded
56
64
  escaped = escaped.replace(/"/g, '\\"');
57
- // Step 3: Convert control characters to HTML for Notes.app
58
- // Apple Notes stores content as HTML, so we convert:
65
+ // Step 4: Convert control characters to HTML for Notes.app
59
66
  // - Newlines (\n) to <br> tags
60
67
  // - Tabs (\t) to <br> tags (better than &nbsp; for readability)
61
68
  escaped = escaped.replace(/\n/g, "<br>");
@@ -255,15 +262,22 @@ export class AppleNotesManager {
255
262
  const safeContent = escapeForAppleScript(content);
256
263
  // Build the AppleScript command
257
264
  // Notes.app uses 'name' for the title and 'body' for content
265
+ // We capture the ID of the newly created note
258
266
  let createCommand;
259
267
  if (folder) {
260
268
  // Create note in specific folder
261
269
  const safeFolder = escapeForAppleScript(folder);
262
- createCommand = `make new note at folder "${safeFolder}" with properties {name:"${safeTitle}", body:"${safeContent}"}`;
270
+ createCommand = `
271
+ set newNote to make new note at folder "${safeFolder}" with properties {name:"${safeTitle}", body:"${safeContent}"}
272
+ return id of newNote
273
+ `;
263
274
  }
264
275
  else {
265
276
  // Create note in default location
266
- createCommand = `make new note with properties {name:"${safeTitle}", body:"${safeContent}"}`;
277
+ createCommand = `
278
+ set newNote to make new note with properties {name:"${safeTitle}", body:"${safeContent}"}
279
+ return id of newNote
280
+ `;
267
281
  }
268
282
  // Execute the script
269
283
  const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
@@ -272,11 +286,12 @@ export class AppleNotesManager {
272
286
  console.error(`Failed to create note "${title}":`, result.error);
273
287
  return null;
274
288
  }
275
- // Return a Note object representing the created note
276
- // Note: We use a timestamp as ID since we can't easily get the real ID
289
+ // Extract the CoreData ID from the response
290
+ const noteId = result.output.trim();
291
+ // Return a Note object representing the created note with real ID
277
292
  const now = new Date();
278
293
  return {
279
- id: Date.now().toString(),
294
+ id: noteId || Date.now().toString(), // Use real ID, fallback to timestamp
280
295
  title,
281
296
  content,
282
297
  tags,
@@ -314,15 +329,25 @@ export class AppleNotesManager {
314
329
  const whereClause = searchContent
315
330
  ? `body contains "${safeQuery}"`
316
331
  : `name contains "${safeQuery}"`;
317
- // Get names and folder for each matching note
318
- // We use a repeat loop to get both properties, separated by a delimiter
332
+ // Get names, IDs, and folder for each matching note
333
+ // We use a repeat loop to get all properties, separated by a delimiter
334
+ // Note: Some notes may have inaccessible containers, so we wrap in try/on error
319
335
  const searchCommand = `
320
336
  set matchingNotes to notes where ${whereClause}
321
337
  set resultList to {}
322
338
  repeat with n in matchingNotes
323
- set noteName to name of n
324
- set noteFolder to name of container of n
325
- set end of resultList to noteName & "|||" & noteFolder
339
+ try
340
+ set noteName to name of n
341
+ set noteId to id of n
342
+ set noteFolder to name of container of n
343
+ set end of resultList to noteName & "|||" & noteId & "|||" & noteFolder
344
+ on error
345
+ try
346
+ set noteName to name of n
347
+ set noteId to id of n
348
+ set end of resultList to noteName & "|||" & noteId & "|||" & "Notes"
349
+ end try
350
+ end try
326
351
  end repeat
327
352
  set AppleScript's text item delimiters to "|||ITEM|||"
328
353
  return resultList as text
@@ -337,15 +362,15 @@ export class AppleNotesManager {
337
362
  if (!result.output.trim()) {
338
363
  return [];
339
364
  }
340
- // Parse the delimited output: "name|||folder|||ITEM|||name|||folder..."
365
+ // Parse the delimited output: "name|||id|||folder|||ITEM|||name|||id|||folder..."
341
366
  const items = result.output.split("|||ITEM|||");
342
367
  const notes = [];
343
368
  for (const item of items) {
344
- const [title, folder] = item.split("|||");
369
+ const [title, id, folder] = item.split("|||");
345
370
  if (!title?.trim())
346
371
  continue;
347
372
  notes.push({
348
- id: Date.now().toString(),
373
+ id: id?.trim() || Date.now().toString(), // Use real ID, fallback to timestamp
349
374
  title: title.trim(),
350
375
  content: "", // Not fetched in search
351
376
  tags: [],
@@ -385,6 +410,26 @@ export class AppleNotesManager {
385
410
  }
386
411
  return result.output;
387
412
  }
413
+ /**
414
+ * Retrieves the HTML content of a note by its CoreData ID.
415
+ *
416
+ * This is more reliable than getNoteContent() because IDs are unique
417
+ * across all accounts, while titles can be duplicated.
418
+ *
419
+ * @param id - CoreData URL identifier for the note
420
+ * @returns HTML content of the note, or empty string if not found
421
+ */
422
+ getNoteContentById(id) {
423
+ // Note IDs work at the application level, not scoped to account
424
+ const getCommand = `get body of note id "${id}"`;
425
+ const script = buildAppLevelScript(getCommand);
426
+ const result = executeAppleScript(script);
427
+ if (!result.success) {
428
+ console.error(`Failed to get content of note with ID "${id}":`, result.error);
429
+ return "";
430
+ }
431
+ return result.output;
432
+ }
388
433
  /**
389
434
  * Retrieves a note by its unique CoreData ID.
390
435
  *
@@ -518,6 +563,25 @@ export class AppleNotesManager {
518
563
  }
519
564
  return true;
520
565
  }
566
+ /**
567
+ * Deletes a note by its CoreData ID.
568
+ *
569
+ * This is more reliable than deleteNote() because IDs are unique
570
+ * across all accounts, while titles can be duplicated.
571
+ *
572
+ * @param id - CoreData URL identifier for the note
573
+ * @returns true if deletion succeeded, false otherwise
574
+ */
575
+ deleteNoteById(id) {
576
+ const deleteCommand = `delete note id "${id}"`;
577
+ const script = buildAppLevelScript(deleteCommand);
578
+ const result = executeAppleScript(script);
579
+ if (!result.success) {
580
+ console.error(`Failed to delete note with ID "${id}":`, result.error);
581
+ return false;
582
+ }
583
+ return true;
584
+ }
521
585
  /**
522
586
  * Updates an existing note's content and optionally its title.
523
587
  *
@@ -549,6 +613,41 @@ export class AppleNotesManager {
549
613
  }
550
614
  return true;
551
615
  }
616
+ /**
617
+ * Updates an existing note by its CoreData ID.
618
+ *
619
+ * This is more reliable than updateNote() because IDs are unique,
620
+ * while titles can be duplicated.
621
+ *
622
+ * @param id - CoreData URL identifier for the note
623
+ * @param newTitle - New title (optional, keeps existing if not provided)
624
+ * @param newContent - New content for the note body
625
+ * @returns true if update succeeded, false otherwise
626
+ */
627
+ updateNoteById(id, newTitle, newContent) {
628
+ // First get the current title if newTitle is not provided
629
+ let effectiveTitle = newTitle;
630
+ if (!effectiveTitle) {
631
+ const note = this.getNoteById(id);
632
+ if (!note) {
633
+ console.error(`Cannot update note: note with ID "${id}" not found`);
634
+ return false;
635
+ }
636
+ effectiveTitle = note.title;
637
+ }
638
+ const safeEffectiveTitle = escapeForAppleScript(effectiveTitle);
639
+ const safeContent = escapeForAppleScript(newContent);
640
+ // Apple Notes uses HTML body; first <div> becomes the title
641
+ const fullBody = `<div>${safeEffectiveTitle}</div><div>${safeContent}</div>`;
642
+ const updateCommand = `set body of note id "${id}" to "${fullBody}"`;
643
+ const script = buildAppLevelScript(updateCommand);
644
+ const result = executeAppleScript(script);
645
+ if (!result.success) {
646
+ console.error(`Failed to update note with ID "${id}":`, result.error);
647
+ return false;
648
+ }
649
+ return true;
650
+ }
552
651
  /**
553
652
  * Lists all notes in an account, optionally filtered by folder.
554
653
  *
@@ -667,13 +766,19 @@ export class AppleNotesManager {
667
766
  */
668
767
  moveNote(title, destinationFolder, account) {
669
768
  const targetAccount = this.resolveAccount(account);
670
- // Step 1: Retrieve the original note's content
769
+ // Step 1: Get the original note's ID first (before creating a copy with the same title)
770
+ const originalNote = this.getNoteDetails(title, targetAccount);
771
+ if (!originalNote) {
772
+ console.error(`Cannot move note "${title}": note not found`);
773
+ return false;
774
+ }
775
+ // Step 2: Retrieve the original note's content
671
776
  const originalContent = this.getNoteContent(title, targetAccount);
672
777
  if (!originalContent) {
673
778
  console.error(`Cannot move note "${title}": failed to retrieve content`);
674
779
  return false;
675
780
  }
676
- // Step 2: Create a copy in the destination folder
781
+ // Step 3: Create a copy in the destination folder
677
782
  // We need to escape the HTML content for AppleScript embedding
678
783
  const safeFolder = escapeForAppleScript(destinationFolder);
679
784
  const safeContent = originalContent.replace(/"/g, '\\"').replace(/'/g, "'\\''");
@@ -684,16 +789,57 @@ export class AppleNotesManager {
684
789
  console.error(`Cannot move note "${title}": failed to create in destination folder:`, copyResult.error);
685
790
  return false;
686
791
  }
687
- // Step 3: Delete the original (only after successful copy)
688
- const deleteSuccess = this.deleteNote(title, targetAccount);
689
- if (!deleteSuccess) {
792
+ // Step 4: Delete the original by ID (not by title, since there are now two notes with the same title)
793
+ const deleteCommand = `delete note id "${originalNote.id}"`;
794
+ const deleteScript = buildAppLevelScript(deleteCommand);
795
+ const deleteResult = executeAppleScript(deleteScript);
796
+ if (!deleteResult.success) {
690
797
  // The note was copied successfully but we couldn't delete the original.
691
798
  // This is still a partial success - the note exists in the new location.
692
- console.error(`Note "${title}" was copied to "${destinationFolder}" but original could not be deleted`);
799
+ console.error(`Note "${title}" was copied to "${destinationFolder}" but original could not be deleted:`, deleteResult.error);
693
800
  return true;
694
801
  }
695
802
  return true;
696
803
  }
804
+ /**
805
+ * Moves a note to a different folder by its CoreData ID.
806
+ *
807
+ * This is more reliable than moveNote() because IDs are unique,
808
+ * while titles can be duplicated.
809
+ *
810
+ * @param id - CoreData URL identifier for the note
811
+ * @param destinationFolder - Name of the folder to move to
812
+ * @param account - Account containing the destination folder (defaults to iCloud)
813
+ * @returns true if move succeeded (or copy succeeded but delete failed)
814
+ */
815
+ moveNoteById(id, destinationFolder, account) {
816
+ const targetAccount = this.resolveAccount(account);
817
+ // Step 1: Retrieve the original note's content by ID
818
+ const originalContent = this.getNoteContentById(id);
819
+ if (!originalContent) {
820
+ console.error(`Cannot move note: note with ID "${id}" not found`);
821
+ return false;
822
+ }
823
+ // Step 2: Create a copy in the destination folder
824
+ const safeFolder = escapeForAppleScript(destinationFolder);
825
+ const safeContent = originalContent.replace(/"/g, '\\"').replace(/'/g, "'\\''");
826
+ const createCommand = `make new note at folder "${safeFolder}" with properties {body:"${safeContent}"}`;
827
+ const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
828
+ const copyResult = executeAppleScript(script);
829
+ if (!copyResult.success) {
830
+ console.error(`Cannot move note: failed to create in destination folder:`, copyResult.error);
831
+ return false;
832
+ }
833
+ // Step 3: Delete the original by ID
834
+ const deleteCommand = `delete note id "${id}"`;
835
+ const deleteScript = buildAppLevelScript(deleteCommand);
836
+ const deleteResult = executeAppleScript(deleteScript);
837
+ if (!deleteResult.success) {
838
+ console.error(`Note was copied to "${destinationFolder}" but original could not be deleted:`, deleteResult.error);
839
+ return true; // Partial success - note exists in new location
840
+ }
841
+ return true;
842
+ }
697
843
  // ===========================================================================
698
844
  // Account Operations
699
845
  // ===========================================================================
@@ -32,16 +32,15 @@ describe("escapeForAppleScript", () => {
32
32
  expect(escapeForAppleScript(undefined)).toBe("");
33
33
  });
34
34
  });
35
- describe("single quote escaping (shell safety)", () => {
36
- it("escapes single quotes for shell embedding", () => {
37
- // Single quotes in: osascript -e 'tell app...'
38
- // Need to become: '\'' (end quote, escaped quote, start quote)
35
+ describe("single quote handling", () => {
36
+ it("preserves single quotes (no escaping needed in AppleScript double-quoted strings)", () => {
37
+ // Single quotes don't need escaping inside AppleScript double-quoted strings
39
38
  const result = escapeForAppleScript("it's working");
40
- expect(result).toBe("it'\\''s working");
39
+ expect(result).toBe("it's working");
41
40
  });
42
41
  it("handles multiple single quotes", () => {
43
42
  const result = escapeForAppleScript("Rob's mom's note");
44
- expect(result).toBe("Rob'\\''s mom'\\''s note");
43
+ expect(result).toBe("Rob's mom's note");
45
44
  });
46
45
  });
47
46
  describe("double quote escaping (AppleScript strings)", () => {
@@ -52,7 +51,7 @@ describe("escapeForAppleScript", () => {
52
51
  });
53
52
  it("handles mixed quotes", () => {
54
53
  const result = escapeForAppleScript('He said "it\'s fine"');
55
- expect(result).toBe("He said \\\"it'\\''s fine\\\"");
54
+ expect(result).toBe('He said \\"it\'s fine\\"');
56
55
  });
57
56
  });
58
57
  describe("control character conversion (HTML for Notes.app)", () => {
@@ -73,7 +72,7 @@ describe("escapeForAppleScript", () => {
73
72
  it("handles real-world note content", () => {
74
73
  const content = 'John\'s "Meeting Notes"\n- Item 1\n- Item 2';
75
74
  const result = escapeForAppleScript(content);
76
- expect(result).toBe("John'\\''s \\\"Meeting Notes\\\"<br>- Item 1<br>- Item 2");
75
+ expect(result).toBe('John\'s \\"Meeting Notes\\"<br>- Item 1<br>- Item 2');
77
76
  });
78
77
  });
79
78
  describe("unicode and special characters", () => {
@@ -90,29 +89,34 @@ describe("escapeForAppleScript", () => {
90
89
  expect(result).toBe("Café résumé naïve");
91
90
  });
92
91
  it("handles backslashes", () => {
92
+ // Backslashes are HTML-encoded to avoid AppleScript escaping issues
93
93
  const result = escapeForAppleScript("path\\to\\file");
94
- expect(result).toBe("path\\to\\file");
94
+ expect(result).toBe("path&#92;to&#92;file");
95
+ });
96
+ it("handles ampersands", () => {
97
+ // Ampersands are HTML-encoded for Notes.app (& becomes &amp;)
98
+ const result = escapeForAppleScript("A && B & C");
99
+ expect(result).toBe("A &amp;&amp; B &amp; C");
95
100
  });
96
101
  it("handles angle brackets (HTML-like content)", () => {
97
- // Single quotes become '\'' (shell escape pattern)
102
+ // Single quotes pass through unchanged
98
103
  const result = escapeForAppleScript("<script>alert('xss')</script>");
99
- expect(result).toBe("<script>alert('\\''xss'\\'')</script>");
104
+ expect(result).toBe("<script>alert('xss')</script>");
100
105
  });
101
106
  });
102
107
  describe("boundary conditions", () => {
103
108
  it("handles very short strings", () => {
104
109
  expect(escapeForAppleScript("a")).toBe("a");
105
- expect(escapeForAppleScript("'")).toBe("'\\''");
110
+ expect(escapeForAppleScript("'")).toBe("'");
106
111
  expect(escapeForAppleScript('"')).toBe('\\"');
107
112
  });
108
113
  it("handles string with only whitespace", () => {
109
114
  expect(escapeForAppleScript(" ")).toBe(" ");
110
115
  });
111
116
  it("handles multiple consecutive special characters", () => {
112
- // Three single quotes become three '\'' sequences
113
- // Three double quotes become three \" sequences
117
+ // Single quotes pass through, double quotes are escaped
114
118
  const result = escapeForAppleScript("'''\"\"\"");
115
- expect(result).toBe("'\\'''\\'''\\''\\\"\\\"\\\"");
119
+ expect(result).toBe("'''\\\"\\\"\\\"");
116
120
  });
117
121
  });
118
122
  });
@@ -227,15 +231,18 @@ describe("AppleNotesManager", () => {
227
231
  it("returns array of matching notes with folder info", () => {
228
232
  mockExecuteAppleScript.mockReturnValue({
229
233
  success: true,
230
- output: "Meeting Notes|||Work|||ITEM|||Project Plan|||Notes|||ITEM|||Weekly Review|||Archive",
234
+ output: "Meeting Notes|||x-coredata://ABC/ICNote/p1|||Work|||ITEM|||Project Plan|||x-coredata://ABC/ICNote/p2|||Notes|||ITEM|||Weekly Review|||x-coredata://ABC/ICNote/p3|||Archive",
231
235
  });
232
236
  const results = manager.searchNotes("notes");
233
237
  expect(results).toHaveLength(3);
234
238
  expect(results[0].title).toBe("Meeting Notes");
239
+ expect(results[0].id).toBe("x-coredata://ABC/ICNote/p1");
235
240
  expect(results[0].folder).toBe("Work");
236
241
  expect(results[1].title).toBe("Project Plan");
242
+ expect(results[1].id).toBe("x-coredata://ABC/ICNote/p2");
237
243
  expect(results[1].folder).toBe("Notes");
238
244
  expect(results[2].title).toBe("Weekly Review");
245
+ expect(results[2].id).toBe("x-coredata://ABC/ICNote/p3");
239
246
  expect(results[2].folder).toBe("Archive");
240
247
  });
241
248
  it("returns empty array when no matches found", () => {
@@ -258,7 +265,7 @@ describe("AppleNotesManager", () => {
258
265
  it("searches content when searchContent is true", () => {
259
266
  mockExecuteAppleScript.mockReturnValue({
260
267
  success: true,
261
- output: "Note with keyword|||Notes",
268
+ output: "Note with keyword|||x-coredata://ABC/ICNote/p1|||Notes",
262
269
  });
263
270
  manager.searchNotes("project alpha", true);
264
271
  expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('body contains "project alpha"'));
@@ -266,7 +273,7 @@ describe("AppleNotesManager", () => {
266
273
  it("searches titles when searchContent is false", () => {
267
274
  mockExecuteAppleScript.mockReturnValue({
268
275
  success: true,
269
- output: "Project Alpha Notes|||Notes",
276
+ output: "Project Alpha Notes|||x-coredata://ABC/ICNote/p1|||Notes",
270
277
  });
271
278
  manager.searchNotes("Project Alpha", false);
272
279
  expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('name contains "Project Alpha"'));
@@ -274,13 +281,15 @@ describe("AppleNotesManager", () => {
274
281
  it("identifies notes in Recently Deleted folder", () => {
275
282
  mockExecuteAppleScript.mockReturnValue({
276
283
  success: true,
277
- output: "Old Note|||Recently Deleted|||ITEM|||Active Note|||Notes",
284
+ output: "Old Note|||x-coredata://ABC/ICNote/p1|||Recently Deleted|||ITEM|||Active Note|||x-coredata://ABC/ICNote/p2|||Notes",
278
285
  });
279
286
  const results = manager.searchNotes("note");
280
287
  expect(results).toHaveLength(2);
281
288
  expect(results[0].title).toBe("Old Note");
289
+ expect(results[0].id).toBe("x-coredata://ABC/ICNote/p1");
282
290
  expect(results[0].folder).toBe("Recently Deleted");
283
291
  expect(results[1].title).toBe("Active Note");
292
+ expect(results[1].id).toBe("x-coredata://ABC/ICNote/p2");
284
293
  expect(results[1].folder).toBe("Notes");
285
294
  });
286
295
  it("scopes search to specified account", () => {
@@ -592,8 +601,12 @@ describe("AppleNotesManager", () => {
592
601
  // ---------------------------------------------------------------------------
593
602
  describe("moveNote", () => {
594
603
  it("returns true when move completes successfully", () => {
595
- // Mock sequence: getNoteContent -> createNote -> deleteNote
604
+ // Mock sequence: getNoteDetails -> getNoteContent -> createNote -> deleteNote
596
605
  mockExecuteAppleScript
606
+ .mockReturnValueOnce({
607
+ success: true,
608
+ output: "My Note, x-coredata://ABC/ICNote/p123, date Monday January 1 2024, date Monday January 1 2024, false, false",
609
+ })
597
610
  .mockReturnValueOnce({
598
611
  success: true,
599
612
  output: "<div>Note Title</div><div>Content</div>",
@@ -608,9 +621,9 @@ describe("AppleNotesManager", () => {
608
621
  });
609
622
  const result = manager.moveNote("My Note", "Archive");
610
623
  expect(result).toBe(true);
611
- expect(mockExecuteAppleScript).toHaveBeenCalledTimes(3);
624
+ expect(mockExecuteAppleScript).toHaveBeenCalledTimes(4);
612
625
  });
613
- it("returns false when source note cannot be read", () => {
626
+ it("returns false when source note cannot be found", () => {
614
627
  mockExecuteAppleScript.mockReturnValueOnce({
615
628
  success: false,
616
629
  output: "",
@@ -618,10 +631,14 @@ describe("AppleNotesManager", () => {
618
631
  });
619
632
  const result = manager.moveNote("Missing Note", "Archive");
620
633
  expect(result).toBe(false);
621
- expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1); // Only tried to read
634
+ expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1); // Only tried to get details
622
635
  });
623
636
  it("returns false when copy to destination fails", () => {
624
637
  mockExecuteAppleScript
638
+ .mockReturnValueOnce({
639
+ success: true,
640
+ output: "My Note, x-coredata://ABC/ICNote/p123, date Monday January 1 2024, date Monday January 1 2024, false, false",
641
+ })
625
642
  .mockReturnValueOnce({
626
643
  success: true,
627
644
  output: "<div>Content</div>",
@@ -633,11 +650,15 @@ describe("AppleNotesManager", () => {
633
650
  });
634
651
  const result = manager.moveNote("My Note", "Nonexistent Folder");
635
652
  expect(result).toBe(false);
636
- expect(mockExecuteAppleScript).toHaveBeenCalledTimes(2); // Read + failed create
653
+ expect(mockExecuteAppleScript).toHaveBeenCalledTimes(3); // Details + Read + failed create
637
654
  });
638
655
  it("returns true even if delete fails (note exists in new location)", () => {
639
656
  // This is partial success - note was copied but original couldn't be deleted
640
657
  mockExecuteAppleScript
658
+ .mockReturnValueOnce({
659
+ success: true,
660
+ output: "My Note, x-coredata://ABC/ICNote/p123, date Monday January 1 2024, date Monday January 1 2024, false, false",
661
+ })
641
662
  .mockReturnValueOnce({
642
663
  success: true,
643
664
  output: "<div>Content</div>",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude",
5
5
  "type": "module",
6
6
  "main": "build/index.js",