apple-notes-mcp 2.8.10 → 2.8.12

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 +55 -6
  2. package/build/index.js +289 -122
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -565,8 +565,22 @@ visible text after saving, not byte-identical rich formatting. Warns when the
565
565
  note is shared with collaborators.
566
566
 
567
567
  **Safety:** The append is rejected if the note changed since it was read, rich
568
- metadata is unavailable, or the note contains attachments or other native
569
- objects. Existing link destinations are verified after saving.
568
+ metadata is unavailable, or the note contains attachments. Existing link
569
+ destinations are verified after saving.
570
+
571
+ **Notes containing native objects** (a table, a checklist, native tags) cannot be
572
+ spliced, so they are routed to the native end-append bridge — see
573
+ [`append-native`](#append-native). That path additionally requires `scopeText`,
574
+ keeps the default blank-line `separator` and `position: "after"`, and accepts a
575
+ fixed HTML subset rather than anything Notes.app can render:
576
+
577
+ | | Native append |
578
+ |---|---|
579
+ | Elements | `<a>` `<b>` `<br>` `<code>` `<del>` `<div>` `<em>` `<h1>` `<h2>` `<h3>` `<i>` `<li>` `<ol>` `<p>` `<s>` `<span>` `<strong>` `<table>` `<tbody>` `<td>` `<th>` `<thead>` `<tr>` `<tt>` `<u>` `<ul>` |
580
+ | Attributes | `href` on `<a>` (`https:`, `http:`, `notes:`, `applenotes:`, `mailto:` only) and a `font-size` style on `<span>`, e.g. `<span style="font-size: 18px">` — the form Notes itself stores a heading as |
581
+ | Refused | every other element and attribute, by name, naming the accepted subset; `<table>` here (use [`create-table`](#create-table)); comments, doctype and processing instructions |
582
+
583
+ Ordinary notes take the guarded HTML path and are not restricted to that subset.
570
584
 
571
585
  ---
572
586
 
@@ -815,11 +829,41 @@ after the note's actual container folder ID matches the destination folder ID.
815
829
 
816
830
  #### `export-notes-json`
817
831
 
818
- Exports all notes as a JSON structure.
832
+ Exports notes as JSON — metadata, HTML content, and plaintext, grouped by account and folder — one page at a time. A whole library rarely fits in one MCP message (note bodies embed images as base64), so each call returns a page and says where the next one starts.
819
833
 
820
- **Parameters:** None
834
+ | Parameter | Type | Required | Description |
835
+ |-----------|------|----------|-------------|
836
+ | `offset` | number | No | 0-based position to start from, counting notes in account → folder → note order (default `0`). Pass the previous page's `page.nextOffset` |
837
+ | `limit` | number | No | Maximum notes in this page (default `50`, max `500`). A page holds fewer when it reaches the response size limit |
838
+ | `modifiedSince` | string | No | ISO 8601 date string; export only notes modified on or after this date (e.g., `"2025-01-01"`). Keep the same value while paging |
839
+
840
+ **Example - First page:**
841
+ ```json
842
+ {}
843
+ ```
844
+
845
+ **Example - A later page of an incremental backup:**
846
+ ```json
847
+ {
848
+ "offset": 50,
849
+ "modifiedSince": "2025-06-01"
850
+ }
851
+ ```
852
+
853
+ **Returns:** `exportDate`, `version`, `accounts` (every account and folder, holding the notes that fall in this page), `summary` (`totalNotes` in this page, `totalFolders`, `totalAccounts`), and `page`:
854
+
855
+ | Field | Description |
856
+ |-------|-------------|
857
+ | `offset` / `limit` | The window that was applied |
858
+ | `totalAvailable` | Notes in the library, after `modifiedSince` |
859
+ | `returned` | Notes in this page |
860
+ | `nextOffset` | Where the next page starts; absent on the last page |
861
+ | `hasMore` | `true` until the last page — call again with `offset` set to `nextOffset` |
862
+ | `stoppedAtSizeLimit` | `true` when the page closed early to stay under the response size limit |
863
+
864
+ **Size limit:** each response stays under `APPLE_NOTES_MCP_EXPORT_MAX_BYTES` (default 8 MB), below the 10 MB per-message limit of MCP SDK stdio clients, which drop the connection on anything larger without passing on any error text. A note too large to fit on its own is still returned: its oversized inline images are replaced with placeholders (`strippedImages`), or failing that its HTML body, and if necessary its plaintext, is left empty with `contentOmitted: true`. Read such a note with `get-note-content`, and its files with `list-attachments` / `save-attachment`. Lower `limit` if your MCP client caps tool output below that size.
821
865
 
822
- **Returns:** Complete JSON export with all accounts, folders, and notes including metadata.
866
+ **Paging:** positions are worked out on every call, so notes created, deleted, or moved between calls can shift a page. Page through promptly, or use `modifiedSince` for incremental backups.
823
867
 
824
868
  ---
825
869
 
@@ -1020,7 +1064,11 @@ Appends bounded plaintext, semantic HTML, or Markdown while preserving existing
1020
1064
  native objects. The regular `append-to-note` tool routes protected notes here
1021
1065
  when `scopeText` is supplied. Use a distinctive phrase of plain words without
1022
1066
  punctuation, hashtags, or paths because Notes search may not resolve them
1023
- literally.
1067
+ literally. `format: "html"` accepts the fixed subset tabulated under
1068
+ [`append-to-note`](#append-to-note); content outside it is refused by name, with
1069
+ the accepted subset in the error. A transport failure names the Shortcut it was
1070
+ waiting on, so a missing or unapproved bridge is identified rather than guessed
1071
+ at.
1024
1072
 
1025
1073
  #### `create-checklist-item`
1026
1074
 
@@ -1189,6 +1237,7 @@ All configuration is optional — the server works out of the box. Override beha
1189
1237
  | `APPLE_NOTES_MCP_MAX_INLINE_IMAGE_BYTES` | `262144` (256 KB) | Per-image cap on the base64 payload kept inline in a [`get-note-content`](#get-note-content) response. Inline images over the cap are replaced with placeholders (with a warning appended) so an image-heavy note cannot exceed the MCP client's message limit and drop the connection; export the real files with [`save-attachment`](#save-attachment) or [`fetch-attachment`](#fetch-attachment). Raise it to keep bigger images inline. |
1190
1238
  | `APPLE_NOTES_MCP_CONFIG_FILE` | `~/Library/Application Support/apple-notes-mcp/config.json` | Path to the JSON config file (see below). |
1191
1239
  | `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. |
1240
+ | `APPLE_NOTES_MCP_EXPORT_MAX_BYTES` | `8388608` (8 MB) | Largest response `export-notes-json` sends; a page closes early to stay under 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. |
1192
1241
  | `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. |
1193
1242
  | `APPLE_NOTES_MCP_RETRY_DELAY_MS` | `1000` (1 s) | Base delay before the first retry; subsequent retries back off exponentially (1s, 2s, 4s, ...). |
1194
1243
  | `DEBUG` / `VERBOSE` | unset | Set either to enable verbose diagnostic logging to stderr. |
package/build/index.js CHANGED
@@ -39484,6 +39484,46 @@ function getChecklistItems(noteId3) {
39484
39484
  return { items };
39485
39485
  }
39486
39486
 
39487
+ // src/utils/inlineImages.ts
39488
+ var DEFAULT_MAX_INLINE_IMAGE_BYTES = 256 * 1024;
39489
+ function maxInlineImageBytes(env = process.env) {
39490
+ const raw = env.APPLE_NOTES_MCP_MAX_INLINE_IMAGE_BYTES;
39491
+ if (raw !== void 0) {
39492
+ const n = Number(raw);
39493
+ if (Number.isFinite(n) && n > 0) return n;
39494
+ }
39495
+ return DEFAULT_MAX_INLINE_IMAGE_BYTES;
39496
+ }
39497
+ function formatBytes(bytes) {
39498
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
39499
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
39500
+ return `${bytes} B`;
39501
+ }
39502
+ var INLINE_IMG_RE = /<img\b[^>]*\bsrc\s*=\s*(["'])data:([^;'"]+);base64,([^"']*)\1[^>]*\/?>/gi;
39503
+ function stripLargeInlineImages(html, maxBytes = maxInlineImageBytes()) {
39504
+ let strippedCount = 0;
39505
+ let strippedBytes = 0;
39506
+ const result = html.replace(INLINE_IMG_RE, (tag, _quote, mediaType, b64) => {
39507
+ if (b64.length <= maxBytes) return tag;
39508
+ const decodedBytes = Math.floor(b64.length * 3 / 4);
39509
+ strippedCount += 1;
39510
+ strippedBytes += decodedBytes;
39511
+ return `<div>[inline image omitted: ${mediaType}, ~${formatBytes(
39512
+ decodedBytes
39513
+ )}; use list-attachments and save-attachment or fetch-attachment to export it]</div>`;
39514
+ });
39515
+ return { html: result, strippedCount, strippedBytes };
39516
+ }
39517
+ function strippedImagesWarning(stripped) {
39518
+ if (stripped.strippedCount === 0) return null;
39519
+ const plural = stripped.strippedCount === 1 ? "image" : "images";
39520
+ return `
39521
+
39522
+ \u26A0\uFE0F ${stripped.strippedCount} inline ${plural} (~${formatBytes(
39523
+ stripped.strippedBytes
39524
+ )} decoded) exceeded the per-image inline cap and ${stripped.strippedCount === 1 ? "was" : "were"} replaced with placeholders so the response stays within MCP message limits. This body is therefore lossy \u2014 do NOT write it back with update-note, or the real images are replaced by the placeholder text; use append-to-note to add content. The images are still in the note: use list-attachments with save-attachment or fetch-attachment to export them, or raise APPLE_NOTES_MCP_MAX_INLINE_IMAGE_BYTES.`;
39525
+ }
39526
+
39487
39527
  // src/utils/noteRichText.ts
39488
39528
  import { execFileSync as execFileSync3 } from "node:child_process";
39489
39529
  import { createHash } from "node:crypto";
@@ -39934,6 +39974,35 @@ var FIELD_SEP = "";
39934
39974
  var RECORD_SEP = "";
39935
39975
  var AS_FIELD_SEP = "(ASCII character 31)";
39936
39976
  var AS_RECORD_SEP = "(ASCII character 30)";
39977
+ var AS_FIELD_SEP_LOCAL = "(character id 31)";
39978
+ var AS_RECORD_SEP_LOCAL = "(character id 30)";
39979
+ var DEFAULT_EXPORT_PAGE_SIZE = 50;
39980
+ var DEFAULT_EXPORT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
39981
+ function exportMaxResponseBytes(env = process.env) {
39982
+ const raw = env.APPLE_NOTES_MCP_EXPORT_MAX_BYTES;
39983
+ if (raw !== void 0) {
39984
+ const n = Number(raw);
39985
+ if (Number.isFinite(n) && n > 0) return Math.floor(n);
39986
+ }
39987
+ return DEFAULT_EXPORT_MAX_RESPONSE_BYTES;
39988
+ }
39989
+ function estimateExportNoteBytes(note) {
39990
+ return Buffer.byteLength(JSON.stringify(note)) + Buffer.byteLength(JSON.stringify(JSON.stringify(note, null, 2))) + 256;
39991
+ }
39992
+ function fitExportNoteToBudget(note, maxBytes) {
39993
+ const stripped = stripLargeInlineImages(note.content);
39994
+ if (stripped.strippedCount > 0) {
39995
+ const withPlaceholders = {
39996
+ ...note,
39997
+ content: stripped.html,
39998
+ strippedImages: stripped.strippedCount
39999
+ };
40000
+ if (estimateExportNoteBytes(withPlaceholders) <= maxBytes) return withPlaceholders;
40001
+ }
40002
+ const withoutHtml = { ...note, content: "", contentOmitted: true };
40003
+ if (estimateExportNoteBytes(withoutHtml) <= maxBytes) return withoutHtml;
40004
+ return { ...withoutHtml, plaintext: "" };
40005
+ }
39937
40006
  function executeMutationAppleScript(script) {
39938
40007
  return executeAppleScript(script, { maxRetries: 1 });
39939
40008
  }
@@ -40729,8 +40798,13 @@ var AppleNotesManager = class {
40729
40798
  set resultList to {}
40730
40799
  if fetchCount > 0 then
40731
40800
  try
40732
- set noteNames to name of ${slicedSource}
40733
- set noteIds to id of ${slicedSource}
40801
+ if fetchCount is totalCount then
40802
+ set noteNames to name of ${fullSource}
40803
+ set noteIds to id of ${fullSource}
40804
+ else
40805
+ set noteNames to name of ${slicedSource}
40806
+ set noteIds to id of ${slicedSource}
40807
+ end if
40734
40808
  on error errMsg number errNum
40735
40809
  if errNum is -1719 or errNum is -1728 then
40736
40810
  error "${BULK_LIST_MUTATION_ERROR}"
@@ -40740,11 +40814,11 @@ var AppleNotesManager = class {
40740
40814
  end try
40741
40815
  ${countGuard("noteIds")}
40742
40816
  repeat with i from 1 to count of noteNames
40743
- set end of resultList to (item i of noteNames) & ${AS_FIELD_SEP} & (item i of noteIds)
40817
+ set end of resultList to (item i of noteNames) & ${AS_FIELD_SEP_LOCAL} & (item i of noteIds)
40744
40818
  end repeat
40745
40819
  end if
40746
- set AppleScript's text item delimiters to ${AS_RECORD_SEP}
40747
- return (totalCount as text) & ${AS_RECORD_SEP} & (resultList as text)
40820
+ set AppleScript's text item delimiters to ${AS_RECORD_SEP_LOCAL}
40821
+ return (totalCount as text) & ${AS_RECORD_SEP_LOCAL} & (resultList as text)
40748
40822
  `;
40749
40823
  }
40750
40824
  const dateFetch = dateSetup ? `set noteDates to modification date of ${fullSource}
@@ -40761,9 +40835,9 @@ var AppleNotesManager = class {
40761
40835
  ${dateFetch}${countGuard("noteIds")}
40762
40836
  ${dateCountGuard}set resultList to {}
40763
40837
  repeat with i from 1 to count of noteNames
40764
- ${dateGuardOpen}set end of resultList to (item i of noteNames) & ${AS_FIELD_SEP} & (item i of noteIds)${dateGuardClose}
40838
+ ${dateGuardOpen}set end of resultList to (item i of noteNames) & ${AS_FIELD_SEP_LOCAL} & (item i of noteIds)${dateGuardClose}
40765
40839
  end repeat
40766
- set AppleScript's text item delimiters to ${AS_RECORD_SEP}
40840
+ set AppleScript's text item delimiters to ${AS_RECORD_SEP_LOCAL}
40767
40841
  return resultList as text
40768
40842
  `;
40769
40843
  }
@@ -40877,18 +40951,32 @@ var AppleNotesManager = class {
40877
40951
  const sharedNotes = [];
40878
40952
  const accounts = this.listAccounts();
40879
40953
  for (const account of accounts) {
40954
+ const countGuard = (listVar) => `if (count of ${listVar}) is not (count of noteShared) then error "${BULK_LIST_MUTATION_ERROR}"`;
40880
40955
  const script = buildAccountScopedScript(
40881
40956
  { account: account.name },
40882
40957
  `
40958
+ set noteShared to shared of notes
40883
40959
  set resultList to {}
40884
- repeat with n in notes
40885
- if shared of n is true then
40886
- set cd to creation date of n
40887
- set md to modification date of n
40888
- set end of resultList to (name of n) & ${AS_FIELD_SEP} & (id of n) & ${AS_FIELD_SEP} & ${asDatePartsExpr("cd")} & ${AS_FIELD_SEP} & ${asDatePartsExpr("md")} & ${AS_FIELD_SEP} & (shared of n as text) & ${AS_FIELD_SEP} & (password protected of n as text)
40889
- end if
40890
- end repeat
40891
- set AppleScript's text item delimiters to ${AS_RECORD_SEP}
40960
+ if noteShared contains true then
40961
+ set noteNames to name of notes
40962
+ set noteIds to id of notes
40963
+ set noteCreated to creation date of notes
40964
+ set noteModified to modification date of notes
40965
+ set noteLocked to password protected of notes
40966
+ ${countGuard("noteIds")}
40967
+ ${countGuard("noteNames")}
40968
+ ${countGuard("noteCreated")}
40969
+ ${countGuard("noteModified")}
40970
+ ${countGuard("noteLocked")}
40971
+ repeat with i from 1 to count of noteShared
40972
+ if (item i of noteShared) is true then
40973
+ set cd to item i of noteCreated
40974
+ set md to item i of noteModified
40975
+ set end of resultList to (item i of noteNames) & ${AS_FIELD_SEP_LOCAL} & (item i of noteIds) & ${AS_FIELD_SEP_LOCAL} & ${asDatePartsExpr("cd")} & ${AS_FIELD_SEP_LOCAL} & ${asDatePartsExpr("md")} & ${AS_FIELD_SEP_LOCAL} & "true" & ${AS_FIELD_SEP_LOCAL} & ((item i of noteLocked) as text)
40976
+ end if
40977
+ end repeat
40978
+ end if
40979
+ set AppleScript's text item delimiters to ${AS_RECORD_SEP_LOCAL}
40892
40980
  return resultList as text
40893
40981
  `
40894
40982
  );
@@ -42117,31 +42205,57 @@ var AppleNotesManager = class {
42117
42205
  return text.replace(/&nbsp;/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#92;/g, "\\").replace(/&amp;/g, "&").replace(/\n{3,}/g, "\n\n").trim();
42118
42206
  }
42119
42207
  /**
42120
- * Exports all notes as a JSON structure for backup/migration.
42208
+ * Exports notes as a JSON structure for backup/migration, one page at a time.
42121
42209
  *
42122
42210
  * Exports complete note data including:
42123
42211
  * - Metadata (id, title, dates, flags)
42124
42212
  * - Content (HTML and plaintext)
42125
42213
  * - Organization (folder, account)
42126
42214
  *
42215
+ * Notes are numbered 0..N-1 in account, folder, note order, after the optional
42216
+ * `modifiedSince` filter. A call returns the window starting at `offset`, at
42217
+ * most `limit` notes (DEFAULT_EXPORT_PAGE_SIZE by default), and closes the page
42218
+ * early once the next note would push the estimated tool response past
42219
+ * `maxResponseBytes`. Every account and folder is listed on every page so the
42220
+ * structure is stable; `page.nextOffset` says where to continue. Only the
42221
+ * page's notes have their metadata and bodies read.
42222
+ *
42223
+ * Why pages (#162): the whole library does not fit in one MCP message. A
42224
+ * 359-note library holding inline images serialized to a 95 MB response, and
42225
+ * the MCP SDK stdio reader closes the connection on any message over 10 MB, so
42226
+ * the caller saw a bare "Connection closed" with no error text.
42227
+ *
42228
+ * A note that alone exceeds the budget is degraded rather than dropped:
42229
+ * oversized inline images become placeholders (`strippedImages`), or failing
42230
+ * that its HTML, and if necessary its plaintext, is omitted (`contentOmitted`).
42231
+ *
42127
42232
  * Note: Password-protected notes are included with metadata only (no content).
42128
42233
  *
42129
- * @returns JSON-serializable export object
42234
+ * @param options - offset, limit, modifiedSince, and maxResponseBytes
42235
+ * @returns JSON-serializable export object for the requested page
42130
42236
  *
42131
42237
  * @example
42132
42238
  * ```typescript
42133
- * const snapshot = manager.exportNotesAsJson();
42134
- * fs.writeFileSync('notes-backup.json', JSON.stringify(snapshot, null, 2));
42239
+ * let offset: number | undefined = 0;
42240
+ * while (offset !== undefined) {
42241
+ * const snapshot = manager.exportNotesAsJson({ offset });
42242
+ * fs.appendFileSync("notes-backup.jsonl", JSON.stringify(snapshot) + "\n");
42243
+ * offset = snapshot.page.nextOffset;
42244
+ * }
42135
42245
  * ```
42136
42246
  */
42137
- exportNotesAsJson() {
42247
+ exportNotesAsJson(options = {}) {
42248
+ const offset = Math.max(0, Math.floor(options.offset ?? 0));
42249
+ const limit = Math.max(1, Math.floor(options.limit ?? DEFAULT_EXPORT_PAGE_SIZE));
42250
+ const maxResponseBytes = options.maxResponseBytes ?? exportMaxResponseBytes();
42251
+ const exportDate = (/* @__PURE__ */ new Date()).toISOString();
42138
42252
  const accounts = this.listAccounts();
42139
- const exportData = {
42140
- exportDate: (/* @__PURE__ */ new Date()).toISOString(),
42141
- version: "1.0",
42142
- accounts: [],
42143
- summary: { totalNotes: 0, totalFolders: 0, totalAccounts: accounts.length }
42144
- };
42253
+ const exportedAccounts = [];
42254
+ const summary = { totalNotes: 0, totalFolders: 0, totalAccounts: accounts.length };
42255
+ let position = 0;
42256
+ let usedBytes = 0;
42257
+ let nextOffset;
42258
+ let stoppedAtSizeLimit = false;
42145
42259
  for (const account of accounts) {
42146
42260
  const folders = this.listFolders(account.name);
42147
42261
  const accountData = {
@@ -42153,8 +42267,14 @@ var AppleNotesManager = class {
42153
42267
  name: folder.name,
42154
42268
  notes: []
42155
42269
  };
42156
- const noteRefs = this.listNoteRefs(account.name, folder.name);
42270
+ const noteRefs = this.listNoteRefs(account.name, folder.name, options.modifiedSince);
42157
42271
  for (const ref of noteRefs) {
42272
+ const index = position++;
42273
+ if (index < offset || nextOffset !== void 0) continue;
42274
+ if (summary.totalNotes >= limit) {
42275
+ nextOffset = index;
42276
+ continue;
42277
+ }
42158
42278
  const note = this.getNoteById(ref.id);
42159
42279
  if (!note) continue;
42160
42280
  note.account = account.name;
@@ -42162,15 +42282,41 @@ var AppleNotesManager = class {
42162
42282
  if (!note.passwordProtected) {
42163
42283
  content = this.getNoteContentById(ref.id);
42164
42284
  }
42165
- folderData.notes.push(this.exportNote(note, content));
42166
- exportData.summary.totalNotes++;
42285
+ let exported = this.exportNote(note, content);
42286
+ let bytes = estimateExportNoteBytes(exported);
42287
+ if (usedBytes + bytes > maxResponseBytes) {
42288
+ if (summary.totalNotes > 0) {
42289
+ nextOffset = index;
42290
+ stoppedAtSizeLimit = true;
42291
+ continue;
42292
+ }
42293
+ exported = fitExportNoteToBudget(exported, maxResponseBytes);
42294
+ bytes = estimateExportNoteBytes(exported);
42295
+ }
42296
+ folderData.notes.push(exported);
42297
+ usedBytes += bytes;
42298
+ summary.totalNotes++;
42167
42299
  }
42168
42300
  accountData.folders.push(folderData);
42169
- exportData.summary.totalFolders++;
42301
+ summary.totalFolders++;
42170
42302
  }
42171
- exportData.accounts.push(accountData);
42303
+ exportedAccounts.push(accountData);
42172
42304
  }
42173
- return exportData;
42305
+ return {
42306
+ exportDate,
42307
+ version: "1.0",
42308
+ accounts: exportedAccounts,
42309
+ summary,
42310
+ page: {
42311
+ offset,
42312
+ limit,
42313
+ totalAvailable: position,
42314
+ returned: summary.totalNotes,
42315
+ ...nextOffset !== void 0 ? { nextOffset } : {},
42316
+ hasMore: nextOffset !== void 0,
42317
+ stoppedAtSizeLimit
42318
+ }
42319
+ };
42174
42320
  }
42175
42321
  // ===========================================================================
42176
42322
  // Markdown Conversion
@@ -42521,46 +42667,6 @@ function parseHashtags(body) {
42521
42667
  return result;
42522
42668
  }
42523
42669
 
42524
- // src/utils/inlineImages.ts
42525
- var DEFAULT_MAX_INLINE_IMAGE_BYTES = 256 * 1024;
42526
- function maxInlineImageBytes(env = process.env) {
42527
- const raw = env.APPLE_NOTES_MCP_MAX_INLINE_IMAGE_BYTES;
42528
- if (raw !== void 0) {
42529
- const n = Number(raw);
42530
- if (Number.isFinite(n) && n > 0) return n;
42531
- }
42532
- return DEFAULT_MAX_INLINE_IMAGE_BYTES;
42533
- }
42534
- function formatBytes(bytes) {
42535
- if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
42536
- if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
42537
- return `${bytes} B`;
42538
- }
42539
- var INLINE_IMG_RE = /<img\b[^>]*\bsrc\s*=\s*(["'])data:([^;'"]+);base64,([^"']*)\1[^>]*\/?>/gi;
42540
- function stripLargeInlineImages(html, maxBytes = maxInlineImageBytes()) {
42541
- let strippedCount = 0;
42542
- let strippedBytes = 0;
42543
- const result = html.replace(INLINE_IMG_RE, (tag, _quote, mediaType, b64) => {
42544
- if (b64.length <= maxBytes) return tag;
42545
- const decodedBytes = Math.floor(b64.length * 3 / 4);
42546
- strippedCount += 1;
42547
- strippedBytes += decodedBytes;
42548
- return `<div>[inline image omitted: ${mediaType}, ~${formatBytes(
42549
- decodedBytes
42550
- )}; use list-attachments and save-attachment or fetch-attachment to export it]</div>`;
42551
- });
42552
- return { html: result, strippedCount, strippedBytes };
42553
- }
42554
- function strippedImagesWarning(stripped) {
42555
- if (stripped.strippedCount === 0) return null;
42556
- const plural = stripped.strippedCount === 1 ? "image" : "images";
42557
- return `
42558
-
42559
- \u26A0\uFE0F ${stripped.strippedCount} inline ${plural} (~${formatBytes(
42560
- stripped.strippedBytes
42561
- )} decoded) exceeded the per-image inline cap and ${stripped.strippedCount === 1 ? "was" : "were"} replaced with placeholders so the response stays within MCP message limits. This body is therefore lossy \u2014 do NOT write it back with update-note, or the real images are replaced by the placeholder text; use append-to-note to add content. The images are still in the note: use list-attachments with save-attachment or fetch-attachment to export them, or raise APPLE_NOTES_MCP_MAX_INLINE_IMAGE_BYTES.`;
42562
- }
42563
-
42564
42670
  // src/utils/updateResponseTitle.ts
42565
42671
  var BLOCK_END_RE = /<\/(?:div|h[1-6]|p|li)>/gi;
42566
42672
  var BREAK_RE = /<br\s*\/?\s*>/gi;
@@ -42726,7 +42832,7 @@ function runNativeTagsShortcut(input) {
42726
42832
  });
42727
42833
  } catch {
42728
42834
  throw new Error(
42729
- "Native tag operation did not complete cleanly (possibly waiting for macOS permission). Do not retry automatically; read the exact note and check Shortcuts"
42835
+ `Native tag operation did not complete cleanly; the "${status.shortcut}" Shortcut may be waiting for macOS permission. Do not retry automatically; read the exact note and check that Shortcut in Shortcuts.app`
42730
42836
  );
42731
42837
  } finally {
42732
42838
  rmSync2(directory, { recursive: true, force: true });
@@ -42824,49 +42930,62 @@ function readBackgroundSnapshot(manager, id2) {
42824
42930
  checklist: getChecklistItems(id2).items || []
42825
42931
  };
42826
42932
  }
42933
+ var NATIVE_APPEND_ELEMENTS = [
42934
+ "a",
42935
+ "b",
42936
+ "br",
42937
+ "code",
42938
+ "del",
42939
+ "div",
42940
+ "em",
42941
+ "h1",
42942
+ "h2",
42943
+ "h3",
42944
+ "i",
42945
+ "li",
42946
+ "ol",
42947
+ "p",
42948
+ "s",
42949
+ "span",
42950
+ "strong",
42951
+ "table",
42952
+ "tbody",
42953
+ "td",
42954
+ "th",
42955
+ "thead",
42956
+ "tr",
42957
+ "tt",
42958
+ "u",
42959
+ "ul"
42960
+ ];
42961
+ var NATIVE_APPEND_SPAN_STYLE = /^font-size\s*:\s*\d{1,3}(?:\.\d+)?(?:px|pt)\s*;?$/i;
42962
+ var NATIVE_APPEND_HTML_SUBSET = `Native append accepts ${NATIVE_APPEND_ELEMENTS.map((e) => `<${e}>`).join(" ")}, with href on <a> and a font-size style on <span> as the only attributes; everything else needs update-note.`;
42827
42963
  function validateAppendContent(content, format) {
42828
42964
  if (!content || content.length > 1024 * 1024 || content.includes("\0"))
42829
42965
  throw new Error("Invalid append content (limit 1 MiB)");
42830
42966
  if (format === "html") {
42831
- const allowed = /* @__PURE__ */ new Set([
42832
- "div",
42833
- "p",
42834
- "br",
42835
- "b",
42836
- "strong",
42837
- "i",
42838
- "em",
42839
- "u",
42840
- "s",
42841
- "del",
42842
- "h1",
42843
- "h2",
42844
- "h3",
42845
- "ul",
42846
- "ol",
42847
- "li",
42848
- "a",
42849
- "table",
42850
- "thead",
42851
- "tbody",
42852
- "tr",
42853
- "td",
42854
- "th"
42855
- ]);
42967
+ const allowed = new Set(NATIVE_APPEND_ELEMENTS);
42856
42968
  for (const tag of content.matchAll(/<\/?\s*([a-z][a-z0-9]*)\b([^>]*)>/gi)) {
42857
- if (!allowed.has(tag[1].toLowerCase()))
42858
- throw new Error(`Unsupported HTML element: ${tag[1]}`);
42969
+ const name = tag[1].toLowerCase();
42970
+ if (!allowed.has(name))
42971
+ throw new Error(`Unsupported HTML element: <${name}>. ${NATIVE_APPEND_HTML_SUBSET}`);
42859
42972
  let attrs = tag[2].replace(/\/$/, "").trim();
42860
- if (tag[1].toLowerCase() === "a")
42973
+ if (name === "a")
42861
42974
  attrs = attrs.replace(/\bhref\s*=\s*(["'])(.*?)\1/gi, (_s, _q, url) => {
42862
42975
  if (!/^(https?:\/\/|notes:\/\/|applenotes:|mailto:)/i.test(url) || Array.from(url).some((c) => c.charCodeAt(0) < 33))
42863
42976
  throw new Error("Unsupported link URL");
42864
42977
  return "";
42865
42978
  });
42979
+ if (name === "span")
42980
+ attrs = attrs.replace(/\bstyle\s*=\s*(["'])(.*?)\1/gi, (_s, _q, style) => {
42981
+ if (!NATIVE_APPEND_SPAN_STYLE.test(style.trim()))
42982
+ throw new Error(
42983
+ `Unsupported <span> style: ${style.trim().slice(0, 80)}. Native append accepts font-size only, as in <span style="font-size: 18px">.`
42984
+ );
42985
+ return "";
42986
+ });
42866
42987
  if (attrs.trim())
42867
- throw new Error(
42868
- "Unsupported HTML attributes; use semantic formatting and explicit blank paragraphs"
42869
- );
42988
+ throw new Error(`Unsupported HTML attributes on <${name}>. ${NATIVE_APPEND_HTML_SUBSET}`);
42870
42989
  }
42871
42990
  if (/<!--|<!|<\?|<[^>]*$/u.test(content)) throw new Error("Unsupported HTML markup");
42872
42991
  }
@@ -42876,7 +42995,9 @@ function validateAppendContent(content, format) {
42876
42995
  function runBackgroundShortcut(input) {
42877
42996
  const status = backgroundStatus();
42878
42997
  if (!status.installed)
42879
- throw new Error("Install the supplied Background Operations shortcut once");
42998
+ throw new Error(
42999
+ `Install the supplied "${status.shortcut}" Shortcut once; Shortcuts must list it exactly once`
43000
+ );
42880
43001
  const directory = mkdtempSync3(join8(tmpdir3(), "apple-notes-background-"));
42881
43002
  try {
42882
43003
  const file = join8(directory, "request.json");
@@ -42898,12 +43019,16 @@ function runBackgroundShortcut(input) {
42898
43019
  }),
42899
43020
  { mode: 384 }
42900
43021
  );
42901
- execFileSync7("/usr/bin/shortcuts", ["run", status.identifier, "--input-path", file], {
42902
- encoding: "utf8",
42903
- timeout: 6e4,
42904
- maxBuffer: 1024 * 1024,
42905
- stdio: ["ignore", "pipe", "pipe"]
42906
- });
43022
+ try {
43023
+ execFileSync7("/usr/bin/shortcuts", ["run", status.identifier, "--input-path", file], {
43024
+ encoding: "utf8",
43025
+ timeout: 6e4,
43026
+ maxBuffer: 1024 * 1024,
43027
+ stdio: ["ignore", "pipe", "pipe"]
43028
+ });
43029
+ } catch (error2) {
43030
+ throw Object.assign(error2, { shortcut: status.shortcut });
43031
+ }
42907
43032
  } finally {
42908
43033
  rmSync3(directory, { recursive: true, force: true });
42909
43034
  }
@@ -42990,7 +43115,8 @@ function mutateBackground(request, operation, data, verify, deps) {
42990
43115
  } catch (error2) {
42991
43116
  transportUncertain = true;
42992
43117
  const detail = error2;
42993
- transportMessage = detail?.code === "ETIMEDOUT" ? "Shortcuts timed out; check for an interactive parameter or permission request" : String(detail?.stderr || detail?.message || "Shortcuts failed").trim().slice(0, 500);
43118
+ const named = detail?.shortcut ? `the "${detail.shortcut}" Shortcut` : "the background Shortcut";
43119
+ transportMessage = detail?.code === "ETIMEDOUT" ? `Shortcuts timed out waiting for ${named}; check for an interactive parameter or permission request` : `${named} failed: ${String(detail?.stderr || detail?.message || "no output").trim().slice(0, 400)}`;
42994
43120
  }
42995
43121
  const after = deps.read(request.id);
42996
43122
  try {
@@ -43874,7 +44000,7 @@ function registerNativeOperations(server2, manager) {
43874
44000
  );
43875
44001
  tool(
43876
44002
  "append-native",
43877
- "Use when: appending formatted content to a native-object note without replacing its body.\nReturns: exact-ID preservation and appended-content readback.\nDo not use when: the existing note can be safely handled by append-to-note or the scope phrase is ambiguous.\nSafety: requires a fresh revision and unique existing scope phrase of plain words; avoid punctuation, hashtags, and paths because Notes search may not resolve them literally. Supports bounded plaintext, semantic HTML, and Markdown without external fetching or automatic retries.",
44003
+ "Use when: appending formatted content to a native-object note without replacing its body.\nReturns: exact-ID preservation and appended-content readback.\nDo not use when: the existing note can be safely handled by append-to-note or the scope phrase is ambiguous.\nSafety: requires a fresh revision and unique existing scope phrase of plain words; avoid punctuation, hashtags, and paths because Notes search may not resolve them literally. Supports bounded plaintext, semantic HTML, and Markdown without external fetching or automatic retries.\nHTML subset: " + NATIVE_APPEND_HTML_SUBSET,
43878
44004
  {
43879
44005
  ...common,
43880
44006
  content: external_exports.string().min(1).max(1024 * 1024),
@@ -44916,7 +45042,7 @@ registerTool(
44916
45042
  registerTool(
44917
45043
  "append-to-note",
44918
45044
  {
44919
- description: "Use when: adding content to one exact note after reading it by id.\nReturns: exact id, new content hash, and visible-text readback verification.\nDo not use when: you only have a title or the note changed since the read.\nSafety: protected native-object notes use native end-append with scopeText; ordinary notes retain guarded HTML editing. Notes.app normalizes HTML, so rich formatting is not claimed as byte-identical.",
45045
+ description: "Use when: adding content to one exact note after reading it by id.\nReturns: exact id, new content hash, and visible-text readback verification.\nDo not use when: you only have a title or the note changed since the read.\nSafety: protected native-object notes use native end-append with scopeText; ordinary notes retain guarded HTML editing. Notes.app normalizes HTML, so rich formatting is not claimed as byte-identical.\nNative-append HTML subset (protected notes only; ordinary notes accept any HTML Notes.app renders): " + NATIVE_APPEND_HTML_SUBSET + " Native append also requires scopeText, the default blank-line separator and position 'after'.",
44920
45046
  inputSchema: {
44921
45047
  id: noteIdInput,
44922
45048
  expectedContentHash: expectedContentHashInput,
@@ -45744,26 +45870,60 @@ registerTool(
45744
45870
  });
45745
45871
  }, "Error showing attachment")
45746
45872
  );
45873
+ var EXPORT_RESPONSE_OVERHEAD_BYTES = 64 * 1024;
45874
+ var formatMegabytes = (bytes) => `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
45747
45875
  registerTool(
45748
45876
  "export-notes-json",
45749
45877
  {
45750
- 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) \u2014 this reads everything and can be large.\nRead-only.",
45751
- inputSchema: {},
45878
+ description: "Use when: exporting notes as structured JSON for backup, migration, or bulk processing.\nReturns: one page of notes (default 50) with metadata, HTML content, and plaintext, grouped by account and folder, plus page info; while page.hasMore is true, call again with offset set to page.nextOffset.\nDo not use when: you need one note (get-note-content) or only titles and ids (list-notes).\nNote: a page stops early to stay under the response size limit (APPLE_NOTES_MCP_EXPORT_MAX_BYTES, default 8 MB), and a note too large on its own comes back with strippedImages or contentOmitted set. Read-only.",
45879
+ inputSchema: {
45880
+ offset: external_exports.number().int().min(0).optional().describe(
45881
+ "0-based position to start from, counting notes in account, folder, note order (default 0). Pass the previous page's page.nextOffset."
45882
+ ),
45883
+ limit: external_exports.number().int().positive().max(500).optional().describe(
45884
+ `Maximum notes in this page (default ${DEFAULT_EXPORT_PAGE_SIZE}). A page holds fewer when it reaches the response size limit; lower it if your client caps tool output.`
45885
+ ),
45886
+ modifiedSince: external_exports.string().max(64).optional().describe(
45887
+ "ISO 8601 date string; export only notes modified on or after this date (e.g., '2025-01-01'). Keep the same value while paging."
45888
+ )
45889
+ },
45752
45890
  outputSchema: {
45753
45891
  exportDate: external_exports.string().optional(),
45754
45892
  version: external_exports.string().optional(),
45755
45893
  accounts: external_exports.array(external_exports.object({}).passthrough()).optional(),
45756
- summary: external_exports.object({}).passthrough().optional()
45894
+ summary: external_exports.object({}).passthrough().optional(),
45895
+ page: external_exports.object({}).passthrough().optional()
45757
45896
  }
45758
45897
  },
45759
- withErrorHandling(() => {
45760
- const exportData = notesManager.exportNotesAsJson();
45761
- const { summary } = exportData;
45762
- return {
45898
+ withErrorHandling(({ offset, limit, modifiedSince }) => {
45899
+ const maxResponseBytes = exportMaxResponseBytes();
45900
+ const exportData = notesManager.exportNotesAsJson({
45901
+ offset,
45902
+ limit,
45903
+ modifiedSince,
45904
+ maxResponseBytes: Math.max(1, maxResponseBytes - EXPORT_RESPONSE_OVERHEAD_BYTES)
45905
+ });
45906
+ const { summary, page } = exportData;
45907
+ const since = modifiedSince ? ` modified since ${modifiedSince}` : "";
45908
+ const lines = [
45909
+ `Exported ${summary.totalNotes} of ${page.totalAvailable} notes${since} (offset ${page.offset}, limit ${page.limit}) from ${summary.totalFolders} folders across ${summary.totalAccounts} account(s).`
45910
+ ];
45911
+ if (page.hasMore) {
45912
+ lines.push(
45913
+ `More notes remain: call export-notes-json again with offset ${page.nextOffset}${modifiedSince ? " and the same modifiedSince" : ""}.` + (page.stoppedAtSizeLimit ? ` This page stopped early to stay under the ${formatMegabytes(maxResponseBytes)} response limit.` : "")
45914
+ );
45915
+ }
45916
+ const degraded = exportData.accounts.flatMap((a) => a.folders.flatMap((f) => f.notes)).filter((n) => n.strippedImages || n.contentOmitted);
45917
+ if (degraded.length > 0) {
45918
+ lines.push(
45919
+ `Too large to return whole, so oversized inline images were replaced (strippedImages) or the body was left out (contentOmitted): ${degraded.map((n) => n.id).join(", ")}. Read these with get-note-content, and their files with list-attachments and save-attachment.`
45920
+ );
45921
+ }
45922
+ const response = {
45763
45923
  content: [
45764
45924
  {
45765
45925
  type: "text",
45766
- text: `Exported ${summary.totalNotes} notes from ${summary.totalFolders} folders across ${summary.totalAccounts} account(s).
45926
+ text: `${lines.join("\n")}
45767
45927
 
45768
45928
  Full JSON export:`
45769
45929
  },
@@ -45774,6 +45934,13 @@ Full JSON export:`
45774
45934
  ],
45775
45935
  structuredContent: { ...exportData }
45776
45936
  };
45937
+ const responseBytes = Buffer.byteLength(JSON.stringify(response));
45938
+ if (responseBytes > maxResponseBytes) {
45939
+ return errorResponse(
45940
+ `Error exporting notes: this page is ${formatMegabytes(responseBytes)}, over the ${formatMegabytes(maxResponseBytes)} response limit, so it was not sent. Call export-notes-json again with offset ${page.offset} and a smaller limit (for example ${Math.max(1, Math.floor(page.returned / 2))}), or raise APPLE_NOTES_MCP_EXPORT_MAX_BYTES if your MCP client accepts larger messages.`
45941
+ );
45942
+ }
45943
+ return response;
45777
45944
  }, "Error exporting notes")
45778
45945
  );
45779
45946
  registerTool(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.8.10",
3
+ "version": "2.8.12",
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",