apple-notes-mcp 2.8.30 → 2.8.32

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 +86 -5
  2. package/build/index.js +380 -123
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -254,11 +254,14 @@ Creates a new note in Apple Notes.
254
254
  | Parameter | Type | Required | Description |
255
255
  |-----------|------|----------|-------------|
256
256
  | `title` | string | Yes | The title of the note. Automatically prepended as `<h1>` — do NOT include the title in `content` |
257
- | `content` | string | Yes | The body content of the note (do not repeat the title here) |
257
+ | `content` | string | One of `content`/`contentPath` | The body content of the note (do not repeat the title here) |
258
+ | `contentPath` | string | One of `content`/`contentPath` | Absolute path of a local UTF-8 file to use as the body instead of `content`. Allowed in the same places [`save-attachment`](#save-attachment) may write (home, temp, `/Volumes`); symbolic links, non-regular files, invalid UTF-8 and files over 1 MiB are refused before anything is written. A leading byte-order mark is dropped |
258
259
  | `tags` | string[] | No | Returned-only metadata — **NOT written to Notes.app**. Apple Notes tags can't be set via AppleScript, so values passed here are echoed back in the response but do not appear on the created note. Use inline `#hashtags` in `content` instead (Notes.app turns those into real tags). Refused with `format: "markdown"` |
259
260
  | `folder` | string | No | Folder to create the note in. Supports nested paths like `"Work/Clients"`. **The folder must already exist** — create it first with [`create-folder`](#create-folder). Defaults to account root |
260
261
  | `account` | string | No | Account name (defaults to Notes.app's default account; matched exactly or by a *unique* prefix — an ambiguous prefix is refused). Must be an account Notes.app already has configured — see [`list-accounts`](#list-accounts) |
261
262
  | `format` | string | No | Content format: `"plaintext"` (default), `"html"`, or `"markdown"`. In all formats, the title is automatically prepended as the note's title line. In plaintext mode, newlines become `<br>`, tabs become `<br>`, and backslashes are preserved as HTML entities. `"markdown"` produces real Title/Heading/Subheading styles through a Shortcut; see [Markdown notes](#markdown-notes) |
263
+ | `markdownRoute` | string | No | With `format: "markdown"` only: `"shortcut"` (default) or `"html"`. See [Markdown through HTML](#markdown-through-html) |
264
+ | `timeoutSeconds` | number | No | Whole seconds, 1–120, for each Notes.app automation step this call runs; overrides `APPLE_NOTES_MCP_TIMEOUT_MS` for this call only. A timed-out write is uncertain, not failed: read the note by id before any retry. Also accepted by `update-note`, `append-to-note`, `delete-note` and `move-note` |
262
265
 
263
266
  **Example (tagged with inline hashtags):**
264
267
  ```json
@@ -345,6 +348,35 @@ title line, with no seed line.
345
348
  as `create-note-markdown`. The Create Markdown Note Shortcut is optional and
346
349
  needed only for this format (macOS 26+); install and approve it as described in
347
350
  [`shortcuts/README.md`](shortcuts/README.md).
351
+ - A first line that is exactly `# <title>` (same case and spacing as `title`) is
352
+ removed together with one blank line after it, because the title is supplied
353
+ separately; the response then carries `strippedDuplicateTitle: true`. A
354
+ different first heading stays in the body. This applies to both Markdown
355
+ routes, and Markdown that holds only that heading is refused.
356
+
357
+ ##### Markdown through HTML
358
+
359
+ `markdownRoute: "html"` imports the same bounded Markdown subset without the
360
+ Shortcut: the server converts it to HTML and creates the note through AppleScript,
361
+ like `format: "html"`. It works in any account, accepts `tags`, and needs no
362
+ Shortcut, but headings get the plain bold styling described in the known
363
+ limitation above rather than real Heading and Subheading styles.
364
+
365
+ On this route, bullet task items (`- [ ] item`, `- [x] item`) become ordinary
366
+ list rows that start with a visible `☐` or `☑` character, and the response
367
+ reports how many as `taskItemsRendered`. They are text, not native checkable
368
+ checklist items. Use this route as the glyph fallback when the Shortcut is not
369
+ installed or the note is outside iCloud.
370
+
371
+ ```json
372
+ {
373
+ "title": "Weekly Review",
374
+ "contentPath": "/Users/me/Documents/weekly-review.md",
375
+ "format": "markdown",
376
+ "markdownRoute": "html",
377
+ "folder": "Work"
378
+ }
379
+ ```
348
380
 
349
381
  **Returns:** Confirmation message with note title and ID. Save the ID for subsequent operations like `update-note`, `delete-note`, etc.
350
382
 
@@ -737,7 +769,10 @@ was read, deletion is also rejected.
737
769
  }
738
770
  ```
739
771
 
740
- **Returns:** Confirmation message, or error if note not found.
772
+ **Returns:** Confirmation message, or error if note not found. The delete and a
773
+ check of the note's original folder run in one AppleScript: if Notes.app accepts
774
+ the delete but the note is still listed in that folder, the call reports that
775
+ nothing was deleted instead of claiming success.
741
776
 
742
777
  **⚠️ Safety:** Irreversible from the agent's side — requires explicit user confirmation before calling. Prefer `search-notes` / `list-notes` first to confirm the exact id(s) being deleted.
743
778
 
@@ -776,7 +811,7 @@ Appends or prepends content to an existing note without replacing it. Always rea
776
811
  | `id` | string | Yes | Exact CoreData note ID returned by a read or search |
777
812
  | `expectedContentHash` | string | Yes | `contentHash` from the exact note version being extended |
778
813
  | `content` | string | Yes | Text to append to the note body |
779
- | `position` | string | No | `"after"` (default) appends to the end; `"before"` prepends to the start |
814
+ | `position` | string | No | `"after"` (default) appends to the end; `"before"` inserts directly below the note's title line, so the title stays first |
780
815
  | `separator` | string | No | String placed between existing content and new content (default: two newlines → `<div><br></div>` in HTML) |
781
816
  | `format` | string | No | Format of the content being appended: `"plaintext"` (default) or `"html"` |
782
817
 
@@ -1314,6 +1349,50 @@ latest `expectedContentHash`, and an absolute `path`. The server never retries
1314
1349
  the insertion. It verifies that existing rich content survived and compares the
1315
1350
  fetched attachment bytes with the source before reporting success.
1316
1351
 
1352
+ | Parameter | Type | Required | Description |
1353
+ |-----------|------|----------|-------------|
1354
+ | `id` | string | Yes | Exact CoreData note ID |
1355
+ | `expectedContentHash` | string | Yes | `contentHash` from the note version being extended |
1356
+ | `path` | string | Yes | Absolute path of the local file; symbolic links are refused |
1357
+ | `filename` | string | No | Name the attachment gets in Notes instead of the source file's name. One path component that keeps the source file's extension, with no slash, colon, backslash, control character, leading dot or surrounding spaces. Notes names a file attachment after the file it receives, so the server gives its private temporary copy this name |
1358
+
1359
+ **Returns:** `attachmentId`, `bytes`, the `name` Notes reports, and the new
1360
+ `contentHash`. With `filename`, `filenameVerified` says whether Notes reports
1361
+ that exact name. A mismatch is a warning (`filenameWarning`), not a failure,
1362
+ because the attachment and its bytes are already verified.
1363
+
1364
+ ---
1365
+
1366
+ #### `create-note-with-attachment`
1367
+
1368
+ Creates a note and attaches one local file to it in a single call. It checks the
1369
+ file and `filename` before creating anything, creates the note through Notes.app
1370
+ the way [`create-note`](#create-note) does in plaintext, then attaches with the
1371
+ same verification as [`add-attachment`](#add-attachment).
1372
+
1373
+ | Parameter | Type | Required | Description |
1374
+ |-----------|------|----------|-------------|
1375
+ | `title` | string | Yes | One-line note title |
1376
+ | `content` | string | No | Plain-text body placed above the attachment |
1377
+ | `folder` | string | No | Existing folder or nested path; create it first with [`create-folder`](#create-folder) |
1378
+ | `account` | string | No | Account name; defaults to Notes.app's default account |
1379
+ | `path` | string | Yes | Absolute path of the local file (at most 64 MiB) |
1380
+ | `filename` | string | No | Attachment name override, as in `add-attachment` |
1381
+
1382
+ ```json
1383
+ {
1384
+ "title": "Signed lease",
1385
+ "folder": "Home",
1386
+ "path": "/Users/me/Downloads/scan-0042.pdf",
1387
+ "filename": "Lease 2026.pdf"
1388
+ }
1389
+ ```
1390
+
1391
+ **Returns:** the new note's `id` with `noteCreated: true` and the
1392
+ `add-attachment` result. If the attachment step fails after the note exists,
1393
+ the error names the new note's id: attach to it with `add-attachment` instead of
1394
+ calling this tool again, which would create a second note.
1395
+
1317
1396
  ---
1318
1397
 
1319
1398
  #### `list-attachments`
@@ -1535,7 +1614,9 @@ the note before retrying, and retry only the items that are not present.
1535
1614
  #### `create-table`
1536
1615
 
1537
1616
  Appends a native table from rectangular string rows and verifies every decoded
1538
- cell. It never substitutes a text table.
1617
+ cell. It never substitutes a text table. Omit `rows` for an empty 2 × 2 table,
1618
+ the size Notes itself inserts from Format > Table; its four empty cells are
1619
+ verified the same way.
1539
1620
 
1540
1621
  #### `set-note-pinned`
1541
1622
 
@@ -1693,7 +1774,7 @@ All configuration is optional — the server works out of the box. Override beha
1693
1774
  | `APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES` | `26214400` (25 MB) | Max size of an attachment that [`fetch-attachment`](#fetch-attachment) will base64-encode inline. Larger attachments are rejected with an error pointing at [`save-attachment`](#save-attachment) (which streams to disk and has no such limit). Raise it to fetch bigger attachments inline; lower it to cap memory. |
1694
1775
  | `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. |
1695
1776
  | `APPLE_NOTES_MCP_CONFIG_FILE` | `~/Library/Application Support/apple-notes-mcp/config.json` | Path to the JSON config file (see below). |
1696
- | `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. |
1777
+ | `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. |
1697
1778
  | `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. |
1698
1779
  | `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`. |
1699
1780
  | `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. |
package/build/index.js CHANGED
@@ -417,11 +417,11 @@ var require_codegen = __commonJS({
417
417
  const rhs = this.rhs === void 0 ? "" : ` = ${this.rhs}`;
418
418
  return `${varKind} ${this.name}${rhs};` + _n;
419
419
  }
420
- optimizeNames(names, constants2) {
420
+ optimizeNames(names, constants3) {
421
421
  if (!names[this.name.str])
422
422
  return;
423
423
  if (this.rhs)
424
- this.rhs = optimizeExpr(this.rhs, names, constants2);
424
+ this.rhs = optimizeExpr(this.rhs, names, constants3);
425
425
  return this;
426
426
  }
427
427
  get names() {
@@ -438,10 +438,10 @@ var require_codegen = __commonJS({
438
438
  render({ _n }) {
439
439
  return `${this.lhs} = ${this.rhs};` + _n;
440
440
  }
441
- optimizeNames(names, constants2) {
441
+ optimizeNames(names, constants3) {
442
442
  if (this.lhs instanceof code_1.Name && !names[this.lhs.str] && !this.sideEffects)
443
443
  return;
444
- this.rhs = optimizeExpr(this.rhs, names, constants2);
444
+ this.rhs = optimizeExpr(this.rhs, names, constants3);
445
445
  return this;
446
446
  }
447
447
  get names() {
@@ -502,8 +502,8 @@ var require_codegen = __commonJS({
502
502
  optimizeNodes() {
503
503
  return `${this.code}` ? this : void 0;
504
504
  }
505
- optimizeNames(names, constants2) {
506
- this.code = optimizeExpr(this.code, names, constants2);
505
+ optimizeNames(names, constants3) {
506
+ this.code = optimizeExpr(this.code, names, constants3);
507
507
  return this;
508
508
  }
509
509
  get names() {
@@ -532,12 +532,12 @@ var require_codegen = __commonJS({
532
532
  }
533
533
  return nodes.length > 0 ? this : void 0;
534
534
  }
535
- optimizeNames(names, constants2) {
535
+ optimizeNames(names, constants3) {
536
536
  const { nodes } = this;
537
537
  let i = nodes.length;
538
538
  while (i--) {
539
539
  const n = nodes[i];
540
- if (n.optimizeNames(names, constants2))
540
+ if (n.optimizeNames(names, constants3))
541
541
  continue;
542
542
  subtractNames(names, n.names);
543
543
  nodes.splice(i, 1);
@@ -590,12 +590,12 @@ var require_codegen = __commonJS({
590
590
  return void 0;
591
591
  return this;
592
592
  }
593
- optimizeNames(names, constants2) {
593
+ optimizeNames(names, constants3) {
594
594
  var _a;
595
- this.else = (_a = this.else) === null || _a === void 0 ? void 0 : _a.optimizeNames(names, constants2);
596
- if (!(super.optimizeNames(names, constants2) || this.else))
595
+ this.else = (_a = this.else) === null || _a === void 0 ? void 0 : _a.optimizeNames(names, constants3);
596
+ if (!(super.optimizeNames(names, constants3) || this.else))
597
597
  return;
598
- this.condition = optimizeExpr(this.condition, names, constants2);
598
+ this.condition = optimizeExpr(this.condition, names, constants3);
599
599
  return this;
600
600
  }
601
601
  get names() {
@@ -618,10 +618,10 @@ var require_codegen = __commonJS({
618
618
  render(opts) {
619
619
  return `for(${this.iteration})` + super.render(opts);
620
620
  }
621
- optimizeNames(names, constants2) {
622
- if (!super.optimizeNames(names, constants2))
621
+ optimizeNames(names, constants3) {
622
+ if (!super.optimizeNames(names, constants3))
623
623
  return;
624
- this.iteration = optimizeExpr(this.iteration, names, constants2);
624
+ this.iteration = optimizeExpr(this.iteration, names, constants3);
625
625
  return this;
626
626
  }
627
627
  get names() {
@@ -657,10 +657,10 @@ var require_codegen = __commonJS({
657
657
  render(opts) {
658
658
  return `for(${this.varKind} ${this.name} ${this.loop} ${this.iterable})` + super.render(opts);
659
659
  }
660
- optimizeNames(names, constants2) {
661
- if (!super.optimizeNames(names, constants2))
660
+ optimizeNames(names, constants3) {
661
+ if (!super.optimizeNames(names, constants3))
662
662
  return;
663
- this.iterable = optimizeExpr(this.iterable, names, constants2);
663
+ this.iterable = optimizeExpr(this.iterable, names, constants3);
664
664
  return this;
665
665
  }
666
666
  get names() {
@@ -702,11 +702,11 @@ var require_codegen = __commonJS({
702
702
  (_b = this.finally) === null || _b === void 0 ? void 0 : _b.optimizeNodes();
703
703
  return this;
704
704
  }
705
- optimizeNames(names, constants2) {
705
+ optimizeNames(names, constants3) {
706
706
  var _a, _b;
707
- super.optimizeNames(names, constants2);
708
- (_a = this.catch) === null || _a === void 0 ? void 0 : _a.optimizeNames(names, constants2);
709
- (_b = this.finally) === null || _b === void 0 ? void 0 : _b.optimizeNames(names, constants2);
707
+ super.optimizeNames(names, constants3);
708
+ (_a = this.catch) === null || _a === void 0 ? void 0 : _a.optimizeNames(names, constants3);
709
+ (_b = this.finally) === null || _b === void 0 ? void 0 : _b.optimizeNames(names, constants3);
710
710
  return this;
711
711
  }
712
712
  get names() {
@@ -1007,7 +1007,7 @@ var require_codegen = __commonJS({
1007
1007
  function addExprNames(names, from) {
1008
1008
  return from instanceof code_1._CodeOrName ? addNames(names, from.names) : names;
1009
1009
  }
1010
- function optimizeExpr(expr, names, constants2) {
1010
+ function optimizeExpr(expr, names, constants3) {
1011
1011
  if (expr instanceof code_1.Name)
1012
1012
  return replaceName(expr);
1013
1013
  if (!canOptimize(expr))
@@ -1022,14 +1022,14 @@ var require_codegen = __commonJS({
1022
1022
  return items;
1023
1023
  }, []));
1024
1024
  function replaceName(n) {
1025
- const c = constants2[n.str];
1025
+ const c = constants3[n.str];
1026
1026
  if (c === void 0 || names[n.str] !== 1)
1027
1027
  return n;
1028
1028
  delete names[n.str];
1029
1029
  return c;
1030
1030
  }
1031
1031
  function canOptimize(e) {
1032
- return e instanceof code_1._Code && e._items.some((c) => c instanceof code_1.Name && names[c.str] === 1 && constants2[c.str] !== void 0);
1032
+ return e instanceof code_1._Code && e._items.some((c) => c instanceof code_1.Name && names[c.str] === 1 && constants3[c.str] !== void 0);
1033
1033
  }
1034
1034
  }
1035
1035
  function subtractNames(names, from) {
@@ -7422,7 +7422,7 @@ var require_DOMException = __commonJS({
7422
7422
  "INVALID_NODE_TYPE_ERR (24): the supplied node is invalid or has an invalid ancestor for this operation",
7423
7423
  "DATA_CLONE_ERR (25): the object can not be cloned."
7424
7424
  ];
7425
- var constants2 = {
7425
+ var constants3 = {
7426
7426
  INDEX_SIZE_ERR,
7427
7427
  DOMSTRING_SIZE_ERR: 2,
7428
7428
  // historical
@@ -7461,8 +7461,8 @@ var require_DOMException = __commonJS({
7461
7461
  this.name = names[code];
7462
7462
  }
7463
7463
  DOMException.prototype.__proto__ = Error.prototype;
7464
- for (c in constants2) {
7465
- v = { value: constants2[c] };
7464
+ for (c in constants3) {
7465
+ v = { value: constants3[c] };
7466
7466
  Object.defineProperty(DOMException, c, v);
7467
7467
  Object.defineProperty(DOMException.prototype, c, v);
7468
7468
  }
@@ -9872,9 +9872,9 @@ var require_select = __commonJS({
9872
9872
  var makeSubject = function() {
9873
9873
  var target;
9874
9874
  function subject(el) {
9875
- var node = el.ownerDocument, scope = node.getElementsByTagName(subject.lname), i = scope.length;
9875
+ var node = el.ownerDocument, scope2 = node.getElementsByTagName(subject.lname), i = scope2.length;
9876
9876
  while (i--) {
9877
- if (subject.test(scope[i]) && target === el) {
9877
+ if (subject.test(scope2[i]) && target === el) {
9878
9878
  target = null;
9879
9879
  return true;
9880
9880
  }
@@ -9902,16 +9902,16 @@ var require_select = __commonJS({
9902
9902
  };
9903
9903
  };
9904
9904
  var find = function(sel, node) {
9905
- var results = [], test = compile(sel), scope = node.getElementsByTagName(test.qname), i = 0, el;
9906
- while (el = scope[i++]) {
9905
+ var results = [], test = compile(sel), scope2 = node.getElementsByTagName(test.qname), i = 0, el;
9906
+ while (el = scope2[i++]) {
9907
9907
  if (test(el)) results.push(el);
9908
9908
  }
9909
9909
  if (test.sel) {
9910
9910
  while (test.sel) {
9911
9911
  test = compile(test.sel);
9912
- scope = node.getElementsByTagName(test.qname);
9912
+ scope2 = node.getElementsByTagName(test.qname);
9913
9913
  i = 0;
9914
- while (el = scope[i++]) {
9914
+ while (el = scope2[i++]) {
9915
9915
  if (test(el) && indexOf.call(results, el) === -1) {
9916
9916
  results.push(el);
9917
9917
  }
@@ -38990,6 +38990,22 @@ var NODE_RUNTIME_TCC_GUIDE_URL = "https://github.com/sweetrb/apple-notes-mcp/blo
38990
38990
  var AUTOMATION_PERMISSION_GUIDE_URL = "https://github.com/sweetrb/apple-notes-mcp#permission-denied";
38991
38991
  var AUTOMATION_REMEDIATION = `Grant automation access in System Settings > Privacy & Security > Automation to the app that launches this server (Claude Desktop / Terminal / iTerm2), then fully quit and relaunch it \u2014 run the doctor tool to verify. See: ${AUTOMATION_PERMISSION_GUIDE_URL}`;
38992
38992
 
38993
+ // src/utils/callTimeout.ts
38994
+ import { AsyncLocalStorage } from "node:async_hooks";
38995
+ var CALL_TIMEOUT_SECONDS = { min: 1, max: 120 };
38996
+ var scope = new AsyncLocalStorage();
38997
+ function runWithCallTimeout(seconds, fn) {
38998
+ if (seconds === void 0) return fn();
38999
+ if (!Number.isInteger(seconds) || seconds < CALL_TIMEOUT_SECONDS.min || seconds > CALL_TIMEOUT_SECONDS.max)
39000
+ throw new Error(
39001
+ `timeoutSeconds must be a whole number from ${CALL_TIMEOUT_SECONDS.min} to ${CALL_TIMEOUT_SECONDS.max}`
39002
+ );
39003
+ return scope.run(seconds * 1e3, fn);
39004
+ }
39005
+ function callTimeoutMs() {
39006
+ return scope.getStore();
39007
+ }
39008
+
38993
39009
  // src/utils/applescript.ts
38994
39010
  var DEFAULT_TIMEOUT_MS = 3e4;
38995
39011
  var DEFAULT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
@@ -39146,7 +39162,7 @@ function parseErrorMessage(errorOutput) {
39146
39162
  return coreError.trim() || "Unknown AppleScript error";
39147
39163
  }
39148
39164
  function executeAppleScript(script, options = {}) {
39149
- const timeoutMs = options.timeoutMs ?? envPositiveNumber("APPLE_NOTES_MCP_TIMEOUT_MS") ?? DEFAULT_TIMEOUT_MS;
39165
+ const timeoutMs = options.timeoutMs ?? callTimeoutMs() ?? envPositiveNumber("APPLE_NOTES_MCP_TIMEOUT_MS") ?? DEFAULT_TIMEOUT_MS;
39150
39166
  const maxRetries = options.maxRetries ?? envPositiveNumber("APPLE_NOTES_MCP_MAX_RETRIES") ?? DEFAULT_MAX_RETRIES;
39151
39167
  const retryDelayMs = options.retryDelayMs ?? envPositiveNumber("APPLE_NOTES_MCP_RETRY_DELAY_MS") ?? DEFAULT_RETRY_DELAY_MS;
39152
39168
  if (!script || !script.trim()) {
@@ -40837,10 +40853,14 @@ function readSmartFolders(dbPath2 = NOTES_DB_PATH3) {
40837
40853
 
40838
40854
  // src/utils/attachmentFs.ts
40839
40855
  import {
40856
+ closeSync,
40857
+ constants,
40840
40858
  existsSync as existsSync3,
40859
+ fstatSync,
40841
40860
  lstatSync,
40842
40861
  mkdirSync,
40843
40862
  mkdtempSync,
40863
+ openSync,
40844
40864
  readFileSync,
40845
40865
  realpathSync,
40846
40866
  rmSync,
@@ -40964,6 +40984,48 @@ function fileSize(p) {
40964
40984
  function makeTempDir() {
40965
40985
  return mkdtempSync(resolve(tmpdir(), "apple-notes-att-"));
40966
40986
  }
40987
+ function readAllowedTextFile(p, maxBytes, roots = allowedSaveRoots()) {
40988
+ if (!p || !p.trim()) throw new Error("A content file path is required.");
40989
+ if (!isAbsolute(p)) throw new Error(`Content file path must be absolute: "${p}"`);
40990
+ const abs = resolve(p);
40991
+ if (!isWithinRoots(abs, roots))
40992
+ throw new Error(`Refusing to read outside allowed locations (home, temp, /Volumes): "${abs}"`);
40993
+ let canonical;
40994
+ try {
40995
+ canonical = canonicalize(abs);
40996
+ } catch {
40997
+ throw new Error(`Content file does not exist or cannot be resolved: "${abs}"`);
40998
+ }
40999
+ if (!isWithinRoots(canonical, canonicalRoots(roots)))
41000
+ throw new Error(
41001
+ `Refusing to read outside allowed locations (home, temp, /Volumes): "${abs}" resolves to "${canonical}".`
41002
+ );
41003
+ let descriptor;
41004
+ try {
41005
+ descriptor = openSync(abs, constants.O_RDONLY | constants.O_NOFOLLOW);
41006
+ } catch (error2) {
41007
+ if (error2.code === "ELOOP")
41008
+ throw new Error(`Refusing to read the symbolic link "${abs}".`);
41009
+ throw error2;
41010
+ }
41011
+ try {
41012
+ const stat = fstatSync(descriptor);
41013
+ if (!stat.isFile()) throw new Error(`Content file is not a regular file: "${abs}"`);
41014
+ if (stat.size === 0) throw new Error(`Content file is empty: "${abs}"`);
41015
+ if (stat.size > maxBytes)
41016
+ throw new Error(`Content file is ${stat.size} bytes, over the ${maxBytes}-byte limit.`);
41017
+ const bytes = readFileSync(descriptor);
41018
+ if (bytes.length !== stat.size)
41019
+ throw new Error("Content file changed while it was being read; try again");
41020
+ try {
41021
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/^\uFEFF/, "");
41022
+ } catch {
41023
+ throw new Error(`Content file is not valid UTF-8 text: "${abs}"`);
41024
+ }
41025
+ } finally {
41026
+ closeSync(descriptor);
41027
+ }
41028
+ }
40967
41029
  function cleanupTempDir(dir) {
40968
41030
  try {
40969
41031
  if (existsSync3(dir)) rmSync(dir, { recursive: true, force: true });
@@ -41182,10 +41244,10 @@ function buildAccountResolution(account) {
41182
41244
  if ${AS_ACCOUNT_REF} is missing value then error "${ACCOUNT_RESOLUTION_ERROR} Account \\"${safeAccount}\\" not found"
41183
41245
  `;
41184
41246
  }
41185
- function buildAccountScopedScript(scope, command) {
41247
+ function buildAccountScopedScript(scope2, command) {
41186
41248
  return `
41187
41249
  tell application "Notes"
41188
- ${buildAccountResolution(scope.account)}
41250
+ ${buildAccountResolution(scope2.account)}
41189
41251
  tell ${AS_ACCOUNT_REF}
41190
41252
  ${command}
41191
41253
  end tell
@@ -41765,11 +41827,20 @@ var AppleNotesManager = class {
41765
41827
  const safeExpectedBody = escapeHtmlForAppleScript(expectedBody);
41766
41828
  const script = buildAppLevelScript(`
41767
41829
  set noteRef to note id "${safeId}"
41830
+ set originalFolder to missing value
41831
+ try
41832
+ set originalFolder to container of noteRef
41833
+ end try
41768
41834
  set currentBody to body of noteRef
41769
41835
  considering case
41770
41836
  if currentBody is not "${safeExpectedBody}" and currentBody is not "${safeExpectedBody}" & linefeed then return "SAFETY_CONFLICT"
41771
41837
  delete noteRef
41772
41838
  end considering
41839
+ if originalFolder is not missing value then
41840
+ try
41841
+ if (id of notes of originalFolder) contains "${safeId}" then return "SAFETY_NOT_DELETED"
41842
+ end try
41843
+ end if
41773
41844
  return "SAFETY_DELETED"
41774
41845
  `);
41775
41846
  const result = executeMutationAppleScript(script);
@@ -41779,6 +41850,7 @@ var AppleNotesManager = class {
41779
41850
  }
41780
41851
  const status = result.output.trim();
41781
41852
  if (status === "SAFETY_CONFLICT") return { status: "conflict" };
41853
+ if (status === "SAFETY_NOT_DELETED") return { status: "not-deleted" };
41782
41854
  return status === "SAFETY_DELETED" ? { status: "deleted" } : { status: "failed" };
41783
41855
  }
41784
41856
  /**
@@ -44969,7 +45041,22 @@ import { join as join12 } from "node:path";
44969
45041
 
44970
45042
  // src/utils/appendMarkdown.ts
44971
45043
  var escape2 = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
44972
- function appendMarkdownHtml(markdown) {
45044
+ var TASK_GLYPHS = { open: "\u2610", done: "\u2611" };
45045
+ var TASK_ITEM = /^\[([ xX])\][ \t]+(\S.*)$/;
45046
+ function stripDuplicateTitleHeading(markdown, title) {
45047
+ const normalized2 = markdown.replace(/^\uFEFF/, "");
45048
+ const lines = normalized2.split(/\r?\n/);
45049
+ if (lines[0] !== `# ${title}`) return { content: markdown, stripped: false };
45050
+ const rest = lines.slice(lines.length > 1 && lines[1] === "" ? 2 : 1);
45051
+ return { content: rest.join("\n"), stripped: true };
45052
+ }
45053
+ function countTaskItems(markdown) {
45054
+ return markdown.replace(/\r\n/g, "\n").split("\n").filter((line) => {
45055
+ const item = /^[-+*]\s+(.+)$/.exec(line);
45056
+ return item !== null && TASK_ITEM.test(item[1]);
45057
+ }).length;
45058
+ }
45059
+ function appendMarkdownHtml(markdown, options = {}) {
44973
45060
  if (markdown.includes("\uE000") || markdown.includes("\uE001"))
44974
45061
  throw new Error("Unsupported reserved characters");
44975
45062
  if (Array.from(markdown).some((c) => c.charCodeAt(0) < 32 && !["\n", "\r", " "].includes(c)))
@@ -45008,7 +45095,8 @@ function appendMarkdownHtml(markdown) {
45008
45095
  list = kind;
45009
45096
  html += `<${kind}>`;
45010
45097
  }
45011
- html += `<li>${inline(item[2])}</li>`;
45098
+ const task = options.taskGlyphs && item[1] ? TASK_ITEM.exec(item[2]) : null;
45099
+ html += task ? `<li>${task[1] === " " ? TASK_GLYPHS.open : TASK_GLYPHS.done} ${inline(task[2])}</li>` : `<li>${inline(item[2])}</li>`;
45012
45100
  continue;
45013
45101
  }
45014
45102
  close();
@@ -45178,7 +45266,7 @@ function runBackgroundShortcut(input, status = backgroundStatus()) {
45178
45266
  try {
45179
45267
  execFileSync11("/usr/bin/shortcuts", ["run", status.identifier, "--input-path", file], {
45180
45268
  encoding: "utf8",
45181
- timeout: 6e4,
45269
+ timeout: callTimeoutMs() ?? 6e4,
45182
45270
  maxBuffer: 1024 * 1024,
45183
45271
  stdio: ["ignore", "pipe", "pipe"]
45184
45272
  });
@@ -45193,9 +45281,9 @@ function backgroundDependencies(manager) {
45193
45281
  return {
45194
45282
  read: (id2) => readBackgroundSnapshot(manager, id2),
45195
45283
  run: runBackgroundShortcut,
45196
- candidates: (title, scope) => manager.listAccounts().flatMap(
45284
+ candidates: (title, scope2) => manager.listAccounts().flatMap(
45197
45285
  (account) => manager.searchNotes(title, false, account.name).filter(
45198
- (note) => note.title === title && !note.passwordProtected && manager.getNotePlaintextById(note.id).includes(scope)
45286
+ (note) => note.title === title && !note.passwordProtected && manager.getNotePlaintextById(note.id).includes(scope2)
45199
45287
  ).map((note) => note.id)
45200
45288
  )
45201
45289
  };
@@ -46650,17 +46738,17 @@ function insertLink(request, deps) {
46650
46738
  // src/tools/directOperations.ts
46651
46739
  import { createHash as createHash2 } from "node:crypto";
46652
46740
  import {
46653
- closeSync,
46654
- constants,
46655
- fstatSync,
46741
+ closeSync as closeSync2,
46742
+ constants as constants2,
46743
+ fstatSync as fstatSync2,
46656
46744
  mkdtempSync as mkdtempSync4,
46657
- openSync,
46745
+ openSync as openSync2,
46658
46746
  readFileSync as readFileSync3,
46659
46747
  rmSync as rmSync4,
46660
46748
  writeFileSync as writeFileSync3
46661
46749
  } from "node:fs";
46662
46750
  import { tmpdir as tmpdir4 } from "node:os";
46663
- import { basename, isAbsolute as isAbsolute2, join as join15 } from "node:path";
46751
+ import { basename, extname, isAbsolute as isAbsolute2, join as join15 } from "node:path";
46664
46752
  var noteId = exactIdInput(
46665
46753
  "ICNote",
46666
46754
  /^x-coredata:\/\/[0-9a-f-]+\/ICNote\/p\d+$/i,
@@ -46676,7 +46764,7 @@ function readSnapshot(manager, id2) {
46676
46764
  if (!body) throw new Error("Note content is unavailable");
46677
46765
  const enriched = enrichNoteRead(id2, body);
46678
46766
  const rich = readRichNote(id2);
46679
- if (!enriched.complete || enriched.revision !== rich.revision)
46767
+ if (enriched.revision !== rich.revision)
46680
46768
  throw new Error("Native metadata changed during read; read the note again");
46681
46769
  return { id: id2, title: note.title, body, rich, hash: richContentHash(body, enriched) };
46682
46770
  }
@@ -46705,9 +46793,9 @@ function assertExistingContentPreserved(before, after) {
46705
46793
  }
46706
46794
  function localAttachment(path7) {
46707
46795
  if (!isAbsolute2(path7)) throw new Error("An absolute local file path is required");
46708
- const descriptor = openSync(path7, constants.O_RDONLY | constants.O_NOFOLLOW);
46796
+ const descriptor = openSync2(path7, constants2.O_RDONLY | constants2.O_NOFOLLOW);
46709
46797
  try {
46710
- const stat = fstatSync(descriptor);
46798
+ const stat = fstatSync2(descriptor);
46711
46799
  if (!stat.isFile() || stat.size === 0 || stat.size > 64 * 1024 * 1024)
46712
46800
  throw new Error("Attachment must be a nonempty regular file of at most 64 MiB");
46713
46801
  const bytes = readFileSync3(descriptor);
@@ -46715,7 +46803,7 @@ function localAttachment(path7) {
46715
46803
  throw new Error("Attachment changed while it was being read; try again");
46716
46804
  return bytes;
46717
46805
  } finally {
46718
- closeSync(descriptor);
46806
+ closeSync2(descriptor);
46719
46807
  }
46720
46808
  }
46721
46809
  function registerDirectOperations(server2, manager) {
@@ -46765,66 +46853,144 @@ function registerDirectOperations(server2, manager) {
46765
46853
  ...manager.renameFolderById(args.id, args.expectedName, args.expectedParentId, args.newName)
46766
46854
  })
46767
46855
  );
46856
+ const attachmentInput = {
46857
+ path: external_exports.string().min(1).max(4096).describe("Absolute path of the local file to attach"),
46858
+ filename: external_exports.string().min(1).max(255).optional().describe(
46859
+ "Name the attachment gets in Notes instead of the source file's name. One path component with the source file's extension; no slash, colon, or control characters, and no leading dot."
46860
+ )
46861
+ };
46768
46862
  tool(
46769
46863
  "add-attachment",
46770
- "Use when: adding one local file to an exact note without replacing its body.\nReturns: the new attachment id, byte count, and post-write content hash after exact byte verification.\nDo not use when: reading or exporting an existing attachment.\nSafety: requires a fresh rich revision, copies at most 64 MiB through a private temporary file, never retries insertion, and verifies existing content plus fetched bytes.",
46771
- { id: noteId, expectedContentHash: revision, path: external_exports.string().min(1).max(4096) },
46772
- ({ id: id2, expectedContentHash, path: path7 }) => {
46773
- const before = readSnapshot(manager, id2);
46774
- if (before.hash !== expectedContentHash) throw new Error("Note revision changed");
46775
- const bytes = localAttachment(path7);
46776
- const beforeAttachments = manager.listAttachmentsById(id2);
46777
- const directory = mkdtempSync4(join15(tmpdir4(), "notes-attachment-add-"));
46778
- const temporaryFile = join15(directory, basename(path7));
46864
+ "Use when: adding one local file to an exact note without replacing its body.\nReturns: the new attachment id, byte count, attachment name, and post-write content hash after exact byte verification.\nDo not use when: reading or exporting an existing attachment.\nSafety: requires a fresh rich revision, copies at most 64 MiB through a private temporary file, never retries insertion, and verifies existing content plus fetched bytes.",
46865
+ { id: noteId, expectedContentHash: revision, ...attachmentInput },
46866
+ (args) => attachFile(manager, args)
46867
+ );
46868
+ tool(
46869
+ "create-note-with-attachment",
46870
+ "Use when: creating a new note that holds one local file, in one call.\nReturns: the new note id plus the add-attachment result (attachment id, bytes, name, content hash).\nDo not use when: the note already exists (add-attachment).\nSafety: checks the file and filename before creating anything, creates the note through Notes.app like create-note, then attaches with the same byte verification as add-attachment. If the attachment step fails after the note exists, the error names the new note's id; attach to it with add-attachment instead of creating another note.",
46871
+ {
46872
+ title: external_exports.string().min(1).max(2e3).refine((s) => !/[\r\n\0]/u.test(s), "One-line title"),
46873
+ content: external_exports.string().min(1).max(1024 * 1024).optional().describe("Optional plain-text body placed above the attachment"),
46874
+ folder: external_exports.string().max(1e3).optional().describe("Existing folder or nested path; create it first with create-folder"),
46875
+ account: external_exports.string().max(200).optional().describe("Account name; defaults to Notes' default"),
46876
+ ...attachmentInput
46877
+ },
46878
+ (args) => {
46879
+ const name = attachmentName(args.path, args.filename);
46880
+ localAttachment(args.path);
46881
+ const note = manager.createNote(
46882
+ args.title,
46883
+ args.content ?? "",
46884
+ [],
46885
+ args.folder,
46886
+ args.account,
46887
+ "plaintext"
46888
+ );
46889
+ if (!note)
46890
+ throw new Error(
46891
+ `Failed to create note "${args.title}". Check that the folder and account exist (list-folders, list-accounts); nothing was attached`
46892
+ );
46893
+ const handOff = `Note ${note.id} was created; attach to it with add-attachment instead of creating another note`;
46894
+ let snapshot;
46895
+ try {
46896
+ snapshot = readSnapshot(manager, note.id);
46897
+ } catch (error2) {
46898
+ throw new Error(`${handOff}. The new note could not be read back: ${String(error2)}`);
46899
+ }
46779
46900
  try {
46780
- writeFileSync3(temporaryFile, bytes, { mode: 384 });
46781
- if (readSnapshot(manager, id2).hash !== before.hash)
46782
- throw new Error("Note revision changed");
46783
- let returnedId;
46784
- let transportUncertain = false;
46785
- try {
46786
- returnedId = manager.addAttachmentById(id2, before.body, temporaryFile);
46787
- } catch {
46788
- transportUncertain = true;
46789
- }
46790
- const after = readSnapshot(manager, id2);
46791
- assertExistingContentPreserved(before, after);
46792
- const readInserted = () => [
46793
- ...new Map(manager.listAttachmentsById(id2).map((item) => [item.id, item])).values()
46794
- ].filter((item) => !beforeAttachments.some((existing) => existing.id === item.id));
46795
- let inserted = readInserted();
46796
- for (let attempt = 0; attempt < 4 && (inserted.length !== 1 || returnedId && returnedId !== inserted[0].id); attempt++) {
46797
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250);
46798
- inserted = readInserted();
46799
- }
46800
- const persistentReturnedId = returnedId && /\/ICAttachment\/p\d+$/.test(returnedId);
46801
- if (inserted.length !== 1 || persistentReturnedId && returnedId !== inserted[0].id)
46802
- throw new Error(
46803
- "Attachment insertion outcome uncertain; read the exact note before retrying"
46804
- );
46805
- const attachmentId = inserted[0].id;
46806
- const fetched = manager.getAttachmentBase64ById(id2, attachmentId);
46807
- const actual = typeof fetched.base64 === "string" ? Buffer.from(fetched.base64, "base64") : null;
46808
- if (!actual || createHash2("sha256").update(actual).digest("hex") !== createHash2("sha256").update(bytes).digest("hex"))
46809
- throw new Error(
46810
- "Attachment bytes were not verified; read the exact note before retrying"
46811
- );
46812
46901
  return {
46813
- ok: true,
46814
- id: id2,
46815
- attachmentId,
46816
- contentHash: after.hash,
46817
- bytes: bytes.length,
46818
- ...transportUncertain ? {
46819
- transportWarning: "Transport was uncertain; exact bytes and prior content were verified"
46820
- } : {}
46902
+ ...attachFile(manager, {
46903
+ id: note.id,
46904
+ expectedContentHash: snapshot.hash,
46905
+ path: args.path,
46906
+ filename: name
46907
+ }),
46908
+ title: args.title,
46909
+ folder: args.folder,
46910
+ noteCreated: true
46821
46911
  };
46822
- } finally {
46823
- rmSync4(directory, { recursive: true, force: true });
46912
+ } catch (error2) {
46913
+ throw new Error(`${handOff}. ${error2 instanceof Error ? error2.message : String(error2)}`);
46824
46914
  }
46825
46915
  }
46826
46916
  );
46827
46917
  }
46918
+ function attachmentName(path7, filename) {
46919
+ const source = basename(path7);
46920
+ if (filename === void 0) return source;
46921
+ if (Buffer.byteLength(filename, "utf8") > 255)
46922
+ throw new Error("filename must be at most 255 bytes");
46923
+ if (filename !== filename.trim() || filename === "" || filename.startsWith(".") || /[/:\\\p{Cc}]/u.test(filename))
46924
+ throw new Error(
46925
+ "filename must be one path component with no slash, colon, backslash, control character, leading dot, or surrounding spaces"
46926
+ );
46927
+ if (extname(filename).toLowerCase() !== extname(source).toLowerCase())
46928
+ throw new Error(
46929
+ `filename must keep the source file's extension (${extname(source) || "none"})`
46930
+ );
46931
+ return filename;
46932
+ }
46933
+ function attachFile(manager, args) {
46934
+ const { id: id2, expectedContentHash, path: path7 } = args;
46935
+ const name = attachmentName(path7, args.filename);
46936
+ const before = readSnapshot(manager, id2);
46937
+ if (before.hash !== expectedContentHash) throw new Error("Note revision changed");
46938
+ const bytes = localAttachment(path7);
46939
+ const beforeAttachments = manager.listAttachmentsById(id2);
46940
+ const directory = mkdtempSync4(join15(tmpdir4(), "notes-attachment-add-"));
46941
+ const temporaryFile = join15(directory, name);
46942
+ try {
46943
+ writeFileSync3(temporaryFile, bytes, { mode: 384 });
46944
+ if (readSnapshot(manager, id2).hash !== before.hash) throw new Error("Note revision changed");
46945
+ let returnedId;
46946
+ let transportUncertain = false;
46947
+ try {
46948
+ returnedId = manager.addAttachmentById(id2, before.body, temporaryFile);
46949
+ } catch {
46950
+ transportUncertain = true;
46951
+ }
46952
+ const after = readSnapshot(manager, id2);
46953
+ assertExistingContentPreserved(before, after);
46954
+ const readInserted = () => [...new Map(manager.listAttachmentsById(id2).map((item) => [item.id, item])).values()].filter(
46955
+ (item) => !beforeAttachments.some((existing) => existing.id === item.id)
46956
+ );
46957
+ let inserted = readInserted();
46958
+ for (let attempt = 0; attempt < 4 && (inserted.length !== 1 || returnedId && returnedId !== inserted[0].id); attempt++) {
46959
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250);
46960
+ inserted = readInserted();
46961
+ }
46962
+ const persistentReturnedId = returnedId && /\/ICAttachment\/p\d+$/.test(returnedId);
46963
+ if (inserted.length !== 1 || persistentReturnedId && returnedId !== inserted[0].id)
46964
+ throw new Error(
46965
+ "Attachment insertion outcome uncertain; read the exact note before retrying"
46966
+ );
46967
+ const attachmentId = inserted[0].id;
46968
+ const fetched = manager.getAttachmentBase64ById(id2, attachmentId);
46969
+ const actual = typeof fetched.base64 === "string" ? Buffer.from(fetched.base64, "base64") : null;
46970
+ if (!actual || createHash2("sha256").update(actual).digest("hex") !== createHash2("sha256").update(bytes).digest("hex"))
46971
+ throw new Error("Attachment bytes were not verified; read the exact note before retrying");
46972
+ const nameVerified = inserted[0].name === name;
46973
+ return {
46974
+ ok: true,
46975
+ id: id2,
46976
+ attachmentId,
46977
+ contentHash: after.hash,
46978
+ bytes: bytes.length,
46979
+ name: inserted[0].name,
46980
+ ...args.filename === void 0 ? {} : {
46981
+ filenameVerified: nameVerified,
46982
+ ...nameVerified ? {} : {
46983
+ filenameWarning: "The attachment and its bytes were verified, but Notes reports a different name"
46984
+ }
46985
+ },
46986
+ ...transportUncertain ? {
46987
+ transportWarning: "Transport was uncertain; exact bytes and prior content were verified"
46988
+ } : {}
46989
+ };
46990
+ } finally {
46991
+ rmSync4(directory, { recursive: true, force: true });
46992
+ }
46993
+ }
46828
46994
 
46829
46995
  // src/tools/nativeTagsBridge.ts
46830
46996
  var noteId2 = exactIdInput(
@@ -46896,9 +47062,9 @@ function registerNativeTagsBridge(server2, manager) {
46896
47062
  { id: id2, expectedContentHash, scopeText, tags, title: initial.title },
46897
47063
  {
46898
47064
  read,
46899
- candidates: (title, scope) => manager.listAccounts().flatMap(
47065
+ candidates: (title, scope2) => manager.listAccounts().flatMap(
46900
47066
  (account) => manager.searchNotes(title, false, account.name).filter(
46901
- (note) => !note.passwordProtected && manager.getNotePlaintextById(note.id).toLocaleLowerCase().includes(scope.toLocaleLowerCase())
47067
+ (note) => !note.passwordProtected && manager.getNotePlaintextById(note.id).toLocaleLowerCase().includes(scope2.toLocaleLowerCase())
46902
47068
  ).map((note) => note.id)
46903
47069
  ),
46904
47070
  run: runNativeTagsShortcut
@@ -46952,6 +47118,10 @@ var common = {
46952
47118
  "Distinctive existing phrase used by Notes search. Prefer plain words without punctuation, hashtags, or paths."
46953
47119
  )
46954
47120
  };
47121
+ var EMPTY_TABLE_ROWS = [
47122
+ ["", ""],
47123
+ ["", ""]
47124
+ ];
46955
47125
  var htmlEscape = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
46956
47126
  var MAX_CHECKLIST_BATCH = 20;
46957
47127
  var byPosition = (items = []) => [...items].sort((a, b) => a.start - b.start);
@@ -47153,13 +47323,16 @@ function registerNativeOperations(server2, manager) {
47153
47323
  );
47154
47324
  tool(
47155
47325
  "create-table",
47156
- "Use when: appending one native Notes table from a rectangular array of strings.\nReturns: the native table identity and decoded cells after readback.\nDo not use when: a text table is acceptable or the rows are not rectangular.\nSafety: never substitutes text; preserves existing rich content and reports failure unless the native table is verified.",
47326
+ "Use when: appending one native Notes table from a rectangular array of strings, or an empty table when rows is omitted.\nReturns: the native table identity and decoded cells after readback.\nDo not use when: a text table is acceptable or the rows are not rectangular.\nSafety: never substitutes text; preserves existing rich content and reports failure unless the native table is verified.",
47157
47327
  {
47158
47328
  ...common,
47159
- rows: external_exports.array(external_exports.array(external_exports.string().max(1e4)).min(1).max(100)).min(1).max(1e3)
47329
+ rows: external_exports.array(external_exports.array(external_exports.string().max(1e4)).min(1).max(100)).min(1).max(1e3).optional().describe(
47330
+ "Cell text, row by row. Omit for an empty 2 x 2 table, the size Notes itself inserts."
47331
+ )
47160
47332
  },
47161
- (args) => {
47333
+ (input) => {
47162
47334
  requireValidated("create-table");
47335
+ const args = { ...input, rows: input.rows ?? EMPTY_TABLE_ROWS };
47163
47336
  if (args.rows.some((row) => row.length !== args.rows[0].length))
47164
47337
  throw new Error("Table rows must have equal cell counts");
47165
47338
  const before = readRichNote(args.id);
@@ -47437,7 +47610,11 @@ function errorResponse(message, cause) {
47437
47610
  function withErrorHandling(handler, errorPrefix) {
47438
47611
  return async (params) => {
47439
47612
  try {
47440
- return handler(params);
47613
+ const seconds = params.timeoutSeconds;
47614
+ return runWithCallTimeout(
47615
+ typeof seconds === "number" ? seconds : void 0,
47616
+ () => handler(params)
47617
+ );
47441
47618
  } catch (error2) {
47442
47619
  const message = error2 instanceof Error ? error2.message : "Unknown error";
47443
47620
  return errorResponse(`${errorPrefix}: ${message}`, error2);
@@ -47457,6 +47634,7 @@ var MAX = {
47457
47634
  TAGS: 100,
47458
47635
  BATCH_IDS: 500
47459
47636
  };
47637
+ var MAX_CONTENT_FILE_BYTES = 1024 * 1024;
47460
47638
  var noteTitleSchema = {
47461
47639
  title: external_exports.string().min(1, "Note title is required").max(MAX.TITLE),
47462
47640
  account: external_exports.string().max(MAX.ACCOUNT).optional().describe(
@@ -47485,6 +47663,9 @@ function noteIdentifiers(id2) {
47485
47663
  var expectedContentHashInput = external_exports.string().regex(/^sha256:[a-f0-9]{64}$/, "expectedContentHash must come from get-note-content").describe(
47486
47664
  "Revision token returned by get-note-content for this exact ID. The mutation stops if the note changed since that read."
47487
47665
  );
47666
+ var timeoutSecondsInput = external_exports.number().int().min(CALL_TIMEOUT_SECONDS.min).max(CALL_TIMEOUT_SECONDS.max).optional().describe(
47667
+ `Per-call timeout in seconds (${CALL_TIMEOUT_SECONDS.min}-${CALL_TIMEOUT_SECONDS.max}) for each Notes.app automation step this call runs, overriding APPLE_NOTES_MCP_TIMEOUT_MS. A timed-out write is uncertain, not failed: read the note by id before any retry.`
47668
+ );
47488
47669
  function readExactNoteSnapshot(id2) {
47489
47670
  const note = notesManager.getNoteById(id2);
47490
47671
  if (!note) return { error: `Note with ID "${id2}" not found` };
@@ -47498,6 +47679,7 @@ function readExactNoteSnapshot(id2) {
47498
47679
  const rich = enrichNoteRead(id2, body);
47499
47680
  return { note, body, rich, contentHash: richContentHash(body, rich) };
47500
47681
  }
47682
+ var NOT_DELETED_MESSAGE = "Notes.app accepted the delete, but the note is still in its original folder, so it was not moved to Recently Deleted. Nothing was deleted; read the note again before retrying.";
47501
47683
  function revisionConflictMessage(title) {
47502
47684
  return `Note "${title}" changed after it was read. Read it again and review the newer version before retrying.`;
47503
47685
  }
@@ -47521,11 +47703,17 @@ registerTool(
47521
47703
  description: "Use when: the user wants to create a brand-new Apple Note.\nReturns: the new note's title and id \u2014 reuse the id for follow-up reads/edits.\nDo not use when: editing an existing note (use update-note).\nNote: the title is prepended as an <h1>; true Apple Notes checklists cannot be created via AppleScript (see the content field). A 'folder' must already exist \u2014 create-folder first (it is idempotent), since this tool does not create it.",
47522
47704
  inputSchema: {
47523
47705
  title: external_exports.string().min(1, "Title is required").max(MAX.TITLE),
47524
- content: external_exports.string().min(1, "Content is required").max(MAX.CONTENT).describe(
47525
- 'Note body. AppleScript cannot create true Apple Notes checklists \u2014 `<input type="checkbox">`, checklist CSS classes, and markdown `- [ ]` lines do not render as checkable items. To produce a checklist, create the note with a plain `<ul>` or `- ` list and convert it in Notes.app with \u21E7\u2318L.'
47706
+ content: external_exports.string().min(1, "Content is required").max(MAX.CONTENT).optional().describe(
47707
+ 'Note body; required unless contentPath is given (pass exactly one). AppleScript cannot create true Apple Notes checklists \u2014 `<input type="checkbox">`, checklist CSS classes, and markdown `- [ ]` lines do not render as checkable items. To produce a checklist, create the note with a plain `<ul>` or `- ` list and convert it in Notes.app with \u21E7\u2318L.'
47708
+ ),
47709
+ contentPath: external_exports.string().min(1).max(MAX.SAVE_PATH).optional().describe(
47710
+ `Absolute path of a local UTF-8 file to use as the body instead of content (pass exactly one). The same locations save-attachment may write to are allowed (home, temp, /Volumes); symbolic links and non-regular files are refused. Limit ${MAX_CONTENT_FILE_BYTES} bytes.`
47526
47711
  ),
47527
47712
  format: external_exports.enum(["plaintext", "html", "markdown"]).optional().default("plaintext").describe(
47528
- "Content format: 'plaintext' (default), 'html' for rich formatting, or 'markdown' for real Title/Heading/Subheading styles through the Create Markdown Note Shortcut (iCloud only; see get-capabilities)"
47713
+ "Content format: 'plaintext' (default), 'html' for rich formatting, or 'markdown'. Markdown whose first line is exactly `# <title>` (same case and spacing) has that line and one blank line after it removed, since the title is supplied separately."
47714
+ ),
47715
+ markdownRoute: external_exports.enum(["shortcut", "html"]).optional().default("shortcut").describe(
47716
+ "How format 'markdown' is imported. 'shortcut' (default) uses the Create Markdown Note Shortcut for real Title/Heading/Subheading styles (iCloud only, no tags; see get-capabilities). 'html' is the fallback when that Shortcut is not installed or the note is outside iCloud: it converts the same bounded Markdown subset to HTML and creates the note through AppleScript in any account, rendering `- [ ]` / `- [x]` task items as ordinary list rows that start with a visible \u2610 / \u2611 character \u2014 not native, checkable checklist items."
47529
47717
  ),
47530
47718
  tags: external_exports.array(external_exports.string().max(MAX.TAG)).max(MAX.TAGS).optional().describe(
47531
47719
  "Returned-only metadata \u2014 NOT written to Notes.app. Apple Notes tags can't be set via AppleScript, so any values passed here are echoed back in the response but do not appear on the created note. Use #hashtags in the body for searchable text; this does not create native tag objects. Native tags need the Notes Shortcuts action. Refused with format 'markdown': add tags afterwards with add-native-tags."
@@ -47535,7 +47723,8 @@ registerTool(
47535
47723
  ),
47536
47724
  account: external_exports.string().max(MAX.ACCOUNT).optional().describe(
47537
47725
  "Account name (defaults to the account Notes.app itself reports as default). Matched exactly, or by a unique prefix; an ambiguous prefix is refused. Must be an account Notes.app already has configured \u2014 see list-accounts."
47538
- )
47726
+ ),
47727
+ timeoutSeconds: timeoutSecondsInput
47539
47728
  },
47540
47729
  outputSchema: {
47541
47730
  ok: external_exports.boolean().optional(),
@@ -47544,10 +47733,66 @@ registerTool(
47544
47733
  folder: external_exports.string().optional(),
47545
47734
  account: external_exports.string().optional(),
47546
47735
  contentHash: external_exports.string().optional(),
47547
- verified: external_exports.boolean().optional()
47736
+ verified: external_exports.boolean().optional(),
47737
+ strippedDuplicateTitle: external_exports.boolean().optional(),
47738
+ taskItemsRendered: external_exports.number().optional()
47548
47739
  }
47549
47740
  },
47550
- withErrorHandling(({ title, content, format = "plaintext", tags = [], folder, account }) => {
47741
+ withErrorHandling((params) => {
47742
+ const {
47743
+ title,
47744
+ contentPath,
47745
+ format = "plaintext",
47746
+ markdownRoute = "shortcut",
47747
+ tags = [],
47748
+ folder,
47749
+ account
47750
+ } = params;
47751
+ if (params.content === void 0 === (contentPath === void 0))
47752
+ return errorResponse("Provide exactly one of content or contentPath");
47753
+ let content = params.content ?? readAllowedTextFile(contentPath, MAX_CONTENT_FILE_BYTES);
47754
+ if (format !== "markdown" && markdownRoute !== "shortcut")
47755
+ return errorResponse('markdownRoute applies to format "markdown" only');
47756
+ let strippedDuplicateTitle = false;
47757
+ if (format === "markdown") {
47758
+ const stripped = stripDuplicateTitleHeading(content, title);
47759
+ strippedDuplicateTitle = stripped.stripped;
47760
+ content = stripped.content;
47761
+ if (!content.trim())
47762
+ return errorResponse(
47763
+ "The Markdown holds only the title heading; add body content, or use format 'plaintext' for a title-only note"
47764
+ );
47765
+ }
47766
+ const titleNote = strippedDuplicateTitle ? { strippedDuplicateTitle: true } : {};
47767
+ if (format === "markdown" && markdownRoute === "html") {
47768
+ const taskItemsRendered = countTaskItems(content);
47769
+ const html = appendMarkdownHtml(content, { taskGlyphs: true });
47770
+ const note2 = notesManager.createNote(title, html, tags, folder, account, "html");
47771
+ if (!note2)
47772
+ return errorResponse(
47773
+ `Failed to create note "${title}". Check that the folder and account exist (list-folders, list-accounts) and that this server has Automation access (run the doctor tool).`
47774
+ );
47775
+ const createdBody2 = notesManager.getNoteContentById(note2.id);
47776
+ if (!notesManager.getNoteById(note2.id) || !createdBody2)
47777
+ return errorResponse(
47778
+ `A note may have been created, but its exact ID could not be verified. Do not retry automatically. Returned ID: ${note2.id}`
47779
+ );
47780
+ const glyphNote = taskItemsRendered ? ` ${taskItemsRendered} task item(s) were rendered as visible \u2610 / \u2611 text, not native checklist items.` : "";
47781
+ return successResponse(
47782
+ `Note created from Markdown: "${note2.title}" [id: ${note2.id}]${glyphNote}`,
47783
+ {
47784
+ ok: true,
47785
+ id: note2.id,
47786
+ title: note2.title,
47787
+ folder,
47788
+ account,
47789
+ contentHash: richContentHash(createdBody2, enrichNoteRead(note2.id, createdBody2)),
47790
+ verified: true,
47791
+ taskItemsRendered,
47792
+ ...titleNote
47793
+ }
47794
+ );
47795
+ }
47551
47796
  if (format === "markdown") {
47552
47797
  if (account)
47553
47798
  return errorResponse(
@@ -47559,7 +47804,10 @@ registerTool(
47559
47804
  );
47560
47805
  requireValidated("create-note-markdown");
47561
47806
  const result = createMarkdownNote(notesManager, { title, content, folder });
47562
- return successResponse(`Note created from Markdown: "${title}" [id: ${result.id}]`, result);
47807
+ return successResponse(`Note created from Markdown: "${title}" [id: ${result.id}]`, {
47808
+ ...result,
47809
+ ...titleNote
47810
+ });
47563
47811
  }
47564
47812
  const note = notesManager.createNote(title, content, tags, folder, account, format);
47565
47813
  if (!note) {
@@ -47705,7 +47953,7 @@ registerTool(
47705
47953
  }
47706
47954
  throw error2;
47707
47955
  }
47708
- const scope = `scanned ${result.scanned} of ${result.eligible} notes` + (result.scanTruncated ? `, the most recent ${result.scanLimit}; pass a higher scanLimit to include older notes` : "");
47956
+ const scope2 = `scanned ${result.scanned} of ${result.eligible} notes` + (result.scanTruncated ? `, the most recent ${result.scanLimit}; pass a higher scanLimit to include older notes` : "");
47709
47957
  const notes = [];
47710
47958
  if (result.truncated) {
47711
47959
  notes.push(
@@ -47721,7 +47969,7 @@ registerTool(
47721
47969
 
47722
47970
  ${notes.join("\n")}` : "";
47723
47971
  if (result.count === 0) {
47724
- return successResponse(`No notes matched (${scope}).${footer}`, { ...result });
47972
+ return successResponse(`No notes matched (${scope2}).${footer}`, { ...result });
47725
47973
  }
47726
47974
  const lines = result.notes.map((n) => {
47727
47975
  const where = [n.account, n.folder].filter(Boolean).join(" / ");
@@ -47730,7 +47978,7 @@ ${notes.join("\n")}` : "";
47730
47978
  return ` - ${n.title}${where ? ` (${where})` : ""}${n.locked ? " [locked]" : ""} [id: ${n.id}]${snippet}`;
47731
47979
  }).join("\n");
47732
47980
  return successResponse(
47733
- `Found ${result.matched} matching notes (${scope}):
47981
+ `Found ${result.matched} matching notes (${scope2}):
47734
47982
  ${lines}${footer}`,
47735
47983
  { ...result }
47736
47984
  );
@@ -48277,7 +48525,8 @@ registerTool(
48277
48525
  newContent: external_exports.string().min(1, "New content is required").max(MAX.CONTENT).describe(
48278
48526
  "New note body. AppleScript cannot produce true Apple Notes checklists; checkbox inputs and `- [ ]` markdown do not render as checkable items. Use a plain list and convert in Notes.app with \u21E7\u2318L."
48279
48527
  ),
48280
- format: external_exports.enum(["plaintext", "html"]).optional().default("plaintext").describe("Content format: 'plaintext' (default) or 'html' for rich formatting")
48528
+ format: external_exports.enum(["plaintext", "html"]).optional().default("plaintext").describe("Content format: 'plaintext' (default) or 'html' for rich formatting"),
48529
+ timeoutSeconds: timeoutSecondsInput
48281
48530
  },
48282
48531
  outputSchema: {
48283
48532
  ok: external_exports.boolean().optional(),
@@ -48488,10 +48737,11 @@ registerTool(
48488
48737
  content: external_exports.string().min(1, "Content to append is required").max(MAX.CONTENT).describe("Text to append to the note body"),
48489
48738
  scopeText: external_exports.string().min(12).max(500).optional().describe("Existing unique phrase required for native append to protected notes"),
48490
48739
  position: external_exports.enum(["after", "before"]).optional().default("after").describe(
48491
- "Where to insert: 'after' appends to the end (default), 'before' prepends to the start"
48740
+ "Where to insert: 'after' appends to the end (default); 'before' inserts directly below the note's title line, so the title stays first"
48492
48741
  ),
48493
48742
  separator: external_exports.string().max(20).optional().default("\n\n").describe("String placed between existing content and new content (default: two newlines)"),
48494
- format: external_exports.enum(["plaintext", "html"]).optional().default("plaintext").describe("Format of the content being appended: 'plaintext' (default) or 'html'")
48743
+ format: external_exports.enum(["plaintext", "html"]).optional().default("plaintext").describe("Format of the content being appended: 'plaintext' (default) or 'html'"),
48744
+ timeoutSeconds: timeoutSecondsInput
48495
48745
  },
48496
48746
  outputSchema: {
48497
48747
  ok: external_exports.boolean().optional(),
@@ -48580,7 +48830,8 @@ registerTool(
48580
48830
  description: "Use when: moving one exact note to Recently Deleted after reading and reviewing it.\nReturns: confirmation with the exact id.\nDo not use when: you only have a title or the note changed since review.\nSafety: requires id and expectedContentHash from get-note-content. The body comparison and delete happen in one AppleScript, so a newer edit is preserved.",
48581
48831
  inputSchema: {
48582
48832
  id: noteIdInput,
48583
- expectedContentHash: expectedContentHashInput
48833
+ expectedContentHash: expectedContentHashInput,
48834
+ timeoutSeconds: timeoutSecondsInput
48584
48835
  },
48585
48836
  outputSchema: {
48586
48837
  ok: external_exports.boolean().optional(),
@@ -48600,6 +48851,9 @@ registerTool(
48600
48851
  if (result.status === "conflict") {
48601
48852
  return errorResponse(revisionConflictMessage(snapshot.note.title));
48602
48853
  }
48854
+ if (result.status === "not-deleted") {
48855
+ return errorResponse(NOT_DELETED_MESSAGE);
48856
+ }
48603
48857
  if (result.status !== "deleted") {
48604
48858
  return errorResponse(
48605
48859
  `The delete result for note "${snapshot.note.title}" is uncertain. Inspect exact ID ${id2} before retrying.`
@@ -48625,7 +48879,8 @@ registerTool(
48625
48879
  inputSchema: {
48626
48880
  id: noteIdInput,
48627
48881
  folder: external_exports.string().min(1, "Destination folder is required").max(MAX.FOLDER),
48628
- account: external_exports.string().max(MAX.ACCOUNT).optional().describe("Account containing the note/folder")
48882
+ account: external_exports.string().max(MAX.ACCOUNT).optional().describe("Account containing the note/folder"),
48883
+ timeoutSeconds: timeoutSecondsInput
48629
48884
  },
48630
48885
  outputSchema: {
48631
48886
  ok: external_exports.boolean().optional(),
@@ -49188,6 +49443,8 @@ registerTool(
49188
49443
  if (result.status === "conflict") {
49189
49444
  return { id: id2, success: false, error: revisionConflictMessage(snapshot.note.title) };
49190
49445
  }
49446
+ if (result.status === "not-deleted")
49447
+ return { id: id2, success: false, error: NOT_DELETED_MESSAGE };
49191
49448
  return { id: id2, success: false, error: "Delete result uncertain; inspect this exact ID" };
49192
49449
  });
49193
49450
  const succeeded = results.filter((r) => r.success).length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.8.30",
3
+ "version": "2.8.32",
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",