apple-notes-mcp 2.9.3 → 2.9.5

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.
Files changed (3) hide show
  1. package/README.md +121 -2
  2. package/build/index.js +577 -10
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -634,6 +634,74 @@ stable code in brackets, such as `[encrypted]` or `[no-full-disk-access]`.
634
634
 
635
635
  ---
636
636
 
637
+ #### `list-note-paragraphs`
638
+
639
+ Lists one note's non-empty paragraphs in body order, read-only from the
640
+ NoteStore database. Each paragraph has `blockIndex` (its index in
641
+ `get-note-blocks`, where empty paragraphs also count), `text`, `style`,
642
+ `styleType`, `paragraphId` (the UUID Notes stores on the paragraph's first
643
+ text run), and `paragraphIdStatus`:
644
+
645
+ - `unique`: no other paragraph in the note carries this ID. The paragraph also
646
+ gets `url`, a direct
647
+ `applenotes://showNote?identifier=<note>&paragraphID=<paragraph>` link that
648
+ opens Notes at that paragraph.
649
+ - `shared`: other paragraphs carry the same ID (`sharedWith` counts them).
650
+ Notes copies the ID when a paragraph is split, so this is common for body
651
+ text. No `url` is given, because it could open the wrong paragraph.
652
+ - `missing`: no ID is stored.
653
+
654
+ `mixedParagraphIds: true` marks a paragraph whose text runs carry more than
655
+ one ID; only the first-run ID is used. Titles and headings usually have unique
656
+ IDs.
657
+
658
+ **Requires:** Full Disk Access. Password-protected notes are refused.
659
+
660
+ | Parameter | Type | Required | Description |
661
+ |-----------|------|----------|-------------|
662
+ | `id` | string | One of `id`, `title` | Exact note ID, or the note's Notes UUID |
663
+ | `title` | string | One of `id`, `title` | Exact note title; must match one note. Notes in Recently Deleted are ignored, as in `list-notes` |
664
+ | `folder` | string | No | With `title`: the folder's name or full path as `list-folders` shows it, such as `Work/Clients` |
665
+ | `linkableOnly` | boolean | No | Return only paragraphs with a `url` |
666
+ | `offset` | number | No | First paragraph to return (default 0). Use `page.nextOffset` |
667
+ | `limit` | number | No | Maximum paragraphs per page (default 500, max 5000) |
668
+
669
+ ---
670
+
671
+ #### `get-paragraph-link`
672
+
673
+ Returns a direct link to one paragraph of a note:
674
+ `applenotes://showNote?identifier=<note>&paragraphID=<paragraph>`. It selects
675
+ the note like `list-note-paragraphs` and the paragraph by exactly one of:
676
+
677
+ - `contains`: a snippet of the paragraph. Case, runs of spaces, and Unicode
678
+ width are ignored.
679
+ - `match`: the whole paragraph, compared the same way.
680
+ - `blockIndex`: from `list-note-paragraphs`.
681
+
682
+ When `contains` or `match` hits several paragraphs, pass `occurrence` (1-based)
683
+ or a longer snippet.
684
+
685
+ The link is returned only when the paragraph's ID is `unique` in the note.
686
+ Otherwise the result is an error whose `structuredContent` carries the usual
687
+ `code` plus a `reason`: `paragraph-id-shared`, `paragraph-id-missing`,
688
+ `no-match`, `ambiguous-paragraph`, `occurrence-out-of-range`, `ambiguous-note`
689
+ (several notes have that title; the message lists their folders), `not-found`,
690
+ `encrypted`, or `no-body`. The tool never creates or changes a paragraph ID,
691
+ and an edit in Notes can later replace the ID and break the link.
692
+
693
+ **Requires:** Full Disk Access.
694
+
695
+ | Parameter | Type | Required | Description |
696
+ |-----------|------|----------|-------------|
697
+ | `id`, `title`, `folder` | string | One of `id`, `title` | Note selector, as in `list-note-paragraphs` |
698
+ | `contains` | string | One of `contains`, `match`, `blockIndex` | Snippet of the paragraph |
699
+ | `match` | string | One of `contains`, `match`, `blockIndex` | The whole paragraph |
700
+ | `blockIndex` | number | One of `contains`, `match`, `blockIndex` | The paragraph's `blockIndex` |
701
+ | `occurrence` | number | No | Which match to use when several paragraphs match |
702
+
703
+ ---
704
+
637
705
  #### `get-note-structure`
638
706
 
639
707
  Returns a read-only overview of one note from the NoteStore database, in one
@@ -678,6 +746,57 @@ HTML.
678
746
 
679
747
  ---
680
748
 
749
+ #### `list-note-links`
750
+
751
+ Lists links, read-only from the NoteStore database, in one note (`id`) or
752
+ across a `folder`, an `account`, or the whole library (no selector). Each link
753
+ has a `kind`:
754
+
755
+ | Kind | What it is | Where Notes stores it |
756
+ |------|------------|-----------------------|
757
+ | `inline` | A hyperlink on text | Inside the note body |
758
+ | `card` | A rich link preview | An attachment row with the URL and title |
759
+ | `note` | A native link chip to another note | An inline-attachment row with a Notes deep link |
760
+ | `section` | A native link chip to a heading or paragraph | Same, with `paragraphID` in the deep link |
761
+
762
+ Each row has `url`, `text` (label), `linkSafe`, `targetNote` and `paragraphId`
763
+ for Notes deep links, `section` for section chips, and for cards
764
+ `attachmentId` and `previewPath` (Notes' largest cached preview image, found
765
+ the same way [`list-attachments`](#list-attachments) finds it, or null when
766
+ Notes has none). Each row also names its source: `noteId`, `noteIdentifier`,
767
+ `noteTitle`, `noteModified`, `folder`, `folderPath` (as `list-folders` prints
768
+ it), `account`, and `accountIdentifier`. When bodies were decoded, `start` and
769
+ `blockIndex` give the link's position, and `inBody: false` marks a card or chip
770
+ row with no marker left in the body.
771
+
772
+ Inline links need every body in scope decompressed and decoded, so a folder,
773
+ account, or library scan includes them only with `includeInline: true`
774
+ (slower). A single note always includes them. A `folder` scope includes its
775
+ subfolders unless `includeSubfolders` is false. Scans skip Recently Deleted
776
+ and folderless notes; a note requested by `id` is read wherever it is. Links
777
+ come newest-modified note first, in body order within a note.
778
+
779
+ **Requires:** Full Disk Access.
780
+
781
+ | Parameter | Type | Required | Description |
782
+ |-----------|------|----------|-------------|
783
+ | `id` | string | No | One exact note ID (x-coredata id, Notes UUID, or numeric key). Do not combine with `account` or `folder` |
784
+ | `account` | string | No | Account name (exact or unique-prefix match, as in the other tools) |
785
+ | `folder` | string | No | Folder name (any depth, must be unique) or path from the top level as `list-folders` prints it, such as `Work/Clients`; escape a literal slash as `\/` |
786
+ | `includeSubfolders` | boolean | No | With `folder`, also list notes in its subfolders (default true) |
787
+ | `includeInline` | boolean | No | Decode bodies for inline links (default true for `id`, false otherwise) |
788
+ | `kinds` | string[] | No | Only these kinds: `inline`, `card`, `note`, `section` |
789
+ | `offset` | number | No | First link to return (default 0). Use `page.nextOffset` |
790
+ | `limit` | number | No | Maximum links per page (default 200, max 2000) |
791
+
792
+ The response also reports `scope`, `counts` per kind, `notesInScope`,
793
+ `notesWithoutBody` (locked, empty or undecodable bodies when inline links were
794
+ requested), and `page`. A page stops early to stay under
795
+ `APPLE_NOTES_MCP_BLOCKS_MAX_BYTES`. An ambiguous folder name is refused with a
796
+ message that lists the matching paths.
797
+
798
+ ---
799
+
681
800
  #### `list-native-tags`
682
801
 
683
802
  Lists actual native Notes tags. This differs from textual hashtag search. It is
@@ -2258,7 +2377,7 @@ All configuration is optional — the server works out of the box. Override beha
2258
2377
  | `APPLE_NOTES_MCP_CONFIG_FILE` | `~/Library/Application Support/apple-notes-mcp/config.json` | Path to the JSON config file (see below). |
2259
2378
  | `APPLE_NOTES_MCP_TIMEOUT_MS` | `30000` (30 s) | Total AppleScript operation timeout, including retry attempts and delays. Raise it if full-library operations (large searches, exports) time out on a big Notes library. Per-call `timeoutMs` options still win, and a write tool's `timeoutSeconds` argument overrides it for that call. |
2260
2379
  | `APPLE_NOTES_MCP_EXPORT_MAX_BYTES` | `8388608` (8 MB) | Largest response `export-notes-json` sends; a page closes early to stay under it. `export-notes-markdown` returns inline Markdown up to half of it. The default sits below the 10 MB per-message limit of MCP SDK stdio clients, which drop the connection on anything larger. Raise it only if your MCP client accepts bigger messages. |
2261
- | `APPLE_NOTES_MCP_BLOCKS_MAX_BYTES` | `4194304` (4 MB) | Largest block payload one [`get-note-blocks`](#get-note-blocks) page returns; the page closes early to stay under it, and a single oversized paragraph comes back with `textOmitted: true`. [`get-note-structure`](#get-note-structure) also omits note text larger than this. |
2380
+ | `APPLE_NOTES_MCP_BLOCKS_MAX_BYTES` | `4194304` (4 MB) | Largest block payload one [`get-note-blocks`](#get-note-blocks) page returns; the page closes early to stay under it, and a single oversized paragraph comes back with `textOmitted: true`. [`get-note-structure`](#get-note-structure) also omits note text larger than this. A [`list-note-paragraphs`](#list-note-paragraphs) or [`list-note-links`](#list-note-links) page also stops early to stay under it. |
2262
2381
  | `APPLE_NOTES_MCP_MAX_RETRIES` | `2` | Maximum attempts for a read-only AppleScript call that fails with a **transient** error (Notes.app busy / not responding / lost connection). `2` means one retry; set `1` to fail fast with no retries. Retries share the single `APPLE_NOTES_MCP_TIMEOUT_MS` budget rather than each getting a fresh one, and a retry is skipped when under a second of that budget remains — so this is a ceiling, not a guarantee. In particular a call that exhausts the budget with a **timeout** has no time left to retry by construction. Mutating operations run once because a timeout can occur after Notes.app applied the change. Non-transient errors (e.g. "note not found") never retry. |
2263
2382
  | `APPLE_NOTES_MCP_RETRY_DELAY_MS` | `1000` (1 s) | Base delay before the first retry; subsequent retries back off exponentially (1s, 2s, 4s, ...). |
2264
2383
  | `APPLE_NOTES_MCP_ENABLE_PRIVATE` | unset | Set to `1` to allow the opt-in [private helper](#private-helper-opt-in-unsupported-apple-api). Any other value keeps it off. |
@@ -2291,7 +2410,7 @@ MCP stores no secrets, but as a general rule keep only non-secret config here.
2291
2410
 
2292
2411
  ## Full Disk Access
2293
2412
 
2294
- Several tools read directly from the Apple Notes SQLite database, which lives in a macOS-protected directory. Those tools require **Full Disk Access** for the process running the MCP server: `get-checklist-state`, `get-note-metadata`, `get-note-blocks`, `get-note-structure`, `export-notes-markdown`, `export-notes-html`, `get-audio-transcripts`, `list-special-notes`, `list-native-tags`, `list-recent-notes`, `list-folder-tree`, `get-note-link`, the checklist annotations in `get-note-markdown`, `list-attachments` with `includePaths` or `firstImage`, `export-attachments`, `list-paper-attachments`, `export-paper-image`, and the database half of `get-sync-status`.
2413
+ Several tools read directly from the Apple Notes SQLite database, which lives in a macOS-protected directory. Those tools require **Full Disk Access** for the process running the MCP server: `get-checklist-state`, `get-note-metadata`, `get-note-blocks`, `list-note-paragraphs`, `get-paragraph-link`, `get-note-structure`, `list-note-links`, `export-notes-markdown`, `export-notes-html`, `get-audio-transcripts`, `list-special-notes`, `list-native-tags`, `list-recent-notes`, `list-folder-tree`, `get-note-link`, the checklist annotations in `get-note-markdown`, `list-attachments` with `includePaths` or `firstImage`, `export-attachments`, `list-paper-attachments`, `export-paper-image`, and the database half of `get-sync-status`.
2295
2414
 
2296
2415
  > 📘 **For the full why-and-how walkthrough (which app to grant, verifying with `doctor`, graceful degradation), see the [Full Disk Access Setup Guide](https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/FULL-DISK-ACCESS.md).** The summary below is the quick version.
2297
2416
 
package/build/index.js CHANGED
@@ -49052,6 +49052,249 @@ function createShutdown(stream, exit, timeoutMs = SHUTDOWN_DRAIN_TIMEOUT_MS) {
49052
49052
  };
49053
49053
  }
49054
49054
 
49055
+ // src/utils/noteParagraphs.ts
49056
+ import { gunzipSync as gunzipSync9 } from "node:zlib";
49057
+ var ENVELOPE_CODE = {
49058
+ "invalid-argument": "validation_error",
49059
+ "not-found": "not_found",
49060
+ encrypted: "unsupported",
49061
+ "no-body": "unsupported",
49062
+ "ambiguous-note": "ambiguous",
49063
+ "no-match": "not_found",
49064
+ "ambiguous-paragraph": "ambiguous",
49065
+ "occurrence-out-of-range": "validation_error",
49066
+ "paragraph-id-missing": "unsupported",
49067
+ "paragraph-id-shared": "unsupported"
49068
+ };
49069
+ var ParagraphLinkError = class extends CodedError {
49070
+ reason;
49071
+ constructor(reason, message) {
49072
+ super(message, { code: ENVELOPE_CODE[reason], reason });
49073
+ this.name = "ParagraphLinkError";
49074
+ this.reason = reason;
49075
+ }
49076
+ };
49077
+ var paragraphUrl = (noteIdentifier, paragraphId) => `applenotes://showNote?identifier=${noteIdentifier.toUpperCase()}&paragraphID=${paragraphId.toUpperCase()}`;
49078
+ var normalizeParagraphText = (text2) => text2.normalize("NFKC").replace(/\ufffc/g, "").split(/\s+/u).filter(Boolean).join(" ").toLowerCase();
49079
+ var bytesField = (fields, n) => fields.find((f) => f.fieldNumber === n && f.wireType === 2)?.bytes;
49080
+ var uuidOf = (bytes) => {
49081
+ if (bytes?.length !== 16) return void 0;
49082
+ const h = Buffer.from(bytes).toString("hex").toUpperCase();
49083
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
49084
+ };
49085
+ function runParagraphIds(data) {
49086
+ const document = decodeWireFields(bytesField(decodeWireFields(data), 2));
49087
+ const note = decodeWireFields(bytesField(document, 3));
49088
+ const runs = [];
49089
+ let start = 0;
49090
+ for (const field of note.filter((f) => f.fieldNumber === 5)) {
49091
+ const run = decodeWireFields(field.bytes);
49092
+ const length = Number(run.find((f) => f.fieldNumber === 1).varint);
49093
+ const style = bytesField(run, 2);
49094
+ const paragraphId = style ? uuidOf(bytesField(decodeWireFields(style), 9)) : void 0;
49095
+ runs.push({ start, length, ...paragraphId ? { paragraphId } : {} });
49096
+ start += length;
49097
+ }
49098
+ return runs;
49099
+ }
49100
+ function classifyParagraphIds(doc, runs) {
49101
+ const ranges = doc.blocks.map((block) => [block.start, block.start + block.length + 1]);
49102
+ const blocksOfId = /* @__PURE__ */ new Map();
49103
+ const idsOfBlock = doc.blocks.map(() => /* @__PURE__ */ new Set());
49104
+ let b = 0;
49105
+ for (const run of runs) {
49106
+ const end = run.start + run.length;
49107
+ while (b < ranges.length && ranges[b][1] <= run.start) b++;
49108
+ for (let i = b; i < ranges.length && ranges[i][0] < end; i++) {
49109
+ idsOfBlock[i].add(run.paragraphId);
49110
+ if (run.paragraphId) {
49111
+ const owners = blocksOfId.get(run.paragraphId) ?? /* @__PURE__ */ new Set();
49112
+ owners.add(i);
49113
+ blocksOfId.set(run.paragraphId, owners);
49114
+ }
49115
+ }
49116
+ }
49117
+ return doc.blocks.map((block, i) => {
49118
+ const paragraphId = block.paragraphUuid ?? null;
49119
+ const mixed = idsOfBlock[i].size > 1;
49120
+ if (!paragraphId) return { status: "missing", paragraphId, mixed };
49121
+ const owners = blocksOfId.get(paragraphId).size;
49122
+ return owners === 1 ? { status: "unique", paragraphId, mixed } : { status: "shared", paragraphId, sharedWith: owners - 1, mixed };
49123
+ });
49124
+ }
49125
+ function paragraphsOf(doc, runs, noteIdentifier) {
49126
+ const ids = classifyParagraphIds(doc, runs);
49127
+ const out = [];
49128
+ doc.blocks.forEach((block, i) => {
49129
+ if (!normalizeParagraphText(block.text)) return;
49130
+ const { status, paragraphId, sharedWith, mixed } = ids[i];
49131
+ out.push({
49132
+ blockIndex: block.index,
49133
+ text: block.text,
49134
+ style: block.style,
49135
+ styleType: block.styleType,
49136
+ paragraphId,
49137
+ paragraphIdStatus: status,
49138
+ ...sharedWith !== void 0 ? { sharedWith } : {},
49139
+ ...mixed ? { mixedParagraphIds: true } : {},
49140
+ ...status === "unique" && noteIdentifier ? { url: paragraphUrl(noteIdentifier, paragraphId) } : {}
49141
+ });
49142
+ });
49143
+ return out;
49144
+ }
49145
+ function selectParagraph(paragraphs, selector) {
49146
+ const given = [selector.contains, selector.match, selector.blockIndex].filter(
49147
+ (value) => value !== void 0
49148
+ ).length;
49149
+ if (given !== 1)
49150
+ throw new ParagraphLinkError(
49151
+ "invalid-argument",
49152
+ "Choose exactly one paragraph selector: contains, match, or blockIndex"
49153
+ );
49154
+ if (selector.blockIndex !== void 0) {
49155
+ const hit = paragraphs.find((p) => p.blockIndex === selector.blockIndex);
49156
+ if (!hit)
49157
+ throw new ParagraphLinkError(
49158
+ "no-match",
49159
+ `Block ${selector.blockIndex} is not a non-empty paragraph of this note`
49160
+ );
49161
+ return hit;
49162
+ }
49163
+ const wanted = normalizeParagraphText(selector.contains ?? selector.match);
49164
+ if (!wanted)
49165
+ throw new ParagraphLinkError("invalid-argument", "The paragraph selector has no visible text");
49166
+ const matches = paragraphs.filter(
49167
+ (p) => selector.match !== void 0 ? normalizeParagraphText(p.text) === wanted : normalizeParagraphText(p.text).includes(wanted)
49168
+ );
49169
+ if (!matches.length)
49170
+ throw new ParagraphLinkError("no-match", "No paragraph matches the selector");
49171
+ if (selector.occurrence === void 0 && matches.length > 1)
49172
+ throw new ParagraphLinkError(
49173
+ "ambiguous-paragraph",
49174
+ `The selector matches ${matches.length} paragraphs; use a longer snippet or pass occurrence (1-${matches.length})`
49175
+ );
49176
+ const occurrence = selector.occurrence ?? 1;
49177
+ if (occurrence > matches.length)
49178
+ throw new ParagraphLinkError(
49179
+ "occurrence-out-of-range",
49180
+ `Occurrence ${occurrence} requested but only ${matches.length} paragraphs match`
49181
+ );
49182
+ return matches[occurrence - 1];
49183
+ }
49184
+ var NOTE_ID = /^x-coredata:\/\/[0-9a-f-]+\/ICNote\/p([0-9]{1,15})$/i;
49185
+ function titleMatchSql(columns) {
49186
+ return `SELECT json_object('pk', n.Z_PK, 'folder', n.ZFOLDER) FROM ZICCLOUDSYNCINGOBJECT n LEFT JOIN ZICCLOUDSYNCINGOBJECT f ON f.Z_PK = n.ZFOLDER WHERE n.Z_ENT = ${entity("ICNote")} AND n.ZTITLE1 = CAST(@title AS TEXT) AND ${activeNoteSql(columns, "n", "f")} ORDER BY n.Z_PK;`;
49187
+ }
49188
+ function checkNoteSelector(selector) {
49189
+ const given = [selector.id, selector.title].filter((value) => value !== void 0).length;
49190
+ if (given !== 1)
49191
+ throw new ParagraphLinkError("invalid-argument", "Choose exactly one of id or title");
49192
+ if (selector.folder !== void 0 && selector.title === void 0)
49193
+ throw new ParagraphLinkError("invalid-argument", "folder only narrows a title lookup");
49194
+ if (selector.id === void 0) return void 0;
49195
+ const pk = NOTE_ID.exec(selector.id)?.[1];
49196
+ if (!pk)
49197
+ throw new ParagraphLinkError(
49198
+ "invalid-argument",
49199
+ "Invalid note ID: expected x-coredata://<store>/ICNote/p<number> or a Notes UUID"
49200
+ );
49201
+ return Number(pk);
49202
+ }
49203
+ function resolveNote(dbPath2, columns, selector) {
49204
+ const idPk = checkNoteSelector(selector);
49205
+ if (idPk !== void 0) return { pk: idPk, id: selector.id };
49206
+ requireColumns(columns, ["ZTITLE1", "ZFOLDER"], "title lookup");
49207
+ const context = readStoreContext(dbPath2, columns);
49208
+ const paths = folderPaths(context.folders);
49209
+ const names = new Map(context.folders.map((f) => [f.pk, escapeFolderName(f.name ?? "")]));
49210
+ let matches = parseJsonLines(
49211
+ runReadOnlySql(dbPath2, titleMatchSql(columns), {
49212
+ title: { blob: Buffer.from(selector.title, "utf8") }
49213
+ })
49214
+ );
49215
+ if (selector.folder !== void 0) {
49216
+ const wanted = selector.folder;
49217
+ matches = matches.filter(
49218
+ (n) => paths.get(n.folder) === wanted || names.get(n.folder) === wanted
49219
+ );
49220
+ }
49221
+ if (!matches.length) throw new ParagraphLinkError("not-found", "No note matches the selector");
49222
+ if (matches.length > 1)
49223
+ throw new ParagraphLinkError(
49224
+ "ambiguous-note",
49225
+ `${matches.length} notes match; pass folder (a name or a path as list-folders shows it) or use the note id. Folders: ${matches.map((n) => paths.get(n.folder)).join(", ")}`
49226
+ );
49227
+ return { pk: matches[0].pk, id: noteIdFor(context.uuid, matches[0].pk) };
49228
+ }
49229
+ function noteBodySql(columns) {
49230
+ return `SELECT json_object('isNote', n.Z_ENT = ${entity("ICNote")}, 'identifier', ${col(columns, "n", "ZIDENTIFIER")}, 'data', (SELECT hex(d.ZDATA) FROM ZICNOTEDATA d WHERE d.ZNOTE = n.Z_PK), 'encrypted', (SELECT d.ZCRYPTOINITIALIZATIONVECTOR IS NOT NULL FROM ZICNOTEDATA d WHERE d.ZNOTE = n.Z_PK), 'locked', ${col(columns, "n", "ZISPASSWORDPROTECTED")}) FROM ZICCLOUDSYNCINGOBJECT n WHERE n.Z_PK = @pk;`;
49231
+ }
49232
+ function readNoteParagraphs(selector, { dbPath: dbPath2 = NOTES_DB_PATH7 } = {}) {
49233
+ checkNoteSelector(selector);
49234
+ const columns = readColumns(dbPath2);
49235
+ const { pk, id: id2 } = resolveNote(dbPath2, columns, selector);
49236
+ const [row] = parseJsonLines(runReadOnlySql(dbPath2, noteBodySql(columns), { pk: { int: pk } }));
49237
+ if (!row?.isNote) throw new ParagraphLinkError("not-found", `No note found for ID "${id2}"`);
49238
+ if (row.encrypted || row.locked)
49239
+ throw new ParagraphLinkError(
49240
+ "encrypted",
49241
+ "This note is password-protected; its body is encrypted"
49242
+ );
49243
+ if (!row.data || !/^[0-9a-f]+$/i.test(row.data))
49244
+ throw new ParagraphLinkError("no-body", "No body data is stored for this note");
49245
+ let doc;
49246
+ let runs;
49247
+ try {
49248
+ const data = gunzipSync9(Buffer.from(row.data, "hex"), { maxOutputLength: 32 * 1024 * 1024 });
49249
+ doc = decodeNoteBlocks(data);
49250
+ runs = runParagraphIds(data);
49251
+ } catch (error2) {
49252
+ const message = error2 instanceof Error ? error2.message : String(error2);
49253
+ throw new ParagraphLinkError("no-body", `The note body could not be decoded: ${message}`);
49254
+ }
49255
+ const paragraphs = paragraphsOf(doc, runs, row.identifier);
49256
+ const counts = { unique: 0, shared: 0, missing: 0 };
49257
+ for (const p of paragraphs) counts[p.paragraphIdStatus]++;
49258
+ return { id: id2, identifier: row.identifier, paragraphs, counts };
49259
+ }
49260
+ function pageParagraphs(paragraphs, { offset = 0, limit = 500, maxBytes = 4 * 1024 * 1024, linkableOnly = false } = {}) {
49261
+ const list = linkableOnly ? paragraphs.filter((p) => p.url) : paragraphs;
49262
+ const start = Math.min(Math.max(0, offset), list.length);
49263
+ const out = [];
49264
+ let bytes = 0;
49265
+ for (let i = start; i < list.length && out.length < limit; i++) {
49266
+ const size = Buffer.byteLength(JSON.stringify(list[i]));
49267
+ if (out.length && bytes + size > maxBytes) break;
49268
+ out.push(list[i]);
49269
+ bytes += size;
49270
+ }
49271
+ const next = start + out.length;
49272
+ return {
49273
+ paragraphs: out,
49274
+ page: {
49275
+ offset: start,
49276
+ returned: out.length,
49277
+ total: list.length,
49278
+ hasMore: next < list.length,
49279
+ ...next < list.length ? { nextOffset: next } : {}
49280
+ }
49281
+ };
49282
+ }
49283
+ function paragraphLink(note, selector) {
49284
+ const paragraph = selectParagraph(note.paragraphs, selector);
49285
+ if (paragraph.paragraphIdStatus === "shared")
49286
+ throw new ParagraphLinkError(
49287
+ "paragraph-id-shared",
49288
+ `This paragraph's ID is shared with ${paragraph.sharedWith} other paragraph(s) in the note, so a link could open the wrong one`
49289
+ );
49290
+ if (!paragraph.url)
49291
+ throw new ParagraphLinkError(
49292
+ "paragraph-id-missing",
49293
+ paragraph.paragraphId ? "The note has no stored identifier, so no link can be built" : "This paragraph has no stored paragraph ID, so Notes cannot open it directly"
49294
+ );
49295
+ return { url: paragraph.url, paragraph };
49296
+ }
49297
+
49055
49298
  // src/utils/noteStructure.ts
49056
49299
  import { dirname as dirname4 } from "node:path";
49057
49300
 
@@ -49339,6 +49582,212 @@ function readNoteStructure(id2, { dbPath: dbPath2 = NOTES_DB_PATH7, includeText
49339
49582
  };
49340
49583
  }
49341
49584
 
49585
+ // src/utils/noteLinkInventory.ts
49586
+ import { dirname as dirname5 } from "node:path";
49587
+ var BODY_BATCH = 100;
49588
+ function matchFolder(folders, wanted, account) {
49589
+ const paths = folderPaths(folders);
49590
+ const segments = splitFolderPath(wanted);
49591
+ const key = (parts) => parts.join("\0");
49592
+ const pool = folders.filter(
49593
+ (f) => !f.tombstoned && !f.trash && (account === void 0 || f.account === account)
49594
+ );
49595
+ const byPath = segments.length === 1 ? pool.filter((f) => f.name === segments[0]) : pool.filter((f) => key(splitFolderPath(paths.get(f.pk))) === key(segments));
49596
+ const matches = byPath.length ? byPath : pool.filter((f) => f.name === wanted);
49597
+ if (!matches.length) throw new NoteStoreError(`No folder matches "${wanted}".`, "invalid_input");
49598
+ if (matches.length > 1)
49599
+ throw new NoteStoreError(
49600
+ `Folder "${wanted}" is ambiguous; use one of these paths: ${matches.map((f) => paths.get(f.pk)).join(", ")}.`,
49601
+ "invalid_input"
49602
+ );
49603
+ return { folder: matches[0], path: paths.get(matches[0].pk) };
49604
+ }
49605
+ function inventorySql(columns) {
49606
+ requireColumns(columns, ["ZFOLDER"], "list-note-links");
49607
+ const c = (alias, name) => col(columns, alias, name);
49608
+ const subtree = columns.has("ZPARENT") ? `WITH RECURSIVE sub(pk) AS (SELECT @folder UNION
49609
+ SELECT x.Z_PK FROM ZICCLOUDSYNCINGOBJECT x JOIN sub ON x.ZPARENT = sub.pk
49610
+ WHERE @subfolders <> 0 AND x.Z_ENT = ${entity("ICFolder")})
49611
+ SELECT pk FROM sub` : "SELECT @folder";
49612
+ const scopeWhere = `n.Z_ENT = ${entity("ICNote")} AND (
49613
+ (@note <> 0 AND n.Z_PK = @note) OR
49614
+ (@note = 0 AND ${activeNoteSql(columns, "n", "f")}
49615
+ AND (@account = 0 OR a.Z_PK = @account)
49616
+ AND (@folder = 0 OR n.ZFOLDER IN (${subtree}))))`;
49617
+ const scope2 = `SELECT n.Z_PK FROM ZICCLOUDSYNCINGOBJECT n ${folderAccountJoins(columns)} WHERE ${scopeWhere}`;
49618
+ return {
49619
+ rows: [
49620
+ "BEGIN;",
49621
+ `SELECT json_object('k', 'note', 'pk', n.Z_PK, 'identifier', ${c("n", "ZIDENTIFIER")},
49622
+ 'title', ${c("n", "ZTITLE1")}, 'folder', n.ZFOLDER, 'account', a.Z_PK,
49623
+ 'modified', ${c("n", "ZMODIFICATIONDATE1")})
49624
+ FROM ZICCLOUDSYNCINGOBJECT n ${folderAccountJoins(columns)} WHERE ${scopeWhere};`,
49625
+ `SELECT json_object('k', 'card', 'pk', att.Z_PK, 'note', ${c("att", "ZNOTE")},
49626
+ 'identifier', ${c("att", "ZIDENTIFIER")}, 'url', ${c("att", "ZURLSTRING")},
49627
+ 'title', ${c("att", "ZTITLE")})
49628
+ FROM ZICCLOUDSYNCINGOBJECT att
49629
+ WHERE att.Z_ENT = ${entity("ICAttachment")} AND ${notTombstonedSql(columns, "att")}
49630
+ AND ${c("att", "ZTYPEUTI")} LIKE 'public.url%' AND ${c("att", "ZURLSTRING")} IS NOT NULL
49631
+ AND ${c("att", "ZNOTE")} IN (${scope2});`,
49632
+ `SELECT json_object('k', 'chip', 'note', ${c("i", "ZNOTE1")},
49633
+ 'identifier', ${c("i", "ZIDENTIFIER")}, 'token', ${c("i", "ZTOKENCONTENTIDENTIFIER")},
49634
+ 'alt', ${c("i", "ZALTTEXT")})
49635
+ FROM ZICCLOUDSYNCINGOBJECT i
49636
+ WHERE i.Z_ENT = ${entity("ICInlineAttachment")} AND ${notTombstonedSql(columns, "i")}
49637
+ AND ${c("i", "ZTYPEUTI1")} = '${NOTE_LINK_UTI}' AND ${c("i", "ZNOTE1")} IN (${scope2});`,
49638
+ "COMMIT;"
49639
+ ].join("\n"),
49640
+ bodies: `SELECT json_object('note', d.ZNOTE, 'data', hex(d.ZDATA),
49641
+ 'encrypted', d.ZCRYPTOINITIALIZATIONVECTOR IS NOT NULL)
49642
+ FROM ZICNOTEDATA d WHERE d.ZNOTE IN (${scope2}) AND d.ZNOTE > @after
49643
+ ORDER BY d.ZNOTE LIMIT ${BODY_BATCH};`
49644
+ };
49645
+ }
49646
+ function decodeBodies(dbPath2, sql, params) {
49647
+ const docs = /* @__PURE__ */ new Map();
49648
+ let after = 0;
49649
+ for (; ; ) {
49650
+ const rows = parseJsonLines(
49651
+ runReadOnlySql(dbPath2, sql, { ...params, after: { int: after } })
49652
+ );
49653
+ for (const row of rows) {
49654
+ after = Math.max(after, row.note);
49655
+ if (row.encrypted || !row.data) continue;
49656
+ try {
49657
+ docs.set(row.note, decodeCompressedNoteBlocks(Buffer.from(row.data, "hex")));
49658
+ } catch {
49659
+ }
49660
+ }
49661
+ if (rows.length < BODY_BATCH) return docs;
49662
+ }
49663
+ }
49664
+ var KIND_ORDER2 = { inline: 0, card: 1, note: 2, section: 3 };
49665
+ function listNoteLinks(options = {}) {
49666
+ const { dbPath: dbPath2 = NOTES_DB_PATH7, offset = 0, limit = 200, maxBytes = 4 * 1024 * 1024 } = options;
49667
+ if (options.id && (options.account || options.folder))
49668
+ throw new NoteStoreError("Pass either id or account/folder, not both.", "invalid_input");
49669
+ const notePk = options.id ? parseNoteId2(options.id).pk : 0;
49670
+ const columns = readColumns(dbPath2);
49671
+ const sql = inventorySql(columns);
49672
+ const context = readStoreContext(dbPath2, columns);
49673
+ const paths = folderPaths(context.folders);
49674
+ const scope2 = {};
49675
+ let accountPk = 0;
49676
+ let folderPk = 0;
49677
+ const includeSubfolders = options.includeSubfolders ?? true;
49678
+ if (options.account) {
49679
+ const account = resolveAccountName(context.accounts, options.account);
49680
+ accountPk = account.pk;
49681
+ scope2.account = account.name;
49682
+ scope2.accountIdentifier = account.identifier;
49683
+ }
49684
+ if (options.folder) {
49685
+ const { folder, path: path10 } = matchFolder(context.folders, options.folder, accountPk || void 0);
49686
+ folderPk = folder.pk;
49687
+ scope2.folder = folder.name;
49688
+ scope2.folderPath = path10;
49689
+ scope2.includeSubfolders = includeSubfolders;
49690
+ }
49691
+ if (options.id) scope2.note = options.id;
49692
+ const params = {
49693
+ note: { int: notePk },
49694
+ account: { int: accountPk },
49695
+ folder: { int: folderPk },
49696
+ subfolders: { int: includeSubfolders ? 1 : 0 }
49697
+ };
49698
+ const rows = parseJsonLines(runReadOnlySql(dbPath2, sql.rows, params));
49699
+ const notes = rows.filter((row) => row.k === "note");
49700
+ if (options.id && !notes.length)
49701
+ throw new NoteStoreError(`No note found for ID "${options.id}".`, "invalid_input");
49702
+ const includeInline = options.includeInline ?? Boolean(options.id);
49703
+ const docs = includeInline ? decodeBodies(dbPath2, sql.bodies, params) : /* @__PURE__ */ new Map();
49704
+ const noteById = new Map(notes.map((note) => [note.pk, note]));
49705
+ const accountById = new Map(context.accounts.map((account) => [account.pk, account]));
49706
+ const folderById = new Map(context.folders.map((folder) => [folder.pk, folder]));
49707
+ const source = (pk) => {
49708
+ const note = noteById.get(pk);
49709
+ const account = accountById.get(note.account ?? -1);
49710
+ const folder = folderById.get(note.folder ?? -1);
49711
+ return {
49712
+ noteId: noteIdFor(context.uuid, pk),
49713
+ noteIdentifier: note.identifier,
49714
+ noteTitle: note.title,
49715
+ noteModified: coreDataToIso(note.modified),
49716
+ folder: folder?.name ?? null,
49717
+ folderPath: folder ? paths.get(folder.pk) : null,
49718
+ account: account?.name ?? null,
49719
+ accountIdentifier: account?.identifier ?? null
49720
+ };
49721
+ };
49722
+ const containerDir = dirname5(dbPath2);
49723
+ const previewEntries = /* @__PURE__ */ new Map();
49724
+ const previewFor = (identifier, account) => {
49725
+ const accountDir = identifier ? resolveAccountDir(containerDir, account) : null;
49726
+ if (!accountDir || !identifier) return null;
49727
+ if (!previewEntries.has(accountDir))
49728
+ previewEntries.set(accountDir, listPreviewEntries(accountDir));
49729
+ return previewPaths(accountDir, identifier, previewEntries.get(accountDir))[0] ?? null;
49730
+ };
49731
+ const all = [];
49732
+ const add = (pk, link) => all.push({ pk, link: { ...link, ...source(pk) } });
49733
+ for (const [pk, doc] of docs) for (const link of inlineLinks(doc)) add(pk, link);
49734
+ for (const row of rows) {
49735
+ if (row.k === "card") {
49736
+ const noteId3 = noteIdFor(context.uuid, row.note);
49737
+ const link = cardLink(
49738
+ { pk: row.pk, identifier: row.identifier ?? "", url: row.url, title: row.title },
49739
+ {
49740
+ doc: docs.get(row.note),
49741
+ noteId: noteId3,
49742
+ previewPath: previewFor(row.identifier, source(row.note).accountIdentifier)
49743
+ }
49744
+ );
49745
+ if (link) add(row.note, link);
49746
+ } else if (row.k === "chip") {
49747
+ const link = nativeLink(
49748
+ { identifier: row.identifier ?? "", token: row.token, alt: row.alt },
49749
+ docs.get(row.note)
49750
+ );
49751
+ if (link) add(row.note, link);
49752
+ }
49753
+ }
49754
+ const wanted = options.kinds?.length ? new Set(options.kinds) : void 0;
49755
+ const links = all.filter(({ link }) => !wanted || wanted.has(link.kind)).sort(
49756
+ (a, b) => (b.link.noteModified ?? "").localeCompare(a.link.noteModified ?? "") || a.pk - b.pk || (a.link.start ?? Number.MAX_SAFE_INTEGER) - (b.link.start ?? Number.MAX_SAFE_INTEGER) || KIND_ORDER2[a.link.kind] - KIND_ORDER2[b.link.kind]
49757
+ ).map(({ link }) => link);
49758
+ const counts = { inline: 0, card: 0, note: 0, section: 0 };
49759
+ for (const link of links) counts[link.kind]++;
49760
+ const start = Math.min(Math.max(0, offset), links.length);
49761
+ const page = [];
49762
+ let bytes = 0;
49763
+ for (let i = start; i < links.length && page.length < limit; i++) {
49764
+ const size = Buffer.byteLength(JSON.stringify(links[i]));
49765
+ if (page.length && bytes + size > maxBytes) break;
49766
+ page.push(links[i]);
49767
+ bytes += size;
49768
+ }
49769
+ const next = start + page.length;
49770
+ return {
49771
+ scope: scope2,
49772
+ inlineIncluded: includeInline,
49773
+ notesInScope: notes.length,
49774
+ notesWithoutBody: includeInline ? notes.length - docs.size : 0,
49775
+ counts,
49776
+ links: page,
49777
+ page: {
49778
+ offset: start,
49779
+ returned: page.length,
49780
+ total: links.length,
49781
+ hasMore: next < links.length,
49782
+ ...next < links.length ? { nextOffset: next } : {}
49783
+ }
49784
+ };
49785
+ }
49786
+ function describeLinkInventory(result) {
49787
+ const { counts, page } = result;
49788
+ return `Found ${page.total} links in ${result.notesInScope} notes (inline ${counts.inline}${result.inlineIncluded ? "" : " not scanned"}, card ${counts.card}, note ${counts.note}, section ${counts.section}); returned ${page.returned} from offset ${page.offset}` + (page.hasMore ? `; more at offset ${page.nextOffset}` : "") + ".";
49789
+ }
49790
+
49342
49791
  // src/utils/linkInsert.ts
49343
49792
  var MAX_LINK_URL_LENGTH = 4096;
49344
49793
  var MAX_LINK_LABEL_LENGTH = 2e3;
@@ -49456,7 +49905,7 @@ function insertLink(request, deps) {
49456
49905
 
49457
49906
  // src/services/notesExport.ts
49458
49907
  import { mkdirSync as mkdirSync5 } from "node:fs";
49459
- import { basename as basename4, dirname as dirname5, extname as extname4, join as join21 } from "node:path";
49908
+ import { basename as basename4, dirname as dirname6, extname as extname4, join as join21 } from "node:path";
49460
49909
 
49461
49910
  // src/utils/exportAssets.ts
49462
49911
  import {
@@ -50779,7 +51228,7 @@ function loadNotes(ids, single, read) {
50779
51228
  return { notes, skipped };
50780
51229
  }
50781
51230
  function openOutput(output) {
50782
- mkdirSync5(dirname5(output), { recursive: true });
51231
+ mkdirSync5(dirname6(output), { recursive: true });
50783
51232
  try {
50784
51233
  return openCreateOnly(output);
50785
51234
  } catch (error2) {
@@ -50804,7 +51253,7 @@ function exportNotesMarkdown(request, deps) {
50804
51253
  const ids = selectNotes(request, deps);
50805
51254
  const { notes, skipped } = loadNotes(ids, !!request.id, deps.readNote);
50806
51255
  const fd = output ? openOutput(output) : void 0;
50807
- const writer = assetsDir ? new SidecarWriter(assetsDir, output ? dirname5(output) : void 0) : void 0;
51256
+ const writer = assetsDir ? new SidecarWriter(assetsDir, output ? dirname6(output) : void 0) : void 0;
50808
51257
  const ctx = {
50809
51258
  stats: emptyStats(),
50810
51259
  ...writer ? { writer, locator: deps.locator ?? new AssetLocator() } : {}
@@ -50834,7 +51283,7 @@ function exportNotesMarkdown(request, deps) {
50834
51283
  return { ...receipt, markdown };
50835
51284
  }
50836
51285
  function defaultSidecarDir(output) {
50837
- return join21(dirname5(output), `${basename4(output, extname4(output))}.assets`);
51286
+ return join21(dirname6(output), `${basename4(output, extname4(output))}.assets`);
50838
51287
  }
50839
51288
  function exportNotesHtml(request, deps) {
50840
51289
  if (!request.outputPath)
@@ -50855,7 +51304,7 @@ function exportNotesHtml(request, deps) {
50855
51304
  const ids = selectNotes(request, deps);
50856
51305
  const { notes, skipped } = loadNotes(ids, !!request.id, deps.readNote);
50857
51306
  const fd = openOutput(output);
50858
- const writer = assetsDir ? new SidecarWriter(assetsDir, dirname5(output)) : new DataUrlWriter();
51307
+ const writer = assetsDir ? new SidecarWriter(assetsDir, dirname6(output)) : new DataUrlWriter();
50859
51308
  const ctx = {
50860
51309
  stats: emptyStats(),
50861
51310
  writer,
@@ -51961,7 +52410,7 @@ function registerNativeOperations(server2, manager) {
51961
52410
  import { spawnSync as spawnSync2 } from "node:child_process";
51962
52411
  import { existsSync as existsSync13 } from "node:fs";
51963
52412
  import { release as release2 } from "node:os";
51964
- import { dirname as dirname6, resolve as resolve4 } from "node:path";
52413
+ import { dirname as dirname7, resolve as resolve4 } from "node:path";
51965
52414
  import { fileURLToPath } from "node:url";
51966
52415
  var OPTIONAL_BRIDGE_NOTE = "(optional \u2014 needed only for create-note format: markdown, macOS 26+)";
51967
52416
  var MARKDOWN_MIN_DARWIN_MAJOR = 25;
@@ -51984,7 +52433,7 @@ function setupShortcuts(checkOnly, dependencies = {}) {
51984
52433
  const result = spawnSync2("/usr/bin/open", [path10], { encoding: "utf8" });
51985
52434
  return result.status === 0 ? { ok: true } : { ok: false, error: result.stderr || result.error?.message || "open failed" };
51986
52435
  });
51987
- const baseDirectory = dependencies.baseDirectory || resolve4(dirname6(fileURLToPath(import.meta.url)), "../shortcuts");
52436
+ const baseDirectory = dependencies.baseDirectory || resolve4(dirname7(fileURLToPath(import.meta.url)), "../shortcuts");
51988
52437
  const osRelease = (dependencies.osRelease || release2)();
51989
52438
  const darwinMajor = Number.parseInt(osRelease.split(".")[0], 10);
51990
52439
  const items = shortcutFiles.map(({ name, file, optional: optional2 }) => {
@@ -52065,7 +52514,7 @@ import { spawnSync as spawnSync3 } from "node:child_process";
52065
52514
  import { createHash as createHash4 } from "node:crypto";
52066
52515
  import { existsSync as existsSync14, readFileSync as readFileSync4 } from "node:fs";
52067
52516
  import { homedir as homedir19 } from "node:os";
52068
- import { dirname as dirname7, join as join24, resolve as resolve5 } from "node:path";
52517
+ import { dirname as dirname8, join as join24, resolve as resolve5 } from "node:path";
52069
52518
  import { fileURLToPath as fileURLToPath2 } from "node:url";
52070
52519
  var PRIVATE_HELPER_PROTOCOL = 1;
52071
52520
  var ENABLE_ENV = "APPLE_NOTES_MCP_ENABLE_PRIVATE";
@@ -52090,7 +52539,7 @@ var manifestSchema = external_exports.object({
52090
52539
  osVersion: external_exports.string(),
52091
52540
  compiler: external_exports.string()
52092
52541
  });
52093
- function packageRoot(fromDir = dirname7(fileURLToPath2(import.meta.url))) {
52542
+ function packageRoot(fromDir = dirname8(fileURLToPath2(import.meta.url))) {
52094
52543
  let dir = fromDir;
52095
52544
  for (; ; ) {
52096
52545
  const candidate = join24(dir, "package.json");
@@ -52101,7 +52550,7 @@ function packageRoot(fromDir = dirname7(fileURLToPath2(import.meta.url))) {
52101
52550
  } catch {
52102
52551
  }
52103
52552
  }
52104
- const parent = dirname7(dir);
52553
+ const parent = dirname8(dir);
52105
52554
  if (parent === dir) return resolve5(fromDir, "..");
52106
52555
  dir = parent;
52107
52556
  }
@@ -53722,6 +54171,77 @@ registerTool(
53722
54171
  );
53723
54172
  }, "Error reading note blocks")
53724
54173
  );
54174
+ var paragraphNoteSelector = {
54175
+ id: noteIdInput.optional().describe(`Exact note ID (${NOTE_ID_FORMS}); give id or title`),
54176
+ title: external_exports.string().min(1).max(MAX.TITLE).optional().describe("Exact note title; must match one note unless folder narrows it"),
54177
+ folder: external_exports.string().min(1).max(MAX.FOLDER).optional().describe("With title only: the note's folder name or full path as list-folders shows it")
54178
+ };
54179
+ registerTool(
54180
+ "list-note-paragraphs",
54181
+ {
54182
+ description: "Use when: you need a note's paragraphs with their style and stored paragraph ID, for example to choose one to link to.\nReturns: one page of non-empty paragraphs in body order, each with blockIndex (as in get-note-blocks), text, style, paragraphId, paragraphIdStatus (unique, shared, missing) and, only when unique, a direct applenotes:// url that opens that paragraph; plus counts per status and page info (call again with offset set to page.nextOffset while page.hasMore is true).\nDo not use when: you need inline formatting (get-note-blocks).\nSafety: read-only; reads the NoteStore database directly and requires Full Disk Access. Paragraph IDs repeat often (Notes copies them when a paragraph is split), so shared IDs get no url. Title lookups ignore Recently Deleted. Password-protected notes are refused.",
54183
+ inputSchema: {
54184
+ ...paragraphNoteSelector,
54185
+ linkableOnly: external_exports.boolean().optional().describe("Return only paragraphs that have a direct url (default false)"),
54186
+ offset: external_exports.number().int().min(0).optional().describe("Index of the first paragraph to return (default 0); use page.nextOffset"),
54187
+ limit: external_exports.number().int().min(1).max(5e3).optional().describe("Maximum paragraphs to return (default 500, max 5000)")
54188
+ },
54189
+ outputSchema: {
54190
+ id: external_exports.string().optional(),
54191
+ identifier: external_exports.string().nullable().optional(),
54192
+ counts: external_exports.record(external_exports.unknown()).optional(),
54193
+ paragraphs: external_exports.array(external_exports.record(external_exports.unknown())).optional(),
54194
+ page: external_exports.record(external_exports.unknown()).optional()
54195
+ },
54196
+ annotations: { readOnlyHint: true }
54197
+ },
54198
+ withErrorHandling(({ id: id2, title, folder, linkableOnly, offset, limit }) => {
54199
+ const note = readNoteParagraphs({ id: id2, title, folder });
54200
+ const page = pageParagraphs(note.paragraphs, {
54201
+ offset,
54202
+ limit,
54203
+ linkableOnly,
54204
+ maxBytes: blocksMaxResponseBytes()
54205
+ });
54206
+ const { unique, shared, missing } = note.counts;
54207
+ return successResponse(
54208
+ `${note.paragraphs.length} paragraphs: ${unique} linkable, ${shared} with a shared ID, ${missing} without an ID; returned ${page.page.returned} from offset ${page.page.offset}` + (page.page.hasMore ? `; more at offset ${page.page.nextOffset}` : "") + ".",
54209
+ { id: note.id, identifier: note.identifier, counts: note.counts, ...page }
54210
+ );
54211
+ }, "Error listing paragraphs")
54212
+ );
54213
+ registerTool(
54214
+ "get-paragraph-link",
54215
+ {
54216
+ description: "Use when: you need a link that opens Notes at one paragraph (for example a heading) of a note.\nReturns: a direct applenotes://showNote?identifier=<note>&paragraphID=<paragraph> url and the selected paragraph, only when that paragraph's stored ID is present and appears in no other paragraph of the note. Otherwise an error whose structuredContent.reason says why: paragraph-id-shared, paragraph-id-missing, no-match, ambiguous-paragraph (pass occurrence or a longer snippet), occurrence-out-of-range, ambiguous-note, encrypted.\nDo not use when: you want a link to the whole note (get-note-link).\nSafety: read-only; never creates or changes a paragraph ID, so a paragraph without a unique ID cannot be linked. Requires Full Disk Access. A later edit in Notes can replace the ID and break the link.",
54217
+ inputSchema: {
54218
+ ...paragraphNoteSelector,
54219
+ contains: external_exports.string().min(1).max(MAX.CONTENT).optional().describe(
54220
+ "Snippet of the paragraph (case, spacing and Unicode width are ignored); give one of contains, match, blockIndex"
54221
+ ),
54222
+ match: external_exports.string().min(1).max(MAX.CONTENT).optional().describe("The whole paragraph text, compared the same way"),
54223
+ blockIndex: external_exports.number().int().min(0).optional().describe("The paragraph's blockIndex from list-note-paragraphs"),
54224
+ occurrence: external_exports.number().int().min(1).optional().describe("Which match to use (1-based) when contains or match hits several paragraphs")
54225
+ },
54226
+ outputSchema: {
54227
+ url: external_exports.string().optional(),
54228
+ id: external_exports.string().optional(),
54229
+ identifier: external_exports.string().nullable().optional(),
54230
+ paragraph: external_exports.record(external_exports.unknown()).optional()
54231
+ },
54232
+ annotations: { readOnlyHint: true }
54233
+ },
54234
+ withErrorHandling(({ id: id2, title, folder, contains, match, blockIndex, occurrence }) => {
54235
+ const note = readNoteParagraphs({ id: id2, title, folder });
54236
+ const result = paragraphLink(note, { contains, match, blockIndex, occurrence });
54237
+ return successResponse(`Paragraph link: ${result.url}`, {
54238
+ url: result.url,
54239
+ id: note.id,
54240
+ identifier: note.identifier,
54241
+ paragraph: { ...result.paragraph }
54242
+ });
54243
+ }, "No paragraph link")
54244
+ );
53725
54245
  registerTool(
53726
54246
  "get-note-structure",
53727
54247
  {
@@ -53775,6 +54295,53 @@ registerTool(
53775
54295
  return successResponse(describeNoteStructure(structure), { ...structure });
53776
54296
  }, "Error reading note structure")
53777
54297
  );
54298
+ registerTool(
54299
+ "list-note-links",
54300
+ {
54301
+ description: "Use when: you need the links in one note (by exact id) or across a folder (with its subfolders by default), an account, or the whole library, with each link's kind: inline (a hyperlink on text), card (a rich link preview), note (a native link chip to another note) or section (a native link chip to a heading or paragraph).\nReturns: one page of links, newest-modified note first, each with its URL, label, linkSafe, target note and paragraph UUIDs for Notes deep links, card previewPath, and its source noteId, note title, folder path (as list-folders prints it) and account; plus per-kind counts and page info (call again with offset set to page.nextOffset while page.hasMore is true).\nDo not use when: you want one note's full structure (get-note-structure) or its formatting (get-note-blocks).\nSafety: read-only; reads the NoteStore database directly and requires Full Disk Access. Inline links need every body in scope decoded, so they are included only with includeInline (default true for id, false for a folder, account or library scan). Recently Deleted is skipped unless the note is requested by id.",
54302
+ inputSchema: {
54303
+ id: noteIdInput.optional().describe("One exact note ID (do not combine with account/folder)"),
54304
+ account: external_exports.string().min(1).max(MAX.ACCOUNT).optional().describe("Account name (exact or unique-prefix match)"),
54305
+ folder: external_exports.string().min(1).max(MAX.FOLDER).optional().describe(
54306
+ "Folder name or path as list-folders prints it, such as Work/Clients (escape a literal slash as \\/)"
54307
+ ),
54308
+ includeSubfolders: external_exports.boolean().optional().describe("With folder, also list notes in its subfolders (default true)"),
54309
+ includeInline: external_exports.boolean().optional().describe(
54310
+ "Also decode note bodies for inline hyperlinks (slower). Default true for id, false otherwise"
54311
+ ),
54312
+ kinds: external_exports.array(external_exports.enum(["inline", "card", "note", "section"])).max(4).optional().describe("Only these link kinds"),
54313
+ offset: external_exports.number().int().min(0).optional().describe("Index of the first link to return (default 0); use page.nextOffset"),
54314
+ limit: external_exports.number().int().min(1).max(2e3).optional().describe("Maximum links to return (default 200, max 2000)")
54315
+ },
54316
+ outputSchema: {
54317
+ scope: external_exports.record(external_exports.unknown()).optional(),
54318
+ inlineIncluded: external_exports.boolean().optional(),
54319
+ notesInScope: external_exports.number().optional(),
54320
+ notesWithoutBody: external_exports.number().optional(),
54321
+ counts: external_exports.record(external_exports.unknown()).optional(),
54322
+ links: external_exports.array(external_exports.record(external_exports.unknown())).optional(),
54323
+ page: external_exports.record(external_exports.unknown()).optional()
54324
+ },
54325
+ annotations: { readOnlyHint: true }
54326
+ },
54327
+ withErrorHandling(
54328
+ ({ id: id2, account, folder, includeSubfolders, includeInline, kinds, offset, limit }) => {
54329
+ const result = listNoteLinks({
54330
+ id: id2,
54331
+ account,
54332
+ folder,
54333
+ includeSubfolders,
54334
+ includeInline,
54335
+ kinds,
54336
+ offset,
54337
+ limit,
54338
+ maxBytes: blocksMaxResponseBytes()
54339
+ });
54340
+ return successResponse(describeLinkInventory(result), { ...result });
54341
+ },
54342
+ "Error listing note links"
54343
+ )
54344
+ );
53778
54345
  registerTool(
53779
54346
  "list-native-tags",
53780
54347
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.9.3",
3
+ "version": "2.9.5",
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",