apple-notes-mcp 2.3.0 → 2.5.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
@@ -135,7 +135,7 @@ A few Notes UI features are not exposed to AppleScript and therefore cannot be
135
135
  supported. See **[docs/APPLESCRIPT-LIMITATIONS.md](docs/APPLESCRIPT-LIMITATIONS.md)**
136
136
  for the investigation and verification behind each:
137
137
 
138
- - **Pinned notes** — Notes has no scriptable `pinned` property, so pin state can be neither read nor set.
138
+ - **Pinned notes** — Notes has no scriptable `pinned` property via AppleScript. Pin state can now be **read** with the BETA `get-note-metadata` tool (from the NoteStore database), but it still cannot be **set** programmatically.
139
139
  - **Note-to-note links** — there is no `applenotes://` deep link or link property; the only stable handle is the `x-coredata://` note id.
140
140
 
141
141
  ---
@@ -267,6 +267,22 @@ see [docs/APPLESCRIPT-LIMITATIONS.md](../docs/APPLESCRIPT-LIMITATIONS.md#tags--h
267
267
 
268
268
  ---
269
269
 
270
+ #### `get-note-plaintext`
271
+
272
+ Retrieves a note's body as plain text, with no HTML markup.
273
+
274
+ | Parameter | Type | Required | Description |
275
+ |-----------|------|----------|-------------|
276
+ | `id` | string | No | Note ID (preferred - more reliable than title) |
277
+ | `title` | string | No | Note title (use `id` instead when available) |
278
+ | `account` | string | No | Account containing the note (defaults to iCloud, ignored if `id` is provided) |
279
+
280
+ **Note:** Either `id` or `title` must be provided. This reads the note's native `plaintext` property, so it skips the HTML-to-text conversion that `get-note-content` plus a Markdown pass would do. Use `get-note-content` when you need the HTML, or `get-note-markdown` when you want Markdown with checklist state.
281
+
282
+ **Returns:** The plain-text content of the note in `structuredContent.plaintext`, or error if not found.
283
+
284
+ ---
285
+
270
286
  #### `get-note-details`
271
287
 
272
288
  Retrieves metadata about a note (without full content).
@@ -376,6 +392,8 @@ Updates an existing note's content and/or title.
376
392
 
377
393
  **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`.
378
394
 
395
+ **Attachments:** A full-body replace can drop embedded files, images, scans, PDFs, or audio. When a note may hold attachments, run [`list-attachments`](#list-attachments) first, and either save them with `save-attachment` or build a new note rather than overwriting. See the skill's [Attachment-Safe Updates](skills/apple-notes/SKILL.md#attachment-safe-updates) guidance.
396
+
379
397
  ---
380
398
 
381
399
  #### `delete-note`
@@ -551,6 +569,19 @@ Deletes a folder.
551
569
 
552
570
  ---
553
571
 
572
+ #### `show-folder`
573
+
574
+ Reveals a folder in Notes.app using its unique CoreData identifier.
575
+
576
+ | Parameter | Type | Required | Description |
577
+ |-----------|------|----------|-------------|
578
+ | `id` | string | Yes | The folder's CoreData identifier (from `list-folders`) |
579
+ | `separately` | boolean | No | Open in a separate window when supported by Notes.app |
580
+
581
+ **Returns:** Confirmation that Notes.app accepted the show command.
582
+
583
+ ---
584
+
554
585
  ### Account Operations
555
586
 
556
587
  #### `list-accounts`
@@ -578,6 +609,19 @@ Returns the default account and folder Notes.app uses for newly created notes.
578
609
 
579
610
  ---
580
611
 
612
+ #### `show-account`
613
+
614
+ Reveals an account in Notes.app using its unique CoreData identifier.
615
+
616
+ | Parameter | Type | Required | Description |
617
+ |-----------|------|----------|-------------|
618
+ | `id` | string | Yes | The account's CoreData identifier (from `list-accounts`) |
619
+ | `separately` | boolean | No | Open in a separate window when supported by Notes.app |
620
+
621
+ **Returns:** Confirmation that Notes.app accepted the show command.
622
+
623
+ ---
624
+
581
625
  ### Batch Operations
582
626
 
583
627
  #### `batch-delete-notes`
@@ -662,6 +706,22 @@ Checklist for "Shopping List" (2/4 done):
662
706
 
663
707
  ---
664
708
 
709
+ #### `get-note-metadata` (BETA)
710
+
711
+ Reads note metadata that AppleScript cannot expose, by querying the NoteStore SQLite database directly: pinned state, checklist flags, trash/recovery state, the preview snippet, and the password hint. The available fields vary by macOS version.
712
+
713
+ **Requires:** Full Disk Access for the MCP host process (see [Full Disk Access Setup](#full-disk-access-for-checklist-features)).
714
+
715
+ **BETA:** the NoteStore schema changes between macOS releases, so some fields can be absent on older or newer systems. The database is only ever read, never written.
716
+
717
+ | Parameter | Type | Required | Description |
718
+ |-----------|------|----------|-------------|
719
+ | `id` | string | Yes | Note ID (use `search-notes` to find it first) |
720
+
721
+ **Returns:** A metadata object in `structuredContent` holding any of `pinned`, `hasChecklist`, `hasChecklistInProgress`, `recoveringFromTrash`, `passwordProtected`, `passwordHint`, `snippet`, `widgetSnippet`, and `smartFolderQuery`. Unlike most read tools, it also resolves trashed notes that AppleScript can no longer find.
722
+
723
+ ---
724
+
665
725
  #### `list-attachments`
666
726
 
667
727
  Lists attachments in a note.
@@ -703,6 +763,20 @@ Returns a note attachment's bytes as base64, without writing to disk (the read c
703
763
 
704
764
  ---
705
765
 
766
+ #### `show-attachment`
767
+
768
+ Reveals one note attachment in Notes.app. Attachments are elements of a note, so this takes both the note id and the attachment id (the same pair used by `save-attachment` / `fetch-attachment`).
769
+
770
+ | Parameter | Type | Required | Description |
771
+ |-----------|------|----------|-------------|
772
+ | `noteId` | string | Yes | CoreData note ID (from `search-notes`/`list-notes`) |
773
+ | `attachmentId` | string | Yes | Attachment ID (from `list-attachments`) |
774
+ | `separately` | boolean | No | Open in a separate window when supported by Notes.app |
775
+
776
+ **Returns:** Confirmation that Notes.app revealed the attachment.
777
+
778
+ ---
779
+
706
780
  ### Diagnostics
707
781
 
708
782
  #### `health-check`
package/build/index.js CHANGED
@@ -26,6 +26,7 @@ import { z } from "zod";
26
26
  import { AppleNotesManager } from "./services/appleNotesManager.js";
27
27
  import { getSyncStatus, withSyncAwarenessSync } from "./utils/syncDetection.js";
28
28
  import { getChecklistItems, hasFullDiskAccess } from "./utils/checklistParser.js";
29
+ import { getNoteMetadata } from "./utils/noteMetadata.js";
29
30
  import { detectChecklistAttempt } from "./utils/contentWarnings.js";
30
31
  import { parseHashtags } from "./utils/hashtags.js";
31
32
  import { runDoctor, formatDoctorReport } from "./tools/doctor.js";
@@ -254,6 +255,54 @@ server.registerTool("get-note-content", {
254
255
  const hashtags = parseHashtags(content);
255
256
  return successResponse(content, { title, content, hashtags });
256
257
  }, "Error retrieving note content"));
258
+ // --- get-note-plaintext ---
259
+ server.registerTool("get-note-plaintext", {
260
+ description: "Use when: reading one note's body as plain text with no HTML, by id (preferred) or title.\nReturns: the note's plaintext exactly as Notes exposes it.\nDo not use when: you need the HTML body (get-note-content) or Markdown with checklist state (get-note-markdown).\nNote: this reads the note's native plaintext property, so it skips the HTML-to-text conversion; password-protected notes must be unlocked in Notes.app first.",
261
+ inputSchema: {
262
+ id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
263
+ title: z.string().optional().describe("Note title (use id instead when available)"),
264
+ account: z
265
+ .string()
266
+ .optional()
267
+ .describe("Account name (defaults to iCloud, ignored if id is provided)"),
268
+ },
269
+ outputSchema: {
270
+ title: z.string().optional(),
271
+ plaintext: z.string().optional(),
272
+ },
273
+ }, withErrorHandling(({ id, title, account }) => {
274
+ // Prefer ID-based lookup if provided
275
+ if (id) {
276
+ const note = notesManager.getNoteById(id);
277
+ if (!note) {
278
+ return errorResponse(`Note with ID "${id}" not found`);
279
+ }
280
+ if (note.passwordProtected) {
281
+ return errorResponse(`Note "${note.title}" is password-protected and cannot be read. Unlock it in Notes.app first.`);
282
+ }
283
+ const plaintext = notesManager.getNotePlaintextById(id);
284
+ if (!plaintext) {
285
+ return errorResponse(`Failed to read plaintext of note "${note.title}"`);
286
+ }
287
+ return successResponse(plaintext, { title: note.title, plaintext });
288
+ }
289
+ // Fall back to title-based lookup
290
+ if (!title) {
291
+ return errorResponse("Either 'id' or 'title' is required");
292
+ }
293
+ const note = notesManager.getNoteDetails(title, account);
294
+ if (!note) {
295
+ return errorResponse(`Note "${title}" not found`);
296
+ }
297
+ if (note.passwordProtected) {
298
+ return errorResponse(`Note "${title}" is password-protected and cannot be read. Unlock it in Notes.app first.`);
299
+ }
300
+ const plaintext = notesManager.getNotePlaintext(title, account);
301
+ if (!plaintext) {
302
+ return errorResponse(`Failed to read plaintext of note "${title}"`);
303
+ }
304
+ return successResponse(plaintext, { title, plaintext });
305
+ }, "Error retrieving note plaintext"));
257
306
  // --- get-note-by-id ---
258
307
  server.registerTool("get-note-by-id", {
259
308
  description: "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).",
@@ -335,9 +384,51 @@ server.registerTool("show-note", {
335
384
  }
336
385
  return successResponse(`Shown note with ID "${id}" in Notes.app`, { id, separately });
337
386
  }, "Error showing note"));
387
+ // --- show-folder ---
388
+ server.registerTool("show-folder", {
389
+ description: "Use when: the user wants to reveal a known folder in Notes.app by id.\nReturns: confirmation that Notes.app accepted the show command.\nDo not use when: you only need the folder list (list-folders).\nNote: this opens or focuses the Notes UI. Get the id from list-folders.",
390
+ inputSchema: {
391
+ id: z.string().min(1, "Folder ID is required"),
392
+ separately: z
393
+ .boolean()
394
+ .optional()
395
+ .describe("Open in a separate window when supported by Notes.app"),
396
+ },
397
+ outputSchema: {
398
+ id: z.string().optional(),
399
+ separately: z.boolean().optional(),
400
+ },
401
+ }, withErrorHandling(({ id, separately = false }) => {
402
+ const success = notesManager.showFolderById(id, separately);
403
+ if (!success) {
404
+ return errorResponse(`Failed to show folder with ID "${id}"`);
405
+ }
406
+ return successResponse(`Shown folder with ID "${id}" in Notes.app`, { id, separately });
407
+ }, "Error showing folder"));
408
+ // --- show-account ---
409
+ server.registerTool("show-account", {
410
+ description: "Use when: the user wants to reveal a known account in Notes.app by id.\nReturns: confirmation that Notes.app accepted the show command.\nDo not use when: you only need the account list (list-accounts).\nNote: this opens or focuses the Notes UI. Get the id from list-accounts.",
411
+ inputSchema: {
412
+ id: z.string().min(1, "Account ID is required"),
413
+ separately: z
414
+ .boolean()
415
+ .optional()
416
+ .describe("Open in a separate window when supported by Notes.app"),
417
+ },
418
+ outputSchema: {
419
+ id: z.string().optional(),
420
+ separately: z.boolean().optional(),
421
+ },
422
+ }, withErrorHandling(({ id, separately = false }) => {
423
+ const success = notesManager.showAccountById(id, separately);
424
+ if (!success) {
425
+ return errorResponse(`Failed to show account with ID "${id}"`);
426
+ }
427
+ return successResponse(`Shown account with ID "${id}" in Notes.app`, { id, separately });
428
+ }, "Error showing account"));
338
429
  // --- update-note ---
339
430
  server.registerTool("update-note", {
340
- description: "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.",
431
+ description: "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, and run list-attachments first when the note may hold files, images, scans, PDFs, or audio, since a full-body replace can drop embedded attachments. Edits to shared notes are immediately visible to all collaborators.",
341
432
  inputSchema: {
342
433
  id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
343
434
  title: z.string().optional().describe("Current note title (use id instead when available)"),
@@ -1048,6 +1139,39 @@ server.registerTool("fetch-attachment", {
1048
1139
  }
1049
1140
  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 });
1050
1141
  }, "Error fetching attachment"));
1142
+ // --- show-attachment ---
1143
+ server.registerTool("show-attachment", {
1144
+ description: "Use when: the user wants to reveal one note attachment in Notes.app.\nReturns: confirmation that Notes.app revealed the attachment.\nDo not use when: you want the bytes (fetch-attachment) or a file on disk (save-attachment).\nNote: this opens or focuses the Notes UI. Get the ids from list-attachments first.",
1145
+ inputSchema: {
1146
+ noteId: z
1147
+ .string()
1148
+ .min(1, "noteId is required")
1149
+ .describe("CoreData note id (from search/list)"),
1150
+ attachmentId: z
1151
+ .string()
1152
+ .min(1, "attachmentId is required")
1153
+ .describe("Attachment id (from list-attachments)"),
1154
+ separately: z
1155
+ .boolean()
1156
+ .optional()
1157
+ .describe("Open in a separate window when supported by Notes.app"),
1158
+ },
1159
+ outputSchema: {
1160
+ noteId: z.string().optional(),
1161
+ attachmentId: z.string().optional(),
1162
+ separately: z.boolean().optional(),
1163
+ },
1164
+ }, withErrorHandling(({ noteId, attachmentId, separately = false }) => {
1165
+ const success = notesManager.showAttachmentById(noteId, attachmentId, separately);
1166
+ if (!success) {
1167
+ return errorResponse(`Failed to show attachment "${attachmentId}" on note "${noteId}"`);
1168
+ }
1169
+ return successResponse(`Shown attachment "${attachmentId}" in Notes.app`, {
1170
+ noteId,
1171
+ attachmentId,
1172
+ separately,
1173
+ });
1174
+ }, "Error showing attachment"));
1051
1175
  // --- export-notes-json ---
1052
1176
  server.registerTool("export-notes-json", {
1053
1177
  description: "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.",
@@ -1138,6 +1262,36 @@ server.registerTool("get-checklist-state", {
1138
1262
  const checked = result.items.filter((i) => i.done).length;
1139
1263
  return successResponse(`Checklist for "${note.title}" (${checked}/${result.items.length} done):\n${summary}`, { items: result.items, checked, total: result.items.length });
1140
1264
  }, "Error reading checklist state"));
1265
+ // --- get-note-metadata (BETA) ---
1266
+ server.registerTool("get-note-metadata", {
1267
+ description: "[BETA] Use when: reading note metadata AppleScript cannot expose — pinned state, checklist flags, trash/recovery state, preview snippet, password hint — by id.\nReturns: a metadata object; fields vary by macOS version and are omitted when unavailable.\nDo not use when: you need the body (get-note-content) or per-item checklist state (get-checklist-state).\nNote: reads the NoteStore SQLite database read-only and requires Full Disk Access. BETA — the database schema changes between macOS releases, so some fields may be absent. Works on trashed notes that AppleScript can no longer resolve.",
1268
+ inputSchema: {
1269
+ id: z.string().min(1, "Note ID is required. Use search-notes to find the note ID first."),
1270
+ },
1271
+ outputSchema: {
1272
+ pinned: z.boolean().optional(),
1273
+ hasChecklist: z.boolean().optional(),
1274
+ hasChecklistInProgress: z.boolean().optional(),
1275
+ recoveringFromTrash: z.boolean().optional(),
1276
+ passwordProtected: z.boolean().optional(),
1277
+ passwordHint: z.string().optional(),
1278
+ snippet: z.string().optional(),
1279
+ widgetSnippet: z.string().optional(),
1280
+ smartFolderQuery: z.string().optional(),
1281
+ },
1282
+ }, withErrorHandling(({ id }) => {
1283
+ // No AppleScript existence pre-check: reading straight from the database lets
1284
+ // this resolve trashed/recovering notes that `note id ...` can no longer find.
1285
+ const { metadata, message } = getNoteMetadata(id);
1286
+ if (!metadata) {
1287
+ return errorResponse(message || `Failed to read metadata for note "${id}"`);
1288
+ }
1289
+ const keys = Object.keys(metadata);
1290
+ const summary = keys.length === 0
1291
+ ? `No additional metadata is available for note "${id}" on this macOS version.`
1292
+ : keys.map((k) => `${k}: ${String(metadata[k])}`).join("\n");
1293
+ return successResponse(summary, metadata);
1294
+ }, "Error reading note metadata"));
1141
1295
  // =============================================================================
1142
1296
  // Server Startup
1143
1297
  // =============================================================================
@@ -0,0 +1,32 @@
1
+ export const NOTES_NORMALIZED_HTML_FIXTURES = [
2
+ {
3
+ name: "headingAndParagraphs",
4
+ description: "An <h1> title and two <div> paragraphs separated by a spacer row",
5
+ html: "<div><h1>Meeting Notes</h1></div><div>First line.</div><div><br></div><div>Second line.</div>",
6
+ expectedMarkdown: "# Meeting Notes\n\nFirst line.\n \n\nSecond line.",
7
+ },
8
+ {
9
+ name: "bulletList",
10
+ description: "An <h2> section heading followed by a native <ul> bullet list",
11
+ html: "<div><h2>Tasks</h2></div><ul><li>Buy milk</li><li>Walk dog</li></ul>",
12
+ expectedMarkdown: "## Tasks\n\n- Buy milk\n- Walk dog",
13
+ },
14
+ {
15
+ name: "inlineEmphasis",
16
+ description: "Inline <b> and <i> emphasis inside a paragraph div",
17
+ html: "<div>This is <b>bold</b> and <i>italic</i> text.</div>",
18
+ expectedMarkdown: "This is **bold** and _italic_ text.",
19
+ },
20
+ {
21
+ name: "codeSpan",
22
+ description: "A <tt> code span — the tag is dropped, only its text survives",
23
+ html: "<div>Run <tt>npm install</tt> first.</div>",
24
+ expectedMarkdown: "Run npm install first.",
25
+ },
26
+ {
27
+ name: "spacerRuns",
28
+ description: "Consecutive <div><br></div> spacer rows between two paragraphs",
29
+ html: "<div>A</div><div><br></div><div><br></div><div>B</div>",
30
+ expectedMarkdown: "A\n \n\n \n\nB",
31
+ },
32
+ ];
@@ -781,6 +781,53 @@ export class AppleNotesManager {
781
781
  }
782
782
  return result.output;
783
783
  }
784
+ /**
785
+ * Retrieves the plain-text content of a note by its exact title.
786
+ *
787
+ * Reads the note's `plaintext` property, which Notes derives from the body
788
+ * with all HTML markup removed. This is the text Notes itself exposes, so it
789
+ * is more faithful than converting the HTML body and skips the markup
790
+ * round-trip entirely.
791
+ *
792
+ * @param title - Exact title of the note
793
+ * @param account - Account to search in (defaults to iCloud)
794
+ * @returns Plain-text content of the note, or empty string if not found
795
+ */
796
+ getNotePlaintext(title, account) {
797
+ const targetAccount = this.resolveAccount(account);
798
+ const safeTitle = escapePlainStringForAppleScript(title);
799
+ const getCommand = `get plaintext of note "${safeTitle}"`;
800
+ const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
801
+ const result = executeAppleScript(script);
802
+ if (!result.success) {
803
+ console.error(`Failed to get plaintext of note "${title}":`, result.error);
804
+ return "";
805
+ }
806
+ return result.output;
807
+ }
808
+ /**
809
+ * Retrieves the plain-text content of a note by its CoreData ID.
810
+ *
811
+ * Reads the read-only `plaintext` property (the body with HTML removed). More
812
+ * reliable than getNotePlaintext() because IDs are unique across accounts.
813
+ *
814
+ * Note: Password-protected notes will fail with an AppleScript error. Callers
815
+ * should check for password protection beforehand using getNoteById().
816
+ *
817
+ * @param id - CoreData URL identifier for the note
818
+ * @returns Plain-text content of the note, or empty string if not found
819
+ */
820
+ getNotePlaintextById(id) {
821
+ const safeId = sanitizeId(id);
822
+ const getCommand = `get plaintext of note id "${safeId}"`;
823
+ const script = buildAppLevelScript(getCommand);
824
+ const result = executeAppleScript(script);
825
+ if (!result.success) {
826
+ console.error(`Failed to get plaintext of note with ID "${id}":`, result.error);
827
+ return "";
828
+ }
829
+ return result.output;
830
+ }
784
831
  /**
785
832
  * Retrieves a note by its unique CoreData ID.
786
833
  *
@@ -1604,6 +1651,91 @@ export class AppleNotesManager {
1604
1651
  }
1605
1652
  return true;
1606
1653
  }
1654
+ /**
1655
+ * Reveals a folder in the Notes.app UI by its id.
1656
+ *
1657
+ * Wraps the Notes `show` command, which the scripting dictionary exposes for
1658
+ * folders as well as notes. This opens or focuses the Notes UI on the folder.
1659
+ *
1660
+ * @param id - CoreData identifier for the folder (from list-folders)
1661
+ * @param separately - Open in a separate window when supported by Notes.app
1662
+ * @returns true if Notes.app accepted the show command, false otherwise
1663
+ */
1664
+ showFolderById(id, separately = false) {
1665
+ const safeId = sanitizeId(id);
1666
+ const separatelyClause = separately ? " separately true" : "";
1667
+ const result = executeAppleScript(buildAppLevelScript(`show folder id "${safeId}"${separatelyClause}`));
1668
+ if (!result.success) {
1669
+ console.error(`Failed to show folder with ID "${id}":`, result.error);
1670
+ return false;
1671
+ }
1672
+ return true;
1673
+ }
1674
+ /**
1675
+ * Reveals an account in the Notes.app UI by its id.
1676
+ *
1677
+ * Wraps the Notes `show` command, which the scripting dictionary exposes for
1678
+ * accounts as well as notes. This opens or focuses the Notes UI on the account.
1679
+ *
1680
+ * @param id - CoreData identifier for the account (from list-accounts)
1681
+ * @param separately - Open in a separate window when supported by Notes.app
1682
+ * @returns true if Notes.app accepted the show command, false otherwise
1683
+ */
1684
+ showAccountById(id, separately = false) {
1685
+ const safeId = sanitizeId(id);
1686
+ const separatelyClause = separately ? " separately true" : "";
1687
+ const result = executeAppleScript(buildAppLevelScript(`show account id "${safeId}"${separatelyClause}`));
1688
+ if (!result.success) {
1689
+ console.error(`Failed to show account with ID "${id}":`, result.error);
1690
+ return false;
1691
+ }
1692
+ return true;
1693
+ }
1694
+ /**
1695
+ * Reveals an attachment in the Notes.app UI.
1696
+ *
1697
+ * Attachments are elements of a note, so they cannot be referenced at the
1698
+ * application level by id alone. This resolves the attachment within its note
1699
+ * (the same lookup used by save-attachment) and then runs the Notes `show`
1700
+ * command on it, opening or focusing the Notes UI on the attachment.
1701
+ *
1702
+ * @param noteId - CoreData identifier for the note containing the attachment
1703
+ * @param attachmentId - id of the attachment (from list-attachments)
1704
+ * @param separately - Open in a separate window when supported by Notes.app
1705
+ * @returns true if Notes.app revealed the attachment, false otherwise
1706
+ */
1707
+ showAttachmentById(noteId, attachmentId, separately = false) {
1708
+ const safeNoteId = sanitizeId(noteId);
1709
+ const safeAttId = escapePlainStringForAppleScript(attachmentId);
1710
+ const separatelyClause = separately ? " separately true" : "";
1711
+ const script = `
1712
+ tell application "Notes"
1713
+ set theNote to note id "${safeNoteId}"
1714
+ set theAttachment to missing value
1715
+ repeat with a in attachments of theNote
1716
+ if (id of a as text) is "${safeAttId}" then
1717
+ set theAttachment to a
1718
+ exit repeat
1719
+ end if
1720
+ end repeat
1721
+ if theAttachment is missing value then
1722
+ return "ERR${AS_FIELD_SEP}attachment not found"
1723
+ end if
1724
+ show theAttachment${separatelyClause}
1725
+ return "OK"
1726
+ end tell
1727
+ `;
1728
+ const result = executeAppleScript(script);
1729
+ if (!result.success) {
1730
+ console.error(`Failed to show attachment "${attachmentId}" on note "${noteId}":`, result.error);
1731
+ return false;
1732
+ }
1733
+ if ((result.output ?? "").trim().startsWith("ERR")) {
1734
+ console.error(`Attachment "${attachmentId}" not found on note "${noteId}"`);
1735
+ return false;
1736
+ }
1737
+ return true;
1738
+ }
1607
1739
  // ===========================================================================
1608
1740
  // Health Check
1609
1741
  // ===========================================================================
@@ -664,6 +664,45 @@ describe("AppleNotesManager", () => {
664
664
  expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('tell account "Gmail"'));
665
665
  });
666
666
  });
667
+ describe("getNotePlaintext", () => {
668
+ it("reads the note's plaintext property by title", () => {
669
+ mockExecuteAppleScript.mockReturnValue({
670
+ success: true,
671
+ output: "Shopping List\n- Eggs\n- Milk",
672
+ });
673
+ const text = manager.getNotePlaintext("Shopping List");
674
+ expect(text).toBe("Shopping List\n- Eggs\n- Milk");
675
+ expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('get plaintext of note "Shopping List"'));
676
+ });
677
+ it("returns empty string when the note is not found", () => {
678
+ mockExecuteAppleScript.mockReturnValue({
679
+ success: false,
680
+ output: "",
681
+ error: 'Can\'t get note "Missing"',
682
+ });
683
+ expect(manager.getNotePlaintext("Missing Note")).toBe("");
684
+ });
685
+ it("uses the specified account", () => {
686
+ mockExecuteAppleScript.mockReturnValue({ success: true, output: "Content" });
687
+ manager.getNotePlaintext("My Note", "Gmail");
688
+ expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('tell account "Gmail"'));
689
+ });
690
+ });
691
+ describe("getNotePlaintextById", () => {
692
+ it("reads the note's plaintext property by id at the application level", () => {
693
+ mockExecuteAppleScript.mockReturnValue({ success: true, output: "Just the text" });
694
+ const text = manager.getNotePlaintextById("x-coredata://ABC/ICNote/p1");
695
+ expect(text).toBe("Just the text");
696
+ expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('get plaintext of note id "x-coredata://ABC/ICNote/p1"'));
697
+ });
698
+ it("returns empty string when Notes.app rejects the read", () => {
699
+ mockExecuteAppleScript.mockReturnValue({ success: false, output: "", error: "no such note" });
700
+ expect(manager.getNotePlaintextById("x-coredata://ABC/ICNote/p1")).toBe("");
701
+ });
702
+ it("rejects malformed IDs", () => {
703
+ expect(() => manager.getNotePlaintextById("arbitrary string")).toThrow();
704
+ });
705
+ });
667
706
  // ---------------------------------------------------------------------------
668
707
  // Password Protection Helpers
669
708
  // ---------------------------------------------------------------------------
@@ -1615,6 +1654,76 @@ describe("AppleNotesManager", () => {
1615
1654
  expect(manager.showNoteById("x-coredata://ABC/ICNote/p1")).toBe(false);
1616
1655
  });
1617
1656
  });
1657
+ describe("showFolderById", () => {
1658
+ it("shows a folder by id", () => {
1659
+ mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
1660
+ expect(manager.showFolderById("x-coredata://ABC/ICFolder/p1")).toBe(true);
1661
+ expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('show folder id "x-coredata://ABC/ICFolder/p1"'));
1662
+ });
1663
+ it("can request a separate window", () => {
1664
+ mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
1665
+ manager.showFolderById("x-coredata://ABC/ICFolder/p1", true);
1666
+ expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("separately true"));
1667
+ });
1668
+ it("returns false when Notes.app rejects the show command", () => {
1669
+ mockExecuteAppleScript.mockReturnValue({
1670
+ success: false,
1671
+ output: "",
1672
+ error: "no such folder",
1673
+ });
1674
+ expect(manager.showFolderById("x-coredata://ABC/ICFolder/p1")).toBe(false);
1675
+ });
1676
+ });
1677
+ describe("showAccountById", () => {
1678
+ it("shows an account by id", () => {
1679
+ mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
1680
+ expect(manager.showAccountById("x-coredata://ABC/ICAccount/p1")).toBe(true);
1681
+ expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('show account id "x-coredata://ABC/ICAccount/p1"'));
1682
+ });
1683
+ it("can request a separate window", () => {
1684
+ mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
1685
+ manager.showAccountById("x-coredata://ABC/ICAccount/p1", true);
1686
+ expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("separately true"));
1687
+ });
1688
+ it("returns false when Notes.app rejects the show command", () => {
1689
+ mockExecuteAppleScript.mockReturnValue({
1690
+ success: false,
1691
+ output: "",
1692
+ error: "no such account",
1693
+ });
1694
+ expect(manager.showAccountById("x-coredata://ABC/ICAccount/p1")).toBe(false);
1695
+ });
1696
+ });
1697
+ describe("showAttachmentById", () => {
1698
+ it("resolves the attachment within its note and shows it", () => {
1699
+ mockExecuteAppleScript.mockReturnValue({ success: true, output: "OK" });
1700
+ expect(manager.showAttachmentById("x-coredata://ABC/ICNote/p1", "att-123")).toBe(true);
1701
+ const script = mockExecuteAppleScript.mock.calls[0][0];
1702
+ expect(script).toContain('set theNote to note id "x-coredata://ABC/ICNote/p1"');
1703
+ expect(script).toContain('is "att-123"');
1704
+ expect(script).toContain("show theAttachment");
1705
+ });
1706
+ it("can request a separate window", () => {
1707
+ mockExecuteAppleScript.mockReturnValue({ success: true, output: "OK" });
1708
+ manager.showAttachmentById("x-coredata://ABC/ICNote/p1", "att-123", true);
1709
+ expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("separately true"));
1710
+ });
1711
+ it("returns false when the attachment is not found on the note", () => {
1712
+ mockExecuteAppleScript.mockReturnValue({
1713
+ success: true,
1714
+ output: `ERR${F}attachment not found`,
1715
+ });
1716
+ expect(manager.showAttachmentById("x-coredata://ABC/ICNote/p1", "missing")).toBe(false);
1717
+ });
1718
+ it("returns false when Notes.app rejects the show command", () => {
1719
+ mockExecuteAppleScript.mockReturnValue({
1720
+ success: false,
1721
+ output: "",
1722
+ error: "no such note",
1723
+ });
1724
+ expect(manager.showAttachmentById("x-coredata://ABC/ICNote/p1", "att-123")).toBe(false);
1725
+ });
1726
+ });
1618
1727
  // ---------------------------------------------------------------------------
1619
1728
  // Health Check
1620
1729
  // ---------------------------------------------------------------------------
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { NOTES_NORMALIZED_HTML_FIXTURES } from "../services/__fixtures__/notesNormalizedHtml.js";
3
+ // Mock the same seams the main manager test does: AppleScript execution and the
4
+ // SQLite-backed checklist reader (no Full Disk Access in unit tests).
5
+ vi.mock("@/utils/applescript.js", () => ({
6
+ executeAppleScript: vi.fn(),
7
+ }));
8
+ vi.mock("@/utils/checklistParser.js", () => ({
9
+ getChecklistItems: vi.fn().mockReturnValue({ items: null }),
10
+ }));
11
+ import { executeAppleScript } from "../utils/applescript.js";
12
+ import { AppleNotesManager } from "../services/appleNotesManager.js";
13
+ const mockExecuteAppleScript = vi.mocked(executeAppleScript);
14
+ const NOTE_ID = "x-coredata://ABC/ICNote/p1";
15
+ /**
16
+ * Regression coverage for Notes-normalized HTML -> Markdown conversion.
17
+ *
18
+ * The fixtures encode the HTML shape Apple Notes returns and the Markdown the
19
+ * server currently emits. Routing through getNoteMarkdownById exercises the real
20
+ * Turndown pipeline (including the notesDivs rule) with AppleScript mocked to
21
+ * return the fixture body.
22
+ */
23
+ describe("Notes-normalized HTML to Markdown", () => {
24
+ let manager;
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ manager = new AppleNotesManager();
28
+ });
29
+ for (const fixture of NOTES_NORMALIZED_HTML_FIXTURES) {
30
+ it(`converts ${fixture.name} (${fixture.description})`, () => {
31
+ mockExecuteAppleScript.mockReturnValue({ success: true, output: fixture.html });
32
+ const markdown = manager.getNoteMarkdownById(NOTE_ID);
33
+ expect(markdown).toBe(fixture.expectedMarkdown);
34
+ });
35
+ }
36
+ it("documents that a <div><br></div> spacer leaves a two-space line", () => {
37
+ mockExecuteAppleScript.mockReturnValue({
38
+ success: true,
39
+ output: "<div>A</div><div><br></div><div>B</div>",
40
+ });
41
+ const markdown = manager.getNoteMarkdownById(NOTE_ID);
42
+ // The spacer survives as a stray " " line — the Markdown-side fingerprint of
43
+ // the whitespace-accumulation behavior CLAUDE.md warns about.
44
+ expect(markdown).toBe("A\n \n\nB");
45
+ });
46
+ it("documents that <tt> is dropped, keeping only its text", () => {
47
+ mockExecuteAppleScript.mockReturnValue({
48
+ success: true,
49
+ output: "<div>Run <tt>npm install</tt> first.</div>",
50
+ });
51
+ const markdown = manager.getNoteMarkdownById(NOTE_ID);
52
+ expect(markdown).toBe("Run npm install first.");
53
+ expect(markdown).not.toContain("`");
54
+ });
55
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Apple Notes Read-Only Metadata Reader [BETA]
3
+ *
4
+ * Reads note metadata that the AppleScript dictionary does not expose (pinned
5
+ * state, checklist flags, trash/recovery, preview snippet, password hint) by
6
+ * querying the NoteStore SQLite database directly.
7
+ *
8
+ * Unlike the note body (a gzipped protobuf blob in ZICNOTEDATA.ZDATA), these are
9
+ * plain scalar columns on ZICCLOUDSYNCINGOBJECT, so no protobuf decoding is
10
+ * needed — an ordinary SELECT is enough.
11
+ *
12
+ * BETA / safety:
13
+ * - The database is opened READ-ONLY (`sqlite3 -readonly`). This code never
14
+ * writes to the live store; doing so would corrupt CloudKit sync state.
15
+ * - Requires Full Disk Access for the host process.
16
+ * - The schema changes between macOS releases, so the reader feature-detects
17
+ * which columns exist (PRAGMA table_info) and only selects those.
18
+ *
19
+ * @module utils/noteMetadata
20
+ * @see TECHNICAL_NOTES.md#read-only-metadata-columns-verified-macos-27--notes-413
21
+ */
22
+ import { execFileSync } from "child_process";
23
+ import * as fs from "fs";
24
+ import * as path from "path";
25
+ import * as os from "os";
26
+ const NOTES_DB_PATH = path.join(os.homedir(), "Library/Group Containers/group.com.apple.notes/NoteStore.sqlite");
27
+ const FDA_MESSAGE = "Full Disk Access is required to read note metadata. " +
28
+ "Grant access in System Settings > Privacy & Security > Full Disk Access, " +
29
+ "then add and restart this application.";
30
+ /**
31
+ * Friendly field name -> NoteStore column. This is a fixed allowlist; nothing
32
+ * here is built from user input, so the column names are never an injection
33
+ * vector. `bool` columns store 0/1; `text` columns store strings.
34
+ */
35
+ const COLUMN_MAP = [
36
+ { key: "pinned", column: "ZISPINNED", type: "bool" },
37
+ { key: "hasChecklist", column: "ZHASCHECKLIST", type: "bool" },
38
+ { key: "hasChecklistInProgress", column: "ZHASCHECKLISTINPROGRESS", type: "bool" },
39
+ { key: "recoveringFromTrash", column: "ZISRECOVERINGFROMTRASH", type: "bool" },
40
+ { key: "passwordProtected", column: "ZISPASSWORDPROTECTED", type: "bool" },
41
+ { key: "passwordHint", column: "ZPASSWORDHINT", type: "text" },
42
+ { key: "snippet", column: "ZSNIPPET", type: "text" },
43
+ { key: "widgetSnippet", column: "ZWIDGETSNIPPET", type: "text" },
44
+ { key: "smartFolderQuery", column: "ZSMARTFOLDERQUERYJSON", type: "text" },
45
+ ];
46
+ /**
47
+ * Runs a read-only sqlite3 query against the live NoteStore and returns trimmed
48
+ * stdout. Throws on failure (callers classify the error).
49
+ *
50
+ * Uses execFileSync with an argument array (no shell), so the database path's
51
+ * spaces and the query string are passed verbatim and shell metacharacters are
52
+ * never interpreted. The only dynamic value in any query is the note's primary
53
+ * key, which callers constrain to digits before it reaches here.
54
+ */
55
+ function runSqlite(query) {
56
+ return execFileSync("sqlite3", ["-readonly", NOTES_DB_PATH, query], {
57
+ encoding: "utf8",
58
+ timeout: 5000,
59
+ stdio: ["pipe", "pipe", "pipe"],
60
+ }).trim();
61
+ }
62
+ /**
63
+ * Returns the set of column names present on ZICCLOUDSYNCINGOBJECT, so the
64
+ * reader can skip columns that do not exist on this macOS version.
65
+ */
66
+ function presentColumns() {
67
+ const out = runSqlite("PRAGMA table_info(ZICCLOUDSYNCINGOBJECT);");
68
+ const cols = new Set();
69
+ for (const line of out.split("\n")) {
70
+ // Each row: cid|name|type|notnull|dflt_value|pk
71
+ const name = line.split("|")[1];
72
+ if (name)
73
+ cols.add(name);
74
+ }
75
+ return cols;
76
+ }
77
+ /**
78
+ * Reads read-only metadata for a note by its CoreData ID.
79
+ *
80
+ * @param noteId - CoreData URL identifier (e.g., "x-coredata://ABC/ICNote/p123")
81
+ * @returns Structured result with the metadata, an error type, and a message
82
+ */
83
+ export function getNoteMetadata(noteId) {
84
+ const pkMatch = noteId.match(/\/p(\d+)$/);
85
+ if (!pkMatch) {
86
+ return {
87
+ metadata: null,
88
+ error: "invalid_id",
89
+ message: `Invalid note ID format: "${noteId}". Expected format: x-coredata://UUID/ICNote/pNNN`,
90
+ };
91
+ }
92
+ const pk = pkMatch[1];
93
+ if (!fs.existsSync(NOTES_DB_PATH)) {
94
+ return { metadata: null, error: "no_fda", message: FDA_MESSAGE };
95
+ }
96
+ try {
97
+ const available = presentColumns();
98
+ const selected = COLUMN_MAP.filter((c) => available.has(c.column));
99
+ if (selected.length === 0) {
100
+ // Schema has none of the known columns (very old or very new macOS).
101
+ return { metadata: {} };
102
+ }
103
+ const pairs = selected.map((c) => `'${c.key}', ${c.column}`).join(", ");
104
+ const row = runSqlite(`SELECT json_object(${pairs}) FROM ZICCLOUDSYNCINGOBJECT WHERE Z_PK = ${pk};`);
105
+ if (!row) {
106
+ return {
107
+ metadata: null,
108
+ error: "not_found",
109
+ message: `No note found in the database for ID "${noteId}".`,
110
+ };
111
+ }
112
+ const raw = JSON.parse(row);
113
+ const metadata = {};
114
+ for (const c of selected) {
115
+ const value = raw[c.key];
116
+ if (value === null || value === undefined)
117
+ continue;
118
+ if (c.type === "bool") {
119
+ metadata[c.key] = value === 1 || value === "1" || value === true;
120
+ }
121
+ else {
122
+ metadata[c.key] = String(value);
123
+ }
124
+ }
125
+ return { metadata: metadata };
126
+ }
127
+ catch (error) {
128
+ const message = error instanceof Error ? error.message : String(error);
129
+ if (message.includes("authorization denied") || message.includes("unable to open database")) {
130
+ return { metadata: null, error: "no_fda", message: FDA_MESSAGE };
131
+ }
132
+ console.error(`Failed to read note metadata: ${message}`);
133
+ return { metadata: null, error: "query_error", message: "Failed to read note metadata." };
134
+ }
135
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Tests for the read-only note metadata reader.
3
+ *
4
+ * The NoteStore SQLite access is mocked, so these exercise the pk extraction,
5
+ * column feature-detection, JSON parsing, boolean coercion, and error
6
+ * classification without touching a real database.
7
+ */
8
+ import { describe, it, expect, vi, beforeEach } from "vitest";
9
+ vi.mock("child_process", () => ({
10
+ execFileSync: vi.fn(),
11
+ }));
12
+ vi.mock("fs", () => ({
13
+ existsSync: vi.fn(() => true),
14
+ }));
15
+ import { execFileSync } from "child_process";
16
+ import { existsSync } from "fs";
17
+ import { getNoteMetadata } from "./noteMetadata.js";
18
+ const mockExecFileSync = vi.mocked(execFileSync);
19
+ const mockExistsSync = vi.mocked(existsSync);
20
+ const NOTE_ID = "x-coredata://ABC/ICNote/p123";
21
+ /** Builds a PRAGMA table_info dump from a list of column names. */
22
+ function tableInfo(columns) {
23
+ return columns.map((name, i) => `${i}|${name}|INTEGER|0||0`).join("\n");
24
+ }
25
+ /** The query string is the third sqlite3 argument: [-readonly, dbPath, query]. */
26
+ function queryOf(call) {
27
+ const args = call[1];
28
+ return args[2];
29
+ }
30
+ describe("getNoteMetadata", () => {
31
+ beforeEach(() => {
32
+ vi.clearAllMocks();
33
+ mockExistsSync.mockReturnValue(true);
34
+ });
35
+ it("rejects a malformed note id without touching the database", () => {
36
+ const result = getNoteMetadata("not-a-real-id");
37
+ expect(result.metadata).toBeNull();
38
+ expect(result.error).toBe("invalid_id");
39
+ expect(mockExecFileSync).not.toHaveBeenCalled();
40
+ });
41
+ it("reads pinned/checklist/snippet and coerces 0/1 to booleans", () => {
42
+ mockExecFileSync.mockImplementation((_cmd, args) => {
43
+ const query = args[2];
44
+ if (query.includes("table_info")) {
45
+ return tableInfo(["Z_PK", "ZISPINNED", "ZHASCHECKLIST", "ZSNIPPET", "ZPASSWORDHINT"]);
46
+ }
47
+ return JSON.stringify({
48
+ pinned: 1,
49
+ hasChecklist: 0,
50
+ snippet: "Hello world",
51
+ passwordHint: null,
52
+ });
53
+ });
54
+ const result = getNoteMetadata(NOTE_ID);
55
+ expect(result.error).toBeUndefined();
56
+ expect(result.metadata).toEqual({
57
+ pinned: true,
58
+ hasChecklist: false,
59
+ snippet: "Hello world",
60
+ });
61
+ // A NULL column (passwordHint) is omitted, not included as null.
62
+ expect(result.metadata).not.toHaveProperty("passwordHint");
63
+ });
64
+ it("only selects columns that exist on this schema", () => {
65
+ mockExecFileSync.mockImplementation((_cmd, args) => {
66
+ const query = args[2];
67
+ if (query.includes("table_info"))
68
+ return tableInfo(["Z_PK", "ZISPINNED"]);
69
+ return JSON.stringify({ pinned: 1 });
70
+ });
71
+ const result = getNoteMetadata(NOTE_ID);
72
+ expect(result.metadata).toEqual({ pinned: true });
73
+ // The SELECT must not reference a column the schema lacks.
74
+ const selectCall = mockExecFileSync.mock.calls.find((c) => queryOf(c).includes("json_object"));
75
+ expect(selectCall).toBeDefined();
76
+ expect(queryOf(selectCall)).toContain("ZISPINNED");
77
+ expect(queryOf(selectCall)).not.toContain("ZSNIPPET");
78
+ });
79
+ it("returns not_found when no row matches the primary key", () => {
80
+ mockExecFileSync.mockImplementation((_cmd, args) => {
81
+ const query = args[2];
82
+ if (query.includes("table_info"))
83
+ return tableInfo(["Z_PK", "ZISPINNED"]);
84
+ return "";
85
+ });
86
+ const result = getNoteMetadata(NOTE_ID);
87
+ expect(result.metadata).toBeNull();
88
+ expect(result.error).toBe("not_found");
89
+ });
90
+ it("classifies a Full Disk Access denial", () => {
91
+ mockExecFileSync.mockImplementation(() => {
92
+ throw new Error("Error: authorization denied");
93
+ });
94
+ const result = getNoteMetadata(NOTE_ID);
95
+ expect(result.metadata).toBeNull();
96
+ expect(result.error).toBe("no_fda");
97
+ expect(result.message).toContain("Full Disk Access");
98
+ });
99
+ it("returns no_fda when the database file is missing", () => {
100
+ mockExistsSync.mockReturnValue(false);
101
+ const result = getNoteMetadata(NOTE_ID);
102
+ expect(result.metadata).toBeNull();
103
+ expect(result.error).toBe("no_fda");
104
+ expect(mockExecFileSync).not.toHaveBeenCalled();
105
+ });
106
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
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",