apple-notes-mcp 2.1.0 → 2.1.2

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
@@ -6,6 +6,10 @@ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that e
6
6
  [![CI](https://github.com/sweetrb/apple-notes-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/sweetrb/apple-notes-mcp/actions/workflows/ci.yml)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
8
 
9
+ <p align="center">
10
+ <img src="codex/assets/screenshot.png" alt="Apple Notes MCP — create, search, and organize Apple Notes from Codex, Claude, and other AI assistants" width="680">
11
+ </p>
12
+
9
13
  ## What is This?
10
14
 
11
15
  This server acts as a bridge between AI assistants and Apple Notes. Once configured, you can ask Claude (or any MCP-compatible AI) to:
@@ -52,6 +56,10 @@ codex plugin add apple-notes@apple-notes-mcp
52
56
 
53
57
  The Codex plugin runs the published `apple-notes-mcp` server through `npx` and ships the same Apple Notes skill, so behavior matches the Claude Code plugin.
54
58
 
59
+ ### Other Hosts (Hermes, Antigravity)
60
+
61
+ Plugin packaging for the Hermes and Antigravity hosts is also included (`.hermes-plugin/` and `.antigravity-plugin/`). Each registers the same `apple-notes` MCP server (launched via `npx -y apple-notes-mcp`) and bundles the Apple Notes skill, so behavior matches the Claude Code and Codex plugins. Install them through each host's plugin/marketplace mechanism pointed at this repository.
62
+
55
63
  ### Manual Installation
56
64
 
57
65
  **1. Install the server:**
@@ -95,11 +103,12 @@ On first use, macOS will ask for permission to automate Notes.app. Click "OK" to
95
103
  | **Delete Notes** | Remove notes (moves to Recently Deleted) |
96
104
  | **Move Notes** | Organize notes into folders (supports nested paths) |
97
105
  | **Folder Management** | Create, list, and delete folders with full hierarchical path support |
98
- | **Multi-Account** | Work with iCloud, Gmail, Exchange, or any configured account |
106
+ | **Multi-Account** | Work with iCloud, Gmail, Exchange, or any configured account, including account IDs and default folders |
99
107
  | **Batch Operations** | Delete or move multiple notes at once |
100
108
  | **Checklist State** | Read checklist done/undone state directly from the Notes database (requires Full Disk Access) |
101
109
  | **Export** | Export all notes as JSON or get individual notes as Markdown |
102
110
  | **Attachments** | List attachments, save them to disk, or fetch their bytes as base64 |
111
+ | **Notes.app UI State** | Reveal a note in Notes.app or read the current Notes.app selection |
103
112
  | **Sync Awareness** | Detect iCloud sync in progress, warn about incomplete results |
104
113
  | **Collaboration** | Detect shared notes, warn before modifying |
105
114
  | **Diagnostics** | `health-check` plus a richer `doctor` (reachability, automation permission, accounts, Full Disk Access), sync status, and statistics |
@@ -294,6 +303,19 @@ Retrieves a note using its unique CoreData identifier.
294
303
 
295
304
  ---
296
305
 
306
+ #### `show-note`
307
+
308
+ Reveals a note in Notes.app using its unique CoreData identifier.
309
+
310
+ | Parameter | Type | Required | Description |
311
+ |-----------|------|----------|-------------|
312
+ | `id` | string | Yes | The CoreData URL identifier (e.g., `x-coredata://...`) |
313
+ | `separately` | boolean | No | Open in a separate note window when supported by Notes.app |
314
+
315
+ **Returns:** Confirmation that Notes.app accepted the show command.
316
+
317
+ ---
318
+
297
319
  #### `update-note`
298
320
 
299
321
  Updates an existing note's content and/or title.
@@ -345,6 +367,8 @@ Updates an existing note's content and/or title.
345
367
 
346
368
  **Returns:** Confirmation message, or error if note not found.
347
369
 
370
+ **Note:** `newContent` **replaces the entire note body** — it is not appended. To preserve existing content, read it first (e.g. with `get-note-content`) and include it in `newContent`.
371
+
348
372
  ---
349
373
 
350
374
  #### `delete-note`
@@ -375,6 +399,8 @@ Deletes a note (moves to Recently Deleted in Notes.app).
375
399
 
376
400
  **Returns:** Confirmation message, or error if note not found.
377
401
 
402
+ **⚠️ Safety:** Irreversible from the agent's side — requires explicit user confirmation before calling. Prefer `search-notes` / `list-notes` first to confirm the exact id(s) being deleted.
403
+
378
404
  ---
379
405
 
380
406
  #### `move-note`
@@ -447,6 +473,16 @@ Lists all notes, optionally filtered by folder, date, and limit.
447
473
 
448
474
  ---
449
475
 
476
+ #### `get-selected-notes`
477
+
478
+ Reads the currently selected note(s) from the Notes.app UI.
479
+
480
+ **Parameters:** None
481
+
482
+ **Returns:** Selected note metadata, including IDs for follow-up operations. Returns an empty list when Notes.app has no selected note.
483
+
484
+ ---
485
+
450
486
  ### Folder Operations
451
487
 
452
488
  #### `list-folders`
@@ -462,7 +498,7 @@ Lists all folders in an account with full hierarchical paths.
462
498
  {}
463
499
  ```
464
500
 
465
- **Returns:** List of folder paths. Nested folders are shown as full paths (e.g., `Work/Clients/Omnia`). Duplicate folder names are disambiguated by their full path. Literal slashes in folder names are escaped as `\/` (e.g., `Spain\/Portugal 2023`).
501
+ **Returns:** List of folders with IDs, paths, account names, and shared state. Nested folders are shown as full paths (e.g., `Work/Clients/Omnia`). Duplicate folder names are disambiguated by their full path. Literal slashes in folder names are escaped as `\/` (e.g., `Spain\/Portugal 2023`).
466
502
 
467
503
  ---
468
504
 
@@ -504,6 +540,8 @@ Deletes a folder.
504
540
 
505
541
  **Returns:** Confirmation message, or error if folder not found or not empty.
506
542
 
543
+ **⚠️ Safety:** Irreversible — requires explicit user confirmation before calling. Prefer `list-folders` first to confirm the exact folder path being deleted.
544
+
507
545
  ---
508
546
 
509
547
  ### Account Operations
@@ -519,7 +557,17 @@ Lists all configured Notes accounts.
519
557
  {}
520
558
  ```
521
559
 
522
- **Returns:** List of account names (e.g., "iCloud", "Gmail", "Exchange").
560
+ **Returns:** List of accounts with names, IDs, upgraded state, and default folder metadata.
561
+
562
+ ---
563
+
564
+ #### `get-default-location`
565
+
566
+ Returns the default account and folder Notes.app uses for newly created notes.
567
+
568
+ **Parameters:** None
569
+
570
+ **Returns:** Default account and folder metadata, including IDs and shared state.
523
571
 
524
572
  ---
525
573
 
@@ -535,6 +583,8 @@ Deletes multiple notes at once by ID.
535
583
 
536
584
  **Returns:** Summary of successes and failures.
537
585
 
586
+ **⚠️ Safety:** Irreversible — requires explicit user confirmation before calling. Prefer `search-notes` / `list-notes` first to confirm the exact ids being deleted.
587
+
538
588
  ---
539
589
 
540
590
  #### `batch-move-notes`
@@ -615,7 +665,7 @@ Lists attachments in a note.
615
665
  | `title` | string | No | Note title |
616
666
  | `account` | string | No | Account containing the note |
617
667
 
618
- **Returns:** List of attachments with names and content types.
668
+ **Returns:** List of attachments with IDs, names, content identifiers, URLs when available, created/modified dates, and shared state.
619
669
 
620
670
  ---
621
671
 
@@ -676,6 +726,8 @@ Gets comprehensive statistics about your notes.
676
726
 
677
727
  **Returns:** Total counts, per-account breakdown, folder statistics, and recently modified counts.
678
728
 
729
+ The `structuredContent` also includes a `coverage` object — `{ complete, scanned, covered, warnings[] }`. If `complete` is `false`, one or more accounts (or the recent-activity scan) could not be read and the counts reflect only the scopes that succeeded; the text output adds a "⚠️ Partial results" line. This lets you tell a genuinely empty library apart from a partial failure.
730
+
679
731
  ---
680
732
 
681
733
  #### `get-sync-status`
@@ -1005,6 +1057,12 @@ Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for gui
1005
1057
 
1006
1058
  ## Related Projects
1007
1059
 
1008
- - [apple-mail-mcp](https://github.com/sweetrb/apple-mail-mcp) MCP server for Apple Mail
1009
- - [apple-numbers-mcp](https://github.com/sweetrb/apple-numbers-mcp) — MCP server for Apple Numbers spreadsheets
1010
- - [apple-photos-mcp](https://github.com/sweetrb/apple-photos-mcp) — MCP server for Apple Photos
1060
+ Part of a family of macOS MCP servers:
1061
+
1062
+ - [apple-mail-mcp](https://github.com/sweetrb/apple-mail-mcp) — MCP server for Apple Mail (read, search, send, and organize email)
1063
+ - [apple-numbers-mcp](https://github.com/sweetrb/apple-numbers-mcp) — MCP server for Apple Numbers (read and write .numbers spreadsheets)
1064
+ - [apple-photos-mcp](https://github.com/sweetrb/apple-photos-mcp) — MCP server for Apple Photos (query metadata and export originals)
1065
+
1066
+ ## Recurring macOS permission prompts
1067
+
1068
+ If macOS keeps re-prompting for Full Disk Access or Automation for `node` (often after a `brew upgrade`), see [docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md](docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md) — the fix is to run this server under the official, Developer-ID-signed Node so the grant survives Node updates.
package/build/index.js CHANGED
@@ -108,7 +108,7 @@ const folderNameSchema = {
108
108
  // Note Tools
109
109
  // =============================================================================
110
110
  // --- create-note ---
111
- server.tool("create-note", {
111
+ server.tool("create-note", "Use when: the user wants to create a brand-new Apple Note.\nReturns: the new note's title and id — reuse the id for follow-up reads/edits.\nDo not use when: editing an existing note (use update-note).\nNote: the title is prepended as an <h1>; true Apple Notes checklists cannot be created via AppleScript (see the content field).", {
112
112
  title: z.string().min(1, "Title is required"),
113
113
  content: z
114
114
  .string()
@@ -134,7 +134,7 @@ server.tool("create-note", {
134
134
  return successResponse(`Note created: "${note.title}" [id: ${note.id}]${checklistWarning}`);
135
135
  }, "Error creating note"));
136
136
  // --- search-notes ---
137
- server.tool("search-notes", {
137
+ server.tool("search-notes", "Use when: finding notes by a keyword in the title (or body with searchContent=true) and you need their ids.\nReturns: matching notes with title, folder, and id.\nDo not use when: you already have a note id (use get-note-content) or want every note (use list-notes).\nPrefer this first to obtain ids for subsequent read/update/delete/move calls.", {
138
138
  query: z.string().min(1, "Search query is required"),
139
139
  searchContent: z.boolean().optional().describe("Search note content instead of titles"),
140
140
  account: z.string().optional().describe("Account to search in"),
@@ -179,7 +179,7 @@ server.tool("search-notes", {
179
179
  return successResponse(`Found ${notes.length} notes (searched ${searchType}${folderInfo}${dateInfo}${limitInfo}):\n${noteList}${syncNote}`, { notes, count: notes.length });
180
180
  }, "Error searching notes"));
181
181
  // --- get-note-content ---
182
- server.tool("get-note-content", {
182
+ server.tool("get-note-content", "Use when: reading the full body text of one known note, by id (preferred) or title.\nReturns: the note's content plus parsed hashtags.\nDo not use when: you only need metadata (get-note-details) or Markdown with checklist state (get-note-markdown).\nNote: password-protected notes must be unlocked in Notes.app first.", {
183
183
  id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
184
184
  title: z.string().optional().describe("Note title (use id instead when available)"),
185
185
  account: z
@@ -224,7 +224,7 @@ server.tool("get-note-content", {
224
224
  return successResponse(content, { title, content, hashtags });
225
225
  }, "Error retrieving note content"));
226
226
  // --- get-note-by-id ---
227
- server.tool("get-note-by-id", {
227
+ server.tool("get-note-by-id", "Use when: you have a note id and need its metadata only.\nReturns: id, title, created, modified, shared, passwordProtected.\nDo not use when: you need the body text (get-note-content) or only have a title (get-note-details).", {
228
228
  id: z.string().min(1, "Note ID is required"),
229
229
  }, withErrorHandling(({ id }) => {
230
230
  const note = notesManager.getNoteById(id);
@@ -243,7 +243,7 @@ server.tool("get-note-by-id", {
243
243
  return successResponse(JSON.stringify(metadata, null, 2), metadata);
244
244
  }, "Error retrieving note"));
245
245
  // --- get-note-details ---
246
- server.tool("get-note-details", noteTitleSchema, withErrorHandling(({ title, account }) => {
246
+ server.tool("get-note-details", "Use when: you have a note title (not an id) and need its metadata.\nReturns: id, title, created, modified, shared, passwordProtected, account.\nDo not use when: you have an id (get-note-by-id) or need the body text (get-note-content).\nUse the returned id for reliable follow-up operations.", noteTitleSchema, withErrorHandling(({ title, account }) => {
247
247
  const note = notesManager.getNoteDetails(title, account);
248
248
  if (!note) {
249
249
  return errorResponse(`Note "${title}" not found`);
@@ -260,8 +260,22 @@ server.tool("get-note-details", noteTitleSchema, withErrorHandling(({ title, acc
260
260
  };
261
261
  return successResponse(JSON.stringify(metadata, null, 2), metadata);
262
262
  }, "Error retrieving note details"));
263
+ // --- show-note ---
264
+ server.tool("show-note", "Use when: the user wants to reveal a known note in Notes.app by id.\nReturns: confirmation that Notes.app accepted the show command.\nDo not use when: you only need note content (get-note-content) or metadata (get-note-by-id).\nNote: this opens or focuses the Notes UI.", {
265
+ id: z.string().min(1, "Note ID is required"),
266
+ separately: z
267
+ .boolean()
268
+ .optional()
269
+ .describe("Open in a separate note window when supported by Notes.app"),
270
+ }, withErrorHandling(({ id, separately = false }) => {
271
+ const success = notesManager.showNoteById(id, separately);
272
+ if (!success) {
273
+ return errorResponse(`Failed to show note with ID "${id}"`);
274
+ }
275
+ return successResponse(`Shown note with ID "${id}" in Notes.app`, { id, separately });
276
+ }, "Error showing note"));
263
277
  // --- update-note ---
264
- server.tool("update-note", {
278
+ server.tool("update-note", "Use when: changing the title and/or replacing the body of an existing note, by id (preferred) or title.\nReturns: confirmation; warns when the note is shared.\nDo not use when: creating a new note (create-note).\nSafety: newContent REPLACES the entire body — it does not append. Read the note first if you need to preserve existing text. Edits to shared notes are immediately visible to all collaborators.", {
265
279
  id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
266
280
  title: z.string().optional().describe("Current note title (use id instead when available)"),
267
281
  newTitle: z.string().optional().describe("New title for the note"),
@@ -326,7 +340,7 @@ server.tool("update-note", {
326
340
  return successResponse(`Note updated: "${finalTitle}"${sharedWarning}${checklistWarning}`);
327
341
  }, "Error updating note"));
328
342
  // --- delete-note ---
329
- server.tool("delete-note", {
343
+ server.tool("delete-note", "Use when: permanently deleting a single note, by id (preferred) or title.\nReturns: confirmation; warns when the note was shared.\nDo not use when: deleting many notes (batch-delete-notes) or just relocating one (move-note).\nSafety: requires explicit user confirmation before deleting. Prefer search-notes/list-notes first to show the affected note id and title. Deleting a shared note removes collaborator access.", {
330
344
  id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
331
345
  title: z.string().optional().describe("Note title (use id instead when available)"),
332
346
  account: z
@@ -371,7 +385,7 @@ server.tool("delete-note", {
371
385
  return successResponse(`Note deleted: "${title}"${sharedWarning}`);
372
386
  }, "Error deleting note"));
373
387
  // --- move-note ---
374
- server.tool("move-note", {
388
+ server.tool("move-note", "Use when: moving one note to a different folder, by id (preferred) or title.\nReturns: confirmation of the note and destination folder.\nDo not use when: moving many notes (batch-move-notes).\nNote: implemented as copy-then-delete; the destination folder must already exist (create-folder).", {
375
389
  id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
376
390
  title: z.string().optional().describe("Note title (use id instead when available)"),
377
391
  folder: z.string().min(1, "Destination folder is required"),
@@ -406,7 +420,7 @@ server.tool("move-note", {
406
420
  return successResponse(`Note moved: "${title}" -> "${folder}"`);
407
421
  }, "Error moving note"));
408
422
  // --- list-notes ---
409
- server.tool("list-notes", {
423
+ server.tool("list-notes", "Use when: enumerating notes in an account or folder; supports modifiedSince and limit for large collections.\nReturns: note titles only (no content or ids).\nDo not use when: you need content (get-note-content) or ids for follow-up edits (use search-notes).\nNote: warns if iCloud sync is active and results may be partial.", {
410
424
  account: z.string().optional().describe("Account to list notes from"),
411
425
  folder: z.string().optional().describe("Filter to specific folder"),
412
426
  modifiedSince: z
@@ -440,11 +454,23 @@ server.tool("list-notes", {
440
454
  const noteList = notes.map((t) => ` - ${t}`).join("\n");
441
455
  return successResponse(`Found ${notes.length} notes${location}${acct}${dateInfo}${limitInfo}:\n${noteList}${syncNote}`, { notes, count: notes.length });
442
456
  }, "Error listing notes"));
457
+ // --- get-selected-notes ---
458
+ server.tool("get-selected-notes", "Use when: the user asks what note(s) are currently selected in Notes.app.\nReturns: selected note metadata with ids for follow-up operations.\nDo not use when: searching all notes (search-notes) or listing a folder (list-notes).\nNote: reads Notes.app UI selection; it may be empty if Notes is closed or no note is selected.", {}, withErrorHandling(() => {
459
+ const notes = notesManager.getSelectedNotes();
460
+ if (notes.length === 0) {
461
+ return successResponse("No notes are currently selected in Notes.app", {
462
+ notes: [],
463
+ count: 0,
464
+ });
465
+ }
466
+ const noteList = notes.map((n) => ` - ${n.title} [id: ${n.id}]`).join("\n");
467
+ return successResponse(`Selected note(s):\n${noteList}`, { notes, count: notes.length });
468
+ }, "Error getting selected notes"));
443
469
  // =============================================================================
444
470
  // Folder Tools
445
471
  // =============================================================================
446
472
  // --- list-folders ---
447
- server.tool("list-folders", {
473
+ server.tool("list-folders", "Use when: listing all folders, with full nested paths, for an account.\nReturns: folder names/paths.\nDo not use when: listing notes (list-notes).\nNote: warns if iCloud sync is active.", {
448
474
  account: z.string().optional().describe("Account to list folders from"),
449
475
  }, withErrorHandling(({ account }) => {
450
476
  // Use sync-aware wrapper for this read operation
@@ -469,7 +495,7 @@ server.tool("list-folders", {
469
495
  });
470
496
  }, "Error listing folders"));
471
497
  // --- create-folder ---
472
- server.tool("create-folder", {
498
+ server.tool("create-folder", "Use when: creating a folder, including nested paths like 'Work/Clients' (intermediate folders are created, existing ones skipped).\nReturns: confirmation.\nDo not use when: creating a note (create-note).", {
473
499
  name: z
474
500
  .string()
475
501
  .min(1, "Folder name is required")
@@ -483,7 +509,7 @@ server.tool("create-folder", {
483
509
  return successResponse(`Folder created: "${folder.name}"`);
484
510
  }, "Error creating folder"));
485
511
  // --- delete-folder ---
486
- server.tool("delete-folder", folderNameSchema, withErrorHandling(({ name, account }) => {
512
+ server.tool("delete-folder", "Use when: deleting an existing folder by name or nested path.\nReturns: confirmation.\nDo not use when: deleting a note (delete-note).\nSafety: requires explicit user confirmation. Deletion fails if the folder still contains notes — list or move those notes first.", folderNameSchema, withErrorHandling(({ name, account }) => {
487
513
  const success = notesManager.deleteFolder(name, account);
488
514
  if (!success) {
489
515
  return errorResponse(`Failed to delete folder "${name}". Folder may not exist or may contain notes.`);
@@ -494,22 +520,35 @@ server.tool("delete-folder", folderNameSchema, withErrorHandling(({ name, accoun
494
520
  // Account Tools
495
521
  // =============================================================================
496
522
  // --- list-accounts ---
497
- server.tool("list-accounts", {}, withErrorHandling(() => {
523
+ server.tool("list-accounts", "Use when: discovering which Notes accounts exist (iCloud, Gmail, Exchange, etc.) before targeting one.\nReturns: account names.\nDo not use when: you already know the account, or are working by note id (ids are account-independent).", {}, withErrorHandling(() => {
498
524
  const accounts = notesManager.listAccounts();
499
525
  if (accounts.length === 0) {
500
526
  return successResponse("No Notes accounts found", { accounts: [], count: 0 });
501
527
  }
502
- const accountList = accounts.map((a) => ` - ${a.name}`).join("\n");
528
+ const accountList = accounts
529
+ .map((a) => {
530
+ const defaultFolder = a.defaultFolder ? ` (default folder: ${a.defaultFolder})` : "";
531
+ const upgraded = a.upgraded === undefined ? "" : `, upgraded: ${a.upgraded ? "yes" : "no"}`;
532
+ return ` - ${a.name}${defaultFolder}${upgraded}`;
533
+ })
534
+ .join("\n");
503
535
  return successResponse(`Found ${accounts.length} accounts:\n${accountList}`, {
504
536
  accounts,
505
537
  count: accounts.length,
506
538
  });
507
539
  }, "Error listing accounts"));
540
+ // --- get-default-location ---
541
+ server.tool("get-default-location", "Use when: discovering where Notes.app will create new notes by default.\nReturns: default account and default folder metadata.\nDo not use when: you already have an explicit account/folder target.", {}, withErrorHandling(() => {
542
+ const location = notesManager.getDefaultLocation();
543
+ const message = `Default account: ${location.account.name} [id: ${location.account.id}]\n` +
544
+ `Default folder: ${location.folder.name} [id: ${location.folder.id}]`;
545
+ return successResponse(message, { ...location });
546
+ }, "Error getting default Notes location"));
508
547
  // =============================================================================
509
548
  // Collaboration Tools
510
549
  // =============================================================================
511
550
  // --- list-shared-notes ---
512
- server.tool("list-shared-notes", {}, withErrorHandling(() => {
551
+ server.tool("list-shared-notes", "Use when: finding notes shared with collaborators.\nReturns: shared notes with title, account, and id.\nDo not use when: searching all notes (search-notes).\nNote: edits or deletes to these notes affect all collaborators.", {}, withErrorHandling(() => {
513
552
  const sharedNotes = notesManager.listSharedNotes();
514
553
  if (sharedNotes.length === 0) {
515
554
  return successResponse("No shared notes found. You have no notes shared with collaborators.", { notes: [], count: 0 });
@@ -527,7 +566,7 @@ server.tool("list-shared-notes", {}, withErrorHandling(() => {
527
566
  // Diagnostics Tools
528
567
  // =============================================================================
529
568
  // --- get-sync-status ---
530
- server.tool("get-sync-status", {}, withErrorHandling(() => {
569
+ server.tool("get-sync-status", "Use when: checking whether iCloud sync is in progress before trusting read results.\nReturns: sync active/idle, pending upload count, and seconds since last change.\nDo not use when: you need note data — this is a read-only diagnostics tool.", {}, withErrorHandling(() => {
531
570
  const status = getSyncStatus();
532
571
  if (status.error) {
533
572
  return successResponse(`⚠️ Sync status unknown: ${status.error}`, { ...status });
@@ -553,7 +592,7 @@ server.tool("get-sync-status", {}, withErrorHandling(() => {
553
592
  return successResponse(lines.join("\n"), { ...status });
554
593
  }, "Error checking sync status"));
555
594
  // --- health-check ---
556
- server.tool("health-check", {}, withErrorHandling(() => {
595
+ server.tool("health-check", "Use when: a quick check that Notes.app is reachable and (optionally) Full Disk Access is granted for checklist features.\nReturns: pass/fail per check.\nDo not use when: you need detailed, actionable setup diagnostics (use doctor).\nRead-only.", {}, withErrorHandling(() => {
557
596
  const result = notesManager.healthCheck();
558
597
  const statusIcon = result.healthy ? "✓" : "✗";
559
598
  const statusText = result.healthy ? "All checks passed" : "Issues detected";
@@ -571,14 +610,14 @@ server.tool("health-check", {}, withErrorHandling(() => {
571
610
  return successResponse(`${statusIcon} ${statusText}\n\n${checkLines}\n${fdaLine}`);
572
611
  }, "Error running health check"));
573
612
  // --- doctor ---
574
- server.tool("doctor", {}, withErrorHandling(() => {
613
+ server.tool("doctor", "Use when: diagnosing setup problems (Notes.app automation permission, account state, Full Disk Access) with actionable guidance.\nReturns: a detailed report plus structured fields.\nDo not use when: you just need a quick pass/fail (health-check).\nRead-only.", {}, withErrorHandling(() => {
575
614
  // Richer than health-check: Notes.app permission, account state, and Full
576
615
  // Disk Access with actionable messages + structuredContent (#22).
577
616
  const report = runDoctor(notesManager);
578
617
  return successResponse(formatDoctorReport(report), { ...report });
579
618
  }, "Error running doctor"));
580
619
  // --- get-notes-stats ---
581
- server.tool("get-notes-stats", {}, withErrorHandling(() => {
620
+ server.tool("get-notes-stats", "Use when: summarizing the library — total notes, per-account/folder counts, and recent activity.\nReturns: aggregate statistics; flags partial coverage when some scopes were unreadable.\nDo not use when: you need individual notes (list-notes/search-notes).\nRead-only.", {}, withErrorHandling(() => {
582
621
  const stats = notesManager.getNotesStats();
583
622
  // Format the output
584
623
  const lines = [];
@@ -614,7 +653,7 @@ server.tool("get-notes-stats", {}, withErrorHandling(() => {
614
653
  return successResponse(lines.join("\n"), { ...stats });
615
654
  }, "Error getting notes statistics"));
616
655
  // --- list-attachments ---
617
- server.tool("list-attachments", {
656
+ server.tool("list-attachments", "Use when: listing the attachments of one note, by id (preferred) or title.\nReturns: each attachment's name, content type, and id (use with save-attachment/fetch-attachment).\nDo not use when: you want the attachment bytes (fetch-attachment) or a file on disk (save-attachment).", {
618
657
  id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
619
658
  title: z.string().optional().describe("Note title (use id instead when available)"),
620
659
  account: z
@@ -654,7 +693,7 @@ server.tool("list-attachments", {
654
693
  return successResponse(`Found ${attachments.length} attachment(s) in "${title}":\n${attachmentList}`, { attachments, count: attachments.length });
655
694
  }, "Error listing attachments"));
656
695
  // --- batch-delete-notes ---
657
- server.tool("batch-delete-notes", {
696
+ server.tool("batch-delete-notes", "Use when: permanently deleting multiple notes by id in one call.\nReturns: per-id success/failure counts.\nDo not use when: deleting a single note (delete-note).\nSafety: requires explicit user confirmation; this is destructive and not undoable. Prefer search-notes/list-notes first to confirm the exact ids being deleted.", {
658
697
  ids: z.array(z.string()).describe("Array of note IDs to delete"),
659
698
  }, withErrorHandling(({ ids }) => {
660
699
  if (ids.length === 0) {
@@ -673,7 +712,7 @@ server.tool("batch-delete-notes", {
673
712
  return succeeded > 0 ? successResponse(lines.join("\n")) : errorResponse(lines.join("\n"));
674
713
  }, "Error performing batch delete"));
675
714
  // --- batch-move-notes ---
676
- server.tool("batch-move-notes", {
715
+ server.tool("batch-move-notes", "Use when: moving multiple notes by id into one destination folder.\nReturns: per-id success/failure counts.\nDo not use when: moving a single note (move-note).\nNote: the destination folder must already exist (create-folder).", {
677
716
  ids: z.array(z.string()).describe("Array of note IDs to move"),
678
717
  folder: z.string().describe("Destination folder name"),
679
718
  account: z
@@ -697,7 +736,7 @@ server.tool("batch-move-notes", {
697
736
  return succeeded > 0 ? successResponse(lines.join("\n")) : errorResponse(lines.join("\n"));
698
737
  }, "Error performing batch move"));
699
738
  // --- save-attachment ---
700
- server.tool("save-attachment", {
739
+ server.tool("save-attachment", "Use when: writing one note attachment to a file on disk.\nReturns: the saved path.\nDo not use when: you want the bytes in-memory as base64 (fetch-attachment).\nSafety: writes a file; savePath must be absolute and under the home directory, a temp dir, or /Volumes. Get the ids from list-attachments first.", {
701
740
  noteId: z.string().min(1, "noteId is required").describe("CoreData note id (from search/list)"),
702
741
  attachmentId: z
703
742
  .string()
@@ -719,7 +758,7 @@ server.tool("save-attachment", {
719
758
  });
720
759
  }, "Error saving attachment"));
721
760
  // --- fetch-attachment ---
722
- server.tool("fetch-attachment", {
761
+ server.tool("fetch-attachment", "Use when: retrieving one note attachment's bytes inline as base64 (no file written).\nReturns: name, content type, byte count, and base64 data.\nDo not use when: you want it saved to disk (save-attachment).\nNote: get the ids from list-attachments first.", {
723
762
  noteId: z.string().min(1, "noteId is required").describe("CoreData note id (from search/list)"),
724
763
  attachmentId: z
725
764
  .string()
@@ -733,7 +772,7 @@ server.tool("fetch-attachment", {
733
772
  return successResponse(`Fetched "${r.name ?? "attachment"}" (${r.contentType ?? "unknown type"}, ${r.bytes ?? 0} bytes) as base64.`, { name: r.name, contentType: r.contentType, bytes: r.bytes, base64: r.base64 });
734
773
  }, "Error fetching attachment"));
735
774
  // --- export-notes-json ---
736
- server.tool("export-notes-json", {}, withErrorHandling(() => {
775
+ server.tool("export-notes-json", "Use when: exporting the entire notes library as structured JSON for backup or bulk processing.\nReturns: a summary plus the full JSON of all notes, folders, and accounts.\nDo not use when: you need a single note (get-note-content) — this reads everything and can be large.\nRead-only.", {}, withErrorHandling(() => {
737
776
  const exportData = notesManager.exportNotesAsJson();
738
777
  const { summary } = exportData;
739
778
  return {
@@ -751,7 +790,7 @@ server.tool("export-notes-json", {}, withErrorHandling(() => {
751
790
  };
752
791
  }, "Error exporting notes"));
753
792
  // --- get-note-markdown ---
754
- server.tool("get-note-markdown", {
793
+ server.tool("get-note-markdown", "Use when: reading a note as Markdown, with checklist items annotated [x]/[ ] when Full Disk Access is granted.\nReturns: the note's Markdown.\nDo not use when: you need the raw HTML/plaintext body (get-note-content) or only metadata (get-note-details).\nNote: falls back to plain lists (no checkmarks) without Full Disk Access.", {
755
794
  id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
756
795
  title: z.string().optional().describe("Note title (use id instead when available)"),
757
796
  account: z
@@ -778,7 +817,7 @@ server.tool("get-note-markdown", {
778
817
  return successResponse(markdown, { markdown });
779
818
  }, "Error getting note as markdown"));
780
819
  // --- get-checklist-state ---
781
- server.tool("get-checklist-state", {
820
+ server.tool("get-checklist-state", "Use when: reading the checked/unchecked state of a note's checklist items, by id.\nReturns: each item's text and done state plus checked/total counts.\nDo not use when: you only have a title (get the id via search-notes first) or want the full body text (get-note-content).\nNote: requires Full Disk Access; reads the NoteStore database directly.", {
782
821
  id: z.string().min(1, "Note ID is required. Use search-notes to find the note ID first."),
783
822
  }, withErrorHandling(({ id }) => {
784
823
  // Verify the note exists and is accessible
@@ -1207,25 +1207,23 @@ export class AppleNotesManager {
1207
1207
  */
1208
1208
  listFolders(account) {
1209
1209
  const targetAccount = this.resolveAccount(account);
1210
- // Get each folder's ID, name, and parent ID in a single AppleScript call.
1211
- // Each line: "id\tname\tparentId" for subfolders, "id\tname" for top-level.
1210
+ // Get each folder's ID, name, parent ID, and shared state in a single AppleScript call.
1212
1211
  // Using IDs enables correct tree building even with duplicate folder names.
1213
1212
  const listCommand = `
1214
- set folderList to ""
1213
+ set folderList to {}
1215
1214
  set allFolders to every folder
1216
1215
  repeat with f in allFolders
1217
1216
  set fRef to contents of f
1218
1217
  set cRef to container of fRef
1219
- if folderList is not "" then
1220
- set folderList to folderList & linefeed
1221
- end if
1218
+ set parentId to ""
1222
1219
  if class of cRef is folder then
1223
- set folderList to folderList & (id of fRef) & tab & (name of fRef) & tab & (id of cRef)
1224
- else
1225
- set folderList to folderList & (id of fRef) & tab & (name of fRef)
1220
+ set parentId to id of cRef
1226
1221
  end if
1222
+ set sharedFlag to shared of fRef as text
1223
+ set end of folderList to (id of fRef) & ${AS_FIELD_SEP} & (name of fRef) & ${AS_FIELD_SEP} & parentId & ${AS_FIELD_SEP} & sharedFlag
1227
1224
  end repeat
1228
- return folderList
1225
+ set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1226
+ return folderList as text
1229
1227
  `;
1230
1228
  const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
1231
1229
  const result = executeAppleScript(script);
@@ -1235,13 +1233,14 @@ export class AppleNotesManager {
1235
1233
  if (!result.output.trim()) {
1236
1234
  return [];
1237
1235
  }
1238
- // Parse "id\tname[\tparentId]" lines
1239
- const entries = result.output.split("\n").map((line) => {
1240
- const parts = line.split("\t");
1236
+ const recordSeparator = result.output.includes(RECORD_SEP) ? RECORD_SEP : "\n";
1237
+ const entries = result.output.split(recordSeparator).map((line) => {
1238
+ const parts = line.includes(FIELD_SEP) ? line.split(FIELD_SEP) : line.split("\t");
1241
1239
  return {
1242
1240
  id: (parts[0] || "").trim(),
1243
1241
  name: (parts[1] || "").trim(),
1244
1242
  parentId: (parts[2] || "").trim(),
1243
+ shared: (parts[3] || "").trim().toLowerCase() === "true",
1245
1244
  };
1246
1245
  });
1247
1246
  // Build an ID-to-entry map for efficient parent lookups
@@ -1264,6 +1263,7 @@ export class AppleNotesManager {
1264
1263
  id: entry.id,
1265
1264
  name: buildPath(entry),
1266
1265
  account: targetAccount,
1266
+ shared: entry.shared,
1267
1267
  }));
1268
1268
  }
1269
1269
  /**
@@ -1455,10 +1455,22 @@ export class AppleNotesManager {
1455
1455
  * @returns Array of Account objects
1456
1456
  */
1457
1457
  listAccounts() {
1458
- // Coerce the name list to text with a control-char record separator so an
1459
- // account name containing a comma can't split into phantom accounts (#18).
1458
+ // Coerce account records to text with control-char delimiters so names
1459
+ // containing commas or tabs can't split into phantom accounts (#18).
1460
1460
  const listCommand = `
1461
- set resultList to name of accounts
1461
+ set resultList to {}
1462
+ repeat with a in accounts
1463
+ set aRef to contents of a
1464
+ set defaultFolderId to ""
1465
+ set defaultFolderName to ""
1466
+ try
1467
+ set fRef to default folder of aRef
1468
+ set defaultFolderId to id of fRef
1469
+ set defaultFolderName to name of fRef
1470
+ end try
1471
+ set upgradedFlag to upgraded of aRef as text
1472
+ set end of resultList to (id of aRef) & ${AS_FIELD_SEP} & (name of aRef) & ${AS_FIELD_SEP} & upgradedFlag & ${AS_FIELD_SEP} & defaultFolderId & ${AS_FIELD_SEP} & defaultFolderName
1473
+ end repeat
1462
1474
  set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1463
1475
  return resultList as text
1464
1476
  `;
@@ -1467,11 +1479,130 @@ export class AppleNotesManager {
1467
1479
  if (!result.success) {
1468
1480
  throw new Error(`Failed to list accounts: ${result.error ?? "unknown error"}`);
1469
1481
  }
1470
- const names = result.output
1482
+ return result.output
1471
1483
  .split(RECORD_SEP)
1472
1484
  .map((s) => s.trim())
1473
- .filter((s) => s.length > 0);
1474
- return names.map((name) => ({ name }));
1485
+ .filter((s) => s.length > 0)
1486
+ .map((item) => {
1487
+ const parts = item.split(FIELD_SEP);
1488
+ if (parts.length === 1) {
1489
+ return { name: parts[0].trim() };
1490
+ }
1491
+ return {
1492
+ id: (parts[0] || "").trim(),
1493
+ name: (parts[1] || "").trim(),
1494
+ upgraded: (parts[2] || "").trim().toLowerCase() === "true",
1495
+ defaultFolderId: (parts[3] || "").trim() || undefined,
1496
+ defaultFolder: (parts[4] || "").trim() || undefined,
1497
+ };
1498
+ });
1499
+ }
1500
+ /**
1501
+ * Gets the default account and folder used by Notes.app for new notes.
1502
+ *
1503
+ * @returns Default account and folder metadata
1504
+ */
1505
+ getDefaultLocation() {
1506
+ const command = `
1507
+ set aRef to default account
1508
+ set fRef to default folder of aRef
1509
+ return (id of aRef) & ${AS_FIELD_SEP} & (name of aRef) & ${AS_FIELD_SEP} & (upgraded of aRef as text) & ${AS_FIELD_SEP} & (id of fRef) & ${AS_FIELD_SEP} & (name of fRef) & ${AS_FIELD_SEP} & (shared of fRef as text)
1510
+ `;
1511
+ const result = executeAppleScript(buildAppLevelScript(command));
1512
+ if (!result.success) {
1513
+ throw new Error(`Failed to get default Notes location: ${result.error ?? "unknown error"}`);
1514
+ }
1515
+ const parts = result.output.split(FIELD_SEP);
1516
+ if (parts.length < 6) {
1517
+ throw new Error(`Failed to parse default Notes location: ${result.output}`);
1518
+ }
1519
+ const accountName = (parts[1] || "").trim();
1520
+ return {
1521
+ account: {
1522
+ id: (parts[0] || "").trim(),
1523
+ name: accountName,
1524
+ upgraded: (parts[2] || "").trim().toLowerCase() === "true",
1525
+ defaultFolderId: (parts[3] || "").trim(),
1526
+ defaultFolder: (parts[4] || "").trim(),
1527
+ },
1528
+ folder: {
1529
+ id: (parts[3] || "").trim(),
1530
+ name: (parts[4] || "").trim(),
1531
+ account: accountName,
1532
+ shared: (parts[5] || "").trim().toLowerCase() === "true",
1533
+ },
1534
+ };
1535
+ }
1536
+ /**
1537
+ * Lists the currently selected Notes in the Notes.app UI.
1538
+ *
1539
+ * @returns Array of selected notes, or an empty array when nothing is selected
1540
+ */
1541
+ getSelectedNotes() {
1542
+ const command = `
1543
+ set selectedNotes to selection
1544
+ set noteList to {}
1545
+ repeat with n in selectedNotes
1546
+ set nRef to contents of n
1547
+ set createdDate to creation date of nRef
1548
+ set modifiedDate to modification date of nRef
1549
+ set createdParts to ${asDatePartsExpr("createdDate")}
1550
+ set modifiedParts to ${asDatePartsExpr("modifiedDate")}
1551
+ set folderName to ""
1552
+ set accountName to ""
1553
+ try
1554
+ set fRef to container of nRef
1555
+ set folderName to name of fRef
1556
+ set aRef to container of fRef
1557
+ set accountName to name of aRef
1558
+ end try
1559
+ set end of noteList to (id of nRef) & ${AS_FIELD_SEP} & (name of nRef) & ${AS_FIELD_SEP} & createdParts & ${AS_FIELD_SEP} & modifiedParts & ${AS_FIELD_SEP} & (shared of nRef as text) & ${AS_FIELD_SEP} & (password protected of nRef as text) & ${AS_FIELD_SEP} & folderName & ${AS_FIELD_SEP} & accountName
1560
+ end repeat
1561
+ set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1562
+ return noteList as text
1563
+ `;
1564
+ const result = executeAppleScript(buildAppLevelScript(command));
1565
+ if (!result.success) {
1566
+ throw new Error(`Failed to get selected notes: ${result.error ?? "unknown error"}`);
1567
+ }
1568
+ if (!result.output.trim()) {
1569
+ return [];
1570
+ }
1571
+ return result.output
1572
+ .split(RECORD_SEP)
1573
+ .filter((s) => s.trim())
1574
+ .map((item) => {
1575
+ const parts = item.split(FIELD_SEP);
1576
+ return {
1577
+ id: (parts[0] || "").trim(),
1578
+ title: (parts[1] || "").trim(),
1579
+ content: "",
1580
+ tags: [],
1581
+ created: parseAppleScriptDate((parts[2] || "").trim()),
1582
+ modified: parseAppleScriptDate((parts[3] || "").trim()),
1583
+ shared: (parts[4] || "").trim().toLowerCase() === "true",
1584
+ passwordProtected: (parts[5] || "").trim().toLowerCase() === "true",
1585
+ folder: (parts[6] || "").trim() || undefined,
1586
+ account: (parts[7] || "").trim() || undefined,
1587
+ };
1588
+ });
1589
+ }
1590
+ /**
1591
+ * Reveals a note in the Notes.app UI by ID.
1592
+ *
1593
+ * @param id - CoreData URL identifier for the note
1594
+ * @param separately - Whether to open the note in a separate window
1595
+ * @returns true if Notes.app accepted the show command
1596
+ */
1597
+ showNoteById(id, separately = false) {
1598
+ const safeId = sanitizeId(id);
1599
+ const separatelyClause = separately ? " separately true" : "";
1600
+ const result = executeAppleScript(buildAppLevelScript(`show note id "${safeId}"${separatelyClause}`));
1601
+ if (!result.success) {
1602
+ console.error(`Failed to show note with ID "${id}":`, result.error);
1603
+ return false;
1604
+ }
1605
+ return true;
1475
1606
  }
1476
1607
  // ===========================================================================
1477
1608
  // Health Check
@@ -1738,8 +1869,17 @@ export class AppleNotesManager {
1738
1869
  repeat with a in attachments of theNote
1739
1870
  set attachId to id of a
1740
1871
  set attachName to name of a
1741
- set attachType to content identifier of a
1742
- set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachType
1872
+ set attachContentId to content identifier of a
1873
+ set attachUrl to ""
1874
+ try
1875
+ set attachUrl to URL of a as text
1876
+ end try
1877
+ set createdDate to creation date of a
1878
+ set modifiedDate to modification date of a
1879
+ set createdParts to ${asDatePartsExpr("createdDate")}
1880
+ set modifiedParts to ${asDatePartsExpr("modifiedDate")}
1881
+ set sharedFlag to shared of a as text
1882
+ set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachContentId & ${AS_FIELD_SEP} & attachUrl & ${AS_FIELD_SEP} & createdParts & ${AS_FIELD_SEP} & modifiedParts & ${AS_FIELD_SEP} & sharedFlag
1743
1883
  end repeat
1744
1884
  set output to ""
1745
1885
  repeat with item in attachmentList
@@ -1765,6 +1905,11 @@ export class AppleNotesManager {
1765
1905
  id: parts[0].trim(),
1766
1906
  name: parts[1].trim(),
1767
1907
  contentType: parts[2].trim(),
1908
+ contentId: parts[2].trim() || undefined,
1909
+ url: parts[3]?.trim() || undefined,
1910
+ created: parts[4] ? parseAppleScriptDate(parts[4].trim()) : undefined,
1911
+ modified: parts[5] ? parseAppleScriptDate(parts[5].trim()) : undefined,
1912
+ shared: parts[6] ? parts[6].trim().toLowerCase() === "true" : undefined,
1768
1913
  });
1769
1914
  }
1770
1915
  }
@@ -1784,13 +1929,22 @@ export class AppleNotesManager {
1784
1929
  tell application "Notes"
1785
1930
  tell account "${targetAccount}"
1786
1931
  set theNote to note "${safeTitle}"
1787
- set attachmentList to {}
1788
- repeat with a in attachments of theNote
1789
- set attachId to id of a
1790
- set attachName to name of a
1791
- set attachType to content identifier of a
1792
- set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachType
1793
- end repeat
1932
+ set attachmentList to {}
1933
+ repeat with a in attachments of theNote
1934
+ set attachId to id of a
1935
+ set attachName to name of a
1936
+ set attachContentId to content identifier of a
1937
+ set attachUrl to ""
1938
+ try
1939
+ set attachUrl to URL of a as text
1940
+ end try
1941
+ set createdDate to creation date of a
1942
+ set modifiedDate to modification date of a
1943
+ set createdParts to ${asDatePartsExpr("createdDate")}
1944
+ set modifiedParts to ${asDatePartsExpr("modifiedDate")}
1945
+ set sharedFlag to shared of a as text
1946
+ set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachContentId & ${AS_FIELD_SEP} & attachUrl & ${AS_FIELD_SEP} & createdParts & ${AS_FIELD_SEP} & modifiedParts & ${AS_FIELD_SEP} & sharedFlag
1947
+ end repeat
1794
1948
  set output to ""
1795
1949
  repeat with item in attachmentList
1796
1950
  set output to output & item & ${AS_RECORD_SEP}
@@ -1816,6 +1970,11 @@ export class AppleNotesManager {
1816
1970
  id: parts[0].trim(),
1817
1971
  name: parts[1].trim(),
1818
1972
  contentType: parts[2].trim(),
1973
+ contentId: parts[2].trim() || undefined,
1974
+ url: parts[3]?.trim() || undefined,
1975
+ created: parts[4] ? parseAppleScriptDate(parts[4].trim()) : undefined,
1976
+ modified: parts[5] ? parseAppleScriptDate(parts[5].trim()) : undefined,
1977
+ shared: parts[6] ? parts[6].trim().toLowerCase() === "true" : undefined,
1819
1978
  });
1820
1979
  }
1821
1980
  }
@@ -1145,7 +1145,11 @@ describe("AppleNotesManager", () => {
1145
1145
  it("returns array of Folder objects with paths", () => {
1146
1146
  mockExecuteAppleScript.mockReturnValue({
1147
1147
  success: true,
1148
- output: "id1\tNotes\nid2\tArchive\nid3\tWork",
1148
+ output: [
1149
+ ["id1", "Notes", "", "false"].join(F),
1150
+ ["id2", "Archive", "", "false"].join(F),
1151
+ ["id3", "Work", "", "true"].join(F),
1152
+ ].join(R),
1149
1153
  });
1150
1154
  const folders = manager.listFolders();
1151
1155
  expect(folders).toHaveLength(3);
@@ -1153,11 +1157,17 @@ describe("AppleNotesManager", () => {
1153
1157
  expect(folders[1].name).toBe("Archive");
1154
1158
  expect(folders[2].name).toBe("Work");
1155
1159
  expect(folders[0].id).toBe("id1");
1160
+ expect(folders[2].shared).toBe(true);
1156
1161
  });
1157
1162
  it("includes parent folder in path", () => {
1158
1163
  mockExecuteAppleScript.mockReturnValue({
1159
1164
  success: true,
1160
- output: "id1\tDev\nid2\tAccessibility\tid1\nid3\tWork\nid4\tClients\tid3",
1165
+ output: [
1166
+ ["id1", "Dev", "", "false"].join(F),
1167
+ ["id2", "Accessibility", "id1", "false"].join(F),
1168
+ ["id3", "Work", "", "false"].join(F),
1169
+ ["id4", "Clients", "id3", "false"].join(F),
1170
+ ].join(R),
1161
1171
  });
1162
1172
  const folders = manager.listFolders();
1163
1173
  expect(folders).toHaveLength(4);
@@ -1169,7 +1179,13 @@ describe("AppleNotesManager", () => {
1169
1179
  it("disambiguates duplicate folder names using IDs", () => {
1170
1180
  mockExecuteAppleScript.mockReturnValue({
1171
1181
  success: true,
1172
- output: "id1\tFinance\nid2\tArchive\tid1\nid3\tTravel\nid4\tTrips\tid3\nid5\tArchive\tid4",
1182
+ output: [
1183
+ ["id1", "Finance", "", "false"].join(F),
1184
+ ["id2", "Archive", "id1", "false"].join(F),
1185
+ ["id3", "Travel", "", "false"].join(F),
1186
+ ["id4", "Trips", "id3", "false"].join(F),
1187
+ ["id5", "Archive", "id4", "false"].join(F),
1188
+ ].join(R),
1173
1189
  });
1174
1190
  const folders = manager.listFolders();
1175
1191
  expect(folders).toHaveLength(5);
@@ -1179,17 +1195,31 @@ describe("AppleNotesManager", () => {
1179
1195
  it("escapes slashes in folder names", () => {
1180
1196
  mockExecuteAppleScript.mockReturnValue({
1181
1197
  success: true,
1182
- output: "id1\tTravel\nid2\tSpain/Portugal 2023\tid1",
1198
+ output: [
1199
+ ["id1", "Travel", "", "false"].join(F),
1200
+ ["id2", "Spain/Portugal 2023", "id1", "false"].join(F),
1201
+ ].join(R),
1183
1202
  });
1184
1203
  const folders = manager.listFolders();
1185
1204
  expect(folders).toHaveLength(2);
1186
1205
  expect(folders[0].name).toBe("Travel");
1187
1206
  expect(folders[1].name).toBe("Travel/Spain\\/Portugal 2023");
1188
1207
  });
1208
+ it("parses legacy tab/newline output (backward compat)", () => {
1209
+ mockExecuteAppleScript.mockReturnValue({
1210
+ success: true,
1211
+ output: "id1\tNotes\nid2\tArchive\tid1",
1212
+ });
1213
+ const folders = manager.listFolders();
1214
+ expect(folders).toHaveLength(2);
1215
+ expect(folders[0].name).toBe("Notes");
1216
+ expect(folders[1].name).toBe("Notes/Archive");
1217
+ expect(folders[1].shared).toBe(false);
1218
+ });
1189
1219
  it("includes account in Folder objects", () => {
1190
1220
  mockExecuteAppleScript.mockReturnValue({
1191
1221
  success: true,
1192
- output: "id1\tNotes",
1222
+ output: ["id1", "Notes", "", "false"].join(F),
1193
1223
  });
1194
1224
  const folders = manager.listFolders("Gmail");
1195
1225
  expect(folders[0].account).toBe("Gmail");
@@ -1420,13 +1450,41 @@ describe("AppleNotesManager", () => {
1420
1450
  it("returns array of Account objects", () => {
1421
1451
  mockExecuteAppleScript.mockReturnValue({
1422
1452
  success: true,
1423
- output: ["iCloud", "Gmail", "Exchange"].join(R),
1453
+ output: [
1454
+ ["acc1", "iCloud", "true", "folder1", "Notes"].join(F),
1455
+ ["acc2", "Gmail", "false", "folder2", "Inbox"].join(F),
1456
+ ["acc3", "Exchange", "false", "", ""].join(F),
1457
+ ].join(R),
1424
1458
  });
1425
1459
  const accounts = manager.listAccounts();
1426
1460
  expect(accounts).toHaveLength(3);
1427
1461
  expect(accounts[0].name).toBe("iCloud");
1428
1462
  expect(accounts[1].name).toBe("Gmail");
1429
1463
  expect(accounts[2].name).toBe("Exchange");
1464
+ expect(accounts[0].id).toBe("acc1");
1465
+ expect(accounts[0].upgraded).toBe(true);
1466
+ expect(accounts[0].defaultFolder).toBe("Notes");
1467
+ });
1468
+ it("parses legacy plain-name output (backward compat)", () => {
1469
+ mockExecuteAppleScript.mockReturnValue({
1470
+ success: true,
1471
+ output: ["iCloud", "Gmail"].join(R),
1472
+ });
1473
+ const accounts = manager.listAccounts();
1474
+ expect(accounts).toHaveLength(2);
1475
+ expect(accounts[0]).toEqual({ name: "iCloud" });
1476
+ expect(accounts[1]).toEqual({ name: "Gmail" });
1477
+ });
1478
+ it("handles account records with empty fields", () => {
1479
+ mockExecuteAppleScript.mockReturnValue({
1480
+ success: true,
1481
+ output: ["", "", "", "", ""].join(F),
1482
+ });
1483
+ const accounts = manager.listAccounts();
1484
+ expect(accounts).toHaveLength(1);
1485
+ expect(accounts[0].name).toBe("");
1486
+ expect(accounts[0].upgraded).toBe(false);
1487
+ expect(accounts[0].defaultFolderId).toBeUndefined();
1430
1488
  });
1431
1489
  it("throws on failure rather than returning empty (#19)", () => {
1432
1490
  mockExecuteAppleScript.mockReturnValue({
@@ -1437,6 +1495,126 @@ describe("AppleNotesManager", () => {
1437
1495
  expect(() => manager.listAccounts()).toThrow(/Notes.app not available/);
1438
1496
  });
1439
1497
  });
1498
+ describe("getDefaultLocation", () => {
1499
+ it("returns default account and folder metadata", () => {
1500
+ mockExecuteAppleScript.mockReturnValue({
1501
+ success: true,
1502
+ output: ["acc1", "iCloud", "true", "folder1", "Notes", "false"].join(F),
1503
+ });
1504
+ const location = manager.getDefaultLocation();
1505
+ expect(location.account).toMatchObject({
1506
+ id: "acc1",
1507
+ name: "iCloud",
1508
+ upgraded: true,
1509
+ defaultFolderId: "folder1",
1510
+ defaultFolder: "Notes",
1511
+ });
1512
+ expect(location.folder).toMatchObject({
1513
+ id: "folder1",
1514
+ name: "Notes",
1515
+ account: "iCloud",
1516
+ shared: false,
1517
+ });
1518
+ });
1519
+ it("throws when default location output cannot be parsed", () => {
1520
+ mockExecuteAppleScript.mockReturnValue({
1521
+ success: true,
1522
+ output: "bad-output",
1523
+ });
1524
+ expect(() => manager.getDefaultLocation()).toThrow(/parse default Notes location/);
1525
+ });
1526
+ it("throws when AppleScript fails", () => {
1527
+ mockExecuteAppleScript.mockReturnValue({ success: false, output: "", error: "boom" });
1528
+ expect(() => manager.getDefaultLocation()).toThrow(/Failed to get default Notes location/);
1529
+ });
1530
+ it("handles empty fields in default location output", () => {
1531
+ mockExecuteAppleScript.mockReturnValue({
1532
+ success: true,
1533
+ output: ["", "", "", "", "", ""].join(F),
1534
+ });
1535
+ const location = manager.getDefaultLocation();
1536
+ expect(location.account.name).toBe("");
1537
+ expect(location.account.upgraded).toBe(false);
1538
+ expect(location.folder.id).toBe("");
1539
+ expect(location.folder.shared).toBe(false);
1540
+ });
1541
+ });
1542
+ describe("getSelectedNotes", () => {
1543
+ it("returns selected note metadata", () => {
1544
+ mockExecuteAppleScript.mockReturnValue({
1545
+ success: true,
1546
+ output: [
1547
+ [
1548
+ "x-coredata://ABC/ICNote/p1",
1549
+ "Selected Note",
1550
+ "2026-6-22-14-30-0",
1551
+ "2026-6-22-14-35-0",
1552
+ "false",
1553
+ "false",
1554
+ "Notes",
1555
+ "iCloud",
1556
+ ].join(F),
1557
+ ].join(R),
1558
+ });
1559
+ const notes = manager.getSelectedNotes();
1560
+ expect(notes).toHaveLength(1);
1561
+ expect(notes[0]).toMatchObject({
1562
+ id: "x-coredata://ABC/ICNote/p1",
1563
+ title: "Selected Note",
1564
+ shared: false,
1565
+ passwordProtected: false,
1566
+ folder: "Notes",
1567
+ account: "iCloud",
1568
+ });
1569
+ expect(notes[0].created.getFullYear()).toBe(2026);
1570
+ });
1571
+ it("returns an empty array when no note is selected", () => {
1572
+ mockExecuteAppleScript.mockReturnValue({
1573
+ success: true,
1574
+ output: "",
1575
+ });
1576
+ expect(manager.getSelectedNotes()).toEqual([]);
1577
+ });
1578
+ it("throws when AppleScript fails", () => {
1579
+ mockExecuteAppleScript.mockReturnValue({ success: false, output: "", error: "boom" });
1580
+ expect(() => manager.getSelectedNotes()).toThrow(/Failed to get selected notes/);
1581
+ });
1582
+ it("handles selected notes with empty optional fields", () => {
1583
+ mockExecuteAppleScript.mockReturnValue({
1584
+ success: true,
1585
+ output: ["", "", "", "", "", "", "", ""].join(F),
1586
+ });
1587
+ const notes = manager.getSelectedNotes();
1588
+ expect(notes).toHaveLength(1);
1589
+ expect(notes[0].id).toBe("");
1590
+ expect(notes[0].shared).toBe(false);
1591
+ expect(notes[0].passwordProtected).toBe(false);
1592
+ expect(notes[0].folder).toBeUndefined();
1593
+ expect(notes[0].account).toBeUndefined();
1594
+ });
1595
+ });
1596
+ describe("showNoteById", () => {
1597
+ it("shows a note by id", () => {
1598
+ mockExecuteAppleScript.mockReturnValue({
1599
+ success: true,
1600
+ output: "",
1601
+ });
1602
+ expect(manager.showNoteById("x-coredata://ABC/ICNote/p1")).toBe(true);
1603
+ expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('show note id "x-coredata://ABC/ICNote/p1"'));
1604
+ });
1605
+ it("can request a separate window", () => {
1606
+ mockExecuteAppleScript.mockReturnValue({
1607
+ success: true,
1608
+ output: "",
1609
+ });
1610
+ manager.showNoteById("x-coredata://ABC/ICNote/p1", true);
1611
+ expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("separately true"));
1612
+ });
1613
+ it("returns false when Notes.app rejects the show command", () => {
1614
+ mockExecuteAppleScript.mockReturnValue({ success: false, output: "", error: "no such note" });
1615
+ expect(manager.showNoteById("x-coredata://ABC/ICNote/p1")).toBe(false);
1616
+ });
1617
+ });
1440
1618
  // ---------------------------------------------------------------------------
1441
1619
  // Health Check
1442
1620
  // ---------------------------------------------------------------------------
@@ -1620,16 +1798,45 @@ describe("AppleNotesManager", () => {
1620
1798
  });
1621
1799
  const attachments = manager.listAttachmentsById("x-coredata://ABC/ICNote/p123");
1622
1800
  expect(attachments).toHaveLength(2);
1623
- expect(attachments[0]).toEqual({
1801
+ expect(attachments[0]).toMatchObject({
1624
1802
  id: "x-coredata://ABC/ICAttachment/p1",
1625
1803
  name: "photo.jpg",
1626
1804
  contentType: "public.jpeg",
1805
+ contentId: "public.jpeg",
1627
1806
  });
1628
- expect(attachments[1]).toEqual({
1807
+ expect(attachments[1]).toMatchObject({
1629
1808
  id: "x-coredata://ABC/ICAttachment/p2",
1630
1809
  name: "document.pdf",
1631
1810
  contentType: "com.adobe.pdf",
1811
+ contentId: "com.adobe.pdf",
1812
+ });
1813
+ });
1814
+ it("parses richer attachment metadata when present", () => {
1815
+ mockExecuteAppleScript.mockReturnValueOnce({
1816
+ success: true,
1817
+ output: [
1818
+ [
1819
+ "x-coredata://ABC/ICAttachment/p1",
1820
+ "site.webloc",
1821
+ "cid:123",
1822
+ "https://example.com",
1823
+ "2026-6-22-10-0-0",
1824
+ "2026-6-22-11-0-0",
1825
+ "true",
1826
+ ].join(F),
1827
+ ].join(R),
1828
+ });
1829
+ const attachments = manager.listAttachmentsById("x-coredata://ABC/ICNote/p123");
1830
+ expect(attachments[0]).toMatchObject({
1831
+ id: "x-coredata://ABC/ICAttachment/p1",
1832
+ name: "site.webloc",
1833
+ contentType: "cid:123",
1834
+ contentId: "cid:123",
1835
+ url: "https://example.com",
1836
+ shared: true,
1632
1837
  });
1838
+ expect(attachments[0].created?.getFullYear()).toBe(2026);
1839
+ expect(attachments[0].modified?.getHours()).toBe(11);
1633
1840
  });
1634
1841
  it("returns empty array when note has no attachments", () => {
1635
1842
  mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "" });
@@ -1659,10 +1866,11 @@ describe("AppleNotesManager", () => {
1659
1866
  });
1660
1867
  const attachments = manager.listAttachments("My Note");
1661
1868
  expect(attachments).toHaveLength(1);
1662
- expect(attachments[0]).toEqual({
1869
+ expect(attachments[0]).toMatchObject({
1663
1870
  id: "attach-id",
1664
1871
  name: "image.png",
1665
1872
  contentType: "public.png",
1873
+ contentId: "public.png",
1666
1874
  });
1667
1875
  });
1668
1876
  it("uses specified account", () => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.1.0",
4
- "description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude and Codex",
3
+ "version": "2.1.2",
4
+ "description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude and other AI assistants",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
7
7
  "types": "build/index.d.ts",
@@ -27,7 +27,7 @@
27
27
  "format": "prettier --write src",
28
28
  "format:check": "prettier --check src",
29
29
  "typecheck": "tsc --noEmit",
30
- "version": "node scripts/sync-plugin-version.mjs && git add .claude-plugin/plugin.json .claude-plugin/marketplace.json .agents/plugins/marketplace.json codex/.codex-plugin/plugin.json",
30
+ "version": "node scripts/sync-plugin-version.mjs && git add .claude-plugin .agents/plugins codex .hermes-plugin .antigravity-plugin",
31
31
  "prepublishOnly": "npm run lint && npm run test && npm run build",
32
32
  "prepare": "husky; npm run build"
33
33
  },
@@ -81,7 +81,7 @@
81
81
  "vitest": "^2.0.0"
82
82
  },
83
83
  "volta": {
84
- "node": "22.13.1"
84
+ "node": "24.17.0"
85
85
  },
86
86
  "lint-staged": {
87
87
  "src/**/*.ts": [