apple-notes-mcp 2.8.42 → 2.8.44

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 +35 -2
  2. package/build/index.js +311 -42
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -420,7 +420,7 @@ Searches for notes by title or content.
420
420
  | Parameter | Type | Required | Description |
421
421
  |-----------|------|----------|-------------|
422
422
  | `query` | string | Yes | Text to search for |
423
- | `searchContent` | boolean | No | If `true`, searches note body; if `false` (default), searches titles only |
423
+ | `searchContent` | boolean | No | If `true`, searches note content (title line included); if `false` (default), searches titles only. With Full Disk Access, content search reads the Notes database (well under a second, the 5000 most recently modified notes, Recently Deleted excluded); without it, it falls back to AppleScript, which scans every body and can time out on a broad term |
424
424
  | `account` | string | No | Account to search in (defaults to Notes.app's default account; exact or unique-prefix match) |
425
425
  | `folder` | string | No | Limit search to a specific folder (supports nested paths like `"Work/Clients"`) |
426
426
  | `modifiedSince` | string | No | ISO 8601 date string to filter notes modified on or after this date (e.g., `"2025-01-01"`) |
@@ -451,7 +451,7 @@ Searches for notes by title or content.
451
451
  }
452
452
  ```
453
453
 
454
- **Returns:** List of matching notes with titles, folder names, and IDs. Use the returned ID for subsequent operations like `get-note-content`, `update-note`, etc.
454
+ **Returns:** List of matching notes with titles, folder names, and IDs. Use the returned ID for subsequent operations like `get-note-content`, `update-note`, etc. A content search also returns `source` (`"database"` or `"applescript"`), and `scanTruncated` when the database path left older notes unsearched.
455
455
 
456
456
  ---
457
457
 
@@ -763,6 +763,7 @@ Updates an existing note's content and/or title.
763
763
  | `newContent` | string | Yes | New content for the note body |
764
764
  | `format` | string | No | Content format: `"plaintext"` (default) or `"html"`. When `"html"`, content replaces the entire note body as raw HTML and `newTitle` is ignored (the first HTML element serves as the title) |
765
765
  | `allowLinkChanges` | boolean | No | Set to `true` only when intentionally changing or removing existing links |
766
+ | `ifFolderId`, `ifAncestorFolderId`, `forbiddenAncestorFolderIds` | string, string, string[] | No | Folder preconditions; see [Folder scope guards](#folder-scope-guards) |
766
767
 
767
768
  Title-only updates are rejected because Apple Notes titles are not unique.
768
769
 
@@ -807,6 +808,7 @@ Deletes a note (moves to Recently Deleted in Notes.app).
807
808
  |-----------|------|----------|-------------|
808
809
  | `id` | string | Yes | Exact CoreData note ID returned by a read or search |
809
810
  | `expectedContentHash` | string | Yes | `contentHash` from the exact note version being deleted |
811
+ | `ifFolderId`, `ifAncestorFolderId`, `forbiddenAncestorFolderIds` | string, string, string[] | No | Folder preconditions; see [Folder scope guards](#folder-scope-guards) |
810
812
 
811
813
  Title-only deletion is rejected. If the note changed after the supplied hash
812
814
  was read, deletion is also rejected.
@@ -842,6 +844,7 @@ Moves a note to a different folder. The note is relocated in place via Notes.app
842
844
  |-----------|------|----------|-------------|
843
845
  | `id` | string | Yes | Exact CoreData note ID returned by a read or search |
844
846
  | `folder` | string | Yes | Destination folder name or nested path (e.g., `"Work/Clients"`) |
847
+ | `ifFolderId`, `ifAncestorFolderId`, `forbiddenAncestorFolderIds` | string, string, string[] | No | Folder preconditions; see [Folder scope guards](#folder-scope-guards) |
845
848
 
846
849
  Title-only moves are rejected.
847
850
 
@@ -858,6 +861,35 @@ actual destination folder ID matches the requested folder.
858
861
 
859
862
  ---
860
863
 
864
+ #### Folder scope guards
865
+
866
+ `update-note`, `append-to-note`, `delete-note`, and `move-note` accept three
867
+ optional folder preconditions. Use exact folder ids from `list-folders`.
868
+
869
+ - `ifFolderId`: the note must currently be in exactly this folder.
870
+ - `ifAncestorFolderId`: the note must be inside this folder or any of its
871
+ subfolders.
872
+ - `forbiddenAncestorFolderIds` (up to 50): the note must not be inside any of
873
+ these folders or their subfolders. For `move-note`, the destination must not
874
+ be either.
875
+
876
+ The checks read Notes.app's live folders inside the same AppleScript as the
877
+ write, immediately before it, so a note moved after you reviewed it is left
878
+ alone and the call fails with `Scope guard failed: …`. The one exception is a
879
+ native append to a protected note (it runs through Shortcuts): there the check
880
+ is a separate read just before the append, so it is not atomic.
881
+
882
+ **Example - retire a note only while it is still in the inbox:**
883
+ ```json
884
+ {
885
+ "id": "x-coredata://ABC123/ICNote/p456",
886
+ "expectedContentHash": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
887
+ "ifFolderId": "x-coredata://ABC123/ICFolder/p12"
888
+ }
889
+ ```
890
+
891
+ ---
892
+
861
893
  #### `append-to-note`
862
894
 
863
895
  Appends or prepends content to an existing note without replacing it. Always reads and writes as HTML, preserving all existing rich formatting.
@@ -870,6 +902,7 @@ Appends or prepends content to an existing note without replacing it. Always rea
870
902
  | `position` | string | No | `"after"` (default) appends to the end; `"before"` inserts directly below the note's title line, so the title stays first |
871
903
  | `separator` | string | No | String placed between existing content and new content (default: two newlines → `<div><br></div>` in HTML) |
872
904
  | `format` | string | No | Format of the content being appended: `"plaintext"` (default) or `"html"` |
905
+ | `ifFolderId`, `ifAncestorFolderId`, `forbiddenAncestorFolderIds` | string, string, string[] | No | Folder preconditions; see [Folder scope guards](#folder-scope-guards) |
873
906
 
874
907
  Title-only appends are rejected.
875
908
 
package/build/index.js CHANGED
@@ -42053,6 +42053,77 @@ function exportDrawingRaster(drawing, savePath, containerDir = NOTES_CONTAINER_D
42053
42053
  }
42054
42054
  }
42055
42055
 
42056
+ // src/utils/scopeGuard.ts
42057
+ var SCOPE_FOLDER_ID = /^x-coredata:\/\/[0-9a-f-]+\/ICFolder\/p\d+$/i;
42058
+ var MAX_FORBIDDEN_FOLDERS = 50;
42059
+ var SCOPE_MARKER = "SAFETY_SCOPE";
42060
+ function hasScopeGuard(guard) {
42061
+ return Boolean(
42062
+ guard && (guard.ifFolderId || guard.ifAncestorFolderId || guard.forbiddenAncestorFolderIds && guard.forbiddenAncestorFolderIds.length > 0)
42063
+ );
42064
+ }
42065
+ function validateScopeGuard(guard) {
42066
+ const ids = [
42067
+ guard.ifFolderId,
42068
+ guard.ifAncestorFolderId,
42069
+ ...guard.forbiddenAncestorFolderIds ?? []
42070
+ ].filter((value) => value !== void 0);
42071
+ for (const id2 of ids) {
42072
+ if (!SCOPE_FOLDER_ID.test(id2)) throw new Error(`Scope guard needs exact folder ids: ${id2}`);
42073
+ }
42074
+ if ((guard.forbiddenAncestorFolderIds?.length ?? 0) > MAX_FORBIDDEN_FOLDERS)
42075
+ throw new Error(`At most ${MAX_FORBIDDEN_FOLDERS} forbidden folder ids are allowed`);
42076
+ }
42077
+ function chainScript(chainVar, startExpr) {
42078
+ return `
42079
+ set ${chainVar} to {}
42080
+ set ${chainVar}Cursor to ${startExpr}
42081
+ repeat while class of ${chainVar}Cursor is folder
42082
+ set end of ${chainVar} to (id of ${chainVar}Cursor)
42083
+ set ${chainVar}Cursor to container of ${chainVar}Cursor
42084
+ end repeat`;
42085
+ }
42086
+ function idList(ids) {
42087
+ return `{${ids.map((id2) => `"${id2}"`).join(", ")}}`;
42088
+ }
42089
+ function buildScopeGuardScript(noteVar, guard, destinationVar) {
42090
+ if (!hasScopeGuard(guard)) return "";
42091
+ validateScopeGuard(guard);
42092
+ const forbidden = guard.forbiddenAncestorFolderIds ?? [];
42093
+ let script = `
42094
+ set scopeFolder to container of ${noteVar}
42095
+ if class of scopeFolder is not folder then return "${SCOPE_MARKER}:the note is not in a folder"`;
42096
+ if (guard.ifFolderId)
42097
+ script += `
42098
+ if (id of scopeFolder) is not "${guard.ifFolderId}" then return "${SCOPE_MARKER}:the note is not in the expected folder"`;
42099
+ if (guard.ifAncestorFolderId || forbidden.length > 0)
42100
+ script += chainScript("scopeChain", "scopeFolder");
42101
+ if (guard.ifAncestorFolderId)
42102
+ script += `
42103
+ if scopeChain does not contain "${guard.ifAncestorFolderId}" then return "${SCOPE_MARKER}:the note is not inside the expected ancestor folder"`;
42104
+ if (forbidden.length > 0) {
42105
+ script += `
42106
+ repeat with forbiddenId in ${idList(forbidden)}
42107
+ if scopeChain contains (contents of forbiddenId) then return "${SCOPE_MARKER}:the note is inside a forbidden folder"
42108
+ end repeat`;
42109
+ if (destinationVar) {
42110
+ script += chainScript("scopeDestChain", destinationVar);
42111
+ script += `
42112
+ repeat with forbiddenId in ${idList(forbidden)}
42113
+ if scopeDestChain contains (contents of forbiddenId) then return "${SCOPE_MARKER}:the destination is inside a forbidden folder"
42114
+ end repeat`;
42115
+ }
42116
+ }
42117
+ return script;
42118
+ }
42119
+ function parseScopeFailure(output) {
42120
+ const trimmed = output.trim();
42121
+ return trimmed.startsWith(`${SCOPE_MARKER}:`) ? trimmed.slice(SCOPE_MARKER.length + 1) : null;
42122
+ }
42123
+ function scopeConflictMessage(reason) {
42124
+ return `Scope guard failed: ${reason}. Nothing was changed; read the note's current folder and review before retrying.`;
42125
+ }
42126
+
42056
42127
  // src/services/appleNotesManager.ts
42057
42128
  var import_turndown = __toESM(require_turndown_cjs(), 1);
42058
42129
  import { existsSync as existsSync5 } from "fs";
@@ -42373,6 +42444,16 @@ var AppleNotesManager = class {
42373
42444
  }
42374
42445
  return this.defaultAccountName || void 0;
42375
42446
  }
42447
+ /**
42448
+ * The account a search with this `account` argument is scoped to: the
42449
+ * caller's account when named, else Notes.app's default account (cached).
42450
+ * Returns `undefined` when no account was named and the default cannot be
42451
+ * determined. Used by search-notes' database path so it searches the same
42452
+ * account the AppleScript path would.
42453
+ */
42454
+ searchAccountScope(account) {
42455
+ return this.reportedAccount(this.resolveAccount(account));
42456
+ }
42376
42457
  /**
42377
42458
  * Checks if a note is password-protected by its ID.
42378
42459
  *
@@ -42816,7 +42897,7 @@ var AppleNotesManager = class {
42816
42897
  * race that would exist if JavaScript checked the note and then issued a
42817
42898
  * separate unconditional `set body` command.
42818
42899
  */
42819
- updateNoteByIdIfUnchanged(id2, currentTitle, expectedBody, newTitle, newContent, format = "plaintext", expectedRichRevision) {
42900
+ updateNoteByIdIfUnchanged(id2, currentTitle, expectedBody, newTitle, newContent, format = "plaintext", expectedRichRevision, scope2) {
42820
42901
  const safeId = sanitizeNoteId(id2);
42821
42902
  if (expectedRichRevision) {
42822
42903
  const rich = readRichNote(id2);
@@ -42837,7 +42918,7 @@ var AppleNotesManager = class {
42837
42918
  const safeExpectedBody = escapeHtmlForAppleScript(expectedBody);
42838
42919
  const safeWrittenBody = escapeHtmlForAppleScript(writtenBody);
42839
42920
  const script = buildAppLevelScript(`
42840
- set noteRef to note id "${safeId}"
42921
+ set noteRef to note id "${safeId}"${buildScopeGuardScript("noteRef", scope2)}
42841
42922
  if (count of attachments of noteRef) is greater than 0 then return "SAFETY_ATTACHMENTS"
42842
42923
  set currentBody to body of noteRef
42843
42924
  considering case
@@ -42851,6 +42932,8 @@ var AppleNotesManager = class {
42851
42932
  console.error(`Failed guarded update for note ID "${id2}":`, result.error);
42852
42933
  return { status: "failed" };
42853
42934
  }
42935
+ const updateScopeFailure = parseScopeFailure(result.output);
42936
+ if (updateScopeFailure) return { status: "scope_conflict", reason: updateScopeFailure };
42854
42937
  const status = result.output.trim();
42855
42938
  if (status === "SAFETY_CONFLICT") return { status: "conflict" };
42856
42939
  if (status === "SAFETY_ATTACHMENTS") return { status: "attachments" };
@@ -42862,12 +42945,12 @@ var AppleNotesManager = class {
42862
42945
  * the caller reviewed. The comparison and delete are one AppleScript action,
42863
42946
  * so a concurrent edit cannot slip between the guard and deletion.
42864
42947
  */
42865
- deleteNoteByIdIfUnchanged(id2, expectedBody) {
42948
+ deleteNoteByIdIfUnchanged(id2, expectedBody, scope2) {
42866
42949
  const safeId = sanitizeNoteId(id2);
42867
42950
  validateLength(expectedBody, MAX_CONTENT_LENGTH, "Expected note content");
42868
42951
  const safeExpectedBody = escapeHtmlForAppleScript(expectedBody);
42869
42952
  const script = buildAppLevelScript(`
42870
- set noteRef to note id "${safeId}"
42953
+ set noteRef to note id "${safeId}"${buildScopeGuardScript("noteRef", scope2)}
42871
42954
  set originalFolder to missing value
42872
42955
  try
42873
42956
  set originalFolder to container of noteRef
@@ -42899,12 +42982,31 @@ var AppleNotesManager = class {
42899
42982
  console.error(`Failed guarded delete for note ID "${id2}":`, result.error);
42900
42983
  return { status: "failed" };
42901
42984
  }
42985
+ const deleteScopeFailure = parseScopeFailure(result.output);
42986
+ if (deleteScopeFailure) return { status: "scope_conflict", reason: deleteScopeFailure };
42902
42987
  const status = result.output.trim();
42903
42988
  if (status === "SAFETY_CONFLICT") return { status: "conflict" };
42904
42989
  if (status === "SAFETY_IN_RECENTLY_DELETED") return { status: "in-recently-deleted" };
42905
42990
  if (status === "SAFETY_NOT_DELETED") return { status: "not-deleted" };
42906
42991
  return status === "SAFETY_DELETED" ? { status: "deleted" } : { status: "failed" };
42907
42992
  }
42993
+ /**
42994
+ * Read-only scope pre-check for writes that do not run as AppleScript (the
42995
+ * native Shortcuts operations). Runs the same checks the AppleScript writes
42996
+ * embed, but in a separate script, so it is not atomic with the write.
42997
+ *
42998
+ * @returns null when every precondition holds, otherwise the failure reason
42999
+ */
43000
+ checkNoteScope(id2, scope2) {
43001
+ const safeId = sanitizeNoteId(id2);
43002
+ const result = executeAppleScript(
43003
+ buildAppLevelScript(`
43004
+ set noteRef to note id "${safeId}"${buildScopeGuardScript("noteRef", scope2)}
43005
+ return "SCOPE_OK"`)
43006
+ );
43007
+ if (!result.success) return result.error || "the note's folder could not be read";
43008
+ return parseScopeFailure(result.output);
43009
+ }
42908
43010
  /**
42909
43011
  * Builds the AppleScript body for a bulk note listing.
42910
43012
  *
@@ -43491,14 +43593,14 @@ var AppleNotesManager = class {
43491
43593
  * @param account - Account containing the destination folder (defaults to Notes.app's default account)
43492
43594
  * @returns true if the move succeeded, false otherwise
43493
43595
  */
43494
- moveNoteById(id2, destinationFolder, account) {
43596
+ moveNoteById(id2, destinationFolder, account, scope2) {
43495
43597
  const targetAccount = this.resolveAccount(account);
43496
43598
  const safeId = sanitizeNoteId(id2);
43497
43599
  const destFolderRef = `${buildFolderReference(destinationFolder)} of ${AS_ACCOUNT_REF}`;
43498
43600
  const moveCommand = `
43499
43601
  ${buildAccountResolution(targetAccount)}
43500
43602
  set destFolder to ${destFolderRef}
43501
- set noteRef to note id "${safeId}"
43603
+ set noteRef to note id "${safeId}"${buildScopeGuardScript("noteRef", scope2, "destFolder")}
43502
43604
  move noteRef to destFolder
43503
43605
  set movedNoteRef to note id "${safeId}"
43504
43606
  set actualFolder to container of movedNoteRef
@@ -43515,6 +43617,8 @@ var AppleNotesManager = class {
43515
43617
  );
43516
43618
  return false;
43517
43619
  }
43620
+ const scopeFailure = parseScopeFailure(result.output);
43621
+ if (scopeFailure) throw new Error(scopeConflictMessage(scopeFailure));
43518
43622
  if (result.output.trim() !== "SAFETY_MOVED") {
43519
43623
  console.error(
43520
43624
  `Move result for note ID "${id2}" did not verify destination "${destinationFolder}"`
@@ -44416,12 +44520,12 @@ var AppleNotesManager = class {
44416
44520
  }
44417
44521
  });
44418
44522
  if (runnable.length > 0) {
44419
- const idList = runnable.map((r) => `"${r.safe}"`).join(", ");
44523
+ const idList2 = runnable.map((r) => `"${r.safe}"`).join(", ");
44420
44524
  const script = buildAppLevelScript(`
44421
44525
  ${buildAccountResolution(targetAccount)}
44422
44526
  set destFolder to ${destFolderRef}
44423
44527
  set out to ""
44424
- repeat with rawId in {${idList}}
44528
+ repeat with rawId in {${idList2}}
44425
44529
  set theId to (rawId as text)
44426
44530
  set noteRef to missing value
44427
44531
  try
@@ -45681,6 +45785,13 @@ function describeSearchScope(searchContent, resultCount) {
45681
45785
  return "\n\n\u2139\uFE0F Only note titles were searched, so a term that appears in note bodies would not match. Retry with `searchContent: true` to search bodies instead.";
45682
45786
  }
45683
45787
 
45788
+ // src/utils/noteQueryStore.ts
45789
+ import { execFileSync as execFileSync13 } from "child_process";
45790
+ import * as fs8 from "fs";
45791
+ import * as os8 from "os";
45792
+ import * as path8 from "path";
45793
+ import { gunzipSync as gunzipSync7 } from "zlib";
45794
+
45684
45795
  // src/utils/noteQuery.ts
45685
45796
  var QUERY_LIMITS = {
45686
45797
  /** Maximum number of tokens (terms, operators, parentheses). */
@@ -46127,11 +46238,6 @@ function positiveTextTerms(node, negated = false) {
46127
46238
  }
46128
46239
 
46129
46240
  // src/utils/noteQueryStore.ts
46130
- import { execFileSync as execFileSync13 } from "child_process";
46131
- import * as fs8 from "fs";
46132
- import * as os8 from "os";
46133
- import * as path8 from "path";
46134
- import { gunzipSync as gunzipSync7 } from "zlib";
46135
46241
  var NOTES_DB_PATH9 = path8.join(
46136
46242
  os8.homedir(),
46137
46243
  "Library/Group Containers/group.com.apple.notes/NoteStore.sqlite"
@@ -46327,7 +46433,13 @@ function buildSnippet(text2, terms) {
46327
46433
  }
46328
46434
  function queryNotes(expression, options = {}) {
46329
46435
  const ast = parseNoteQuery(expression);
46330
- const limit = Math.min(options.limit ?? QUERY_RESULTS.DEFAULT, QUERY_RESULTS.MAX);
46436
+ return runNoteQuery(ast, {
46437
+ ...options,
46438
+ limit: Math.min(options.limit ?? QUERY_RESULTS.DEFAULT, QUERY_RESULTS.MAX)
46439
+ });
46440
+ }
46441
+ function runNoteQuery(ast, options = {}) {
46442
+ const limit = options.limit ?? QUERY_RESULTS.DEFAULT;
46331
46443
  const scanLimit = Math.min(options.scanLimit ?? QUERY_SCAN.DEFAULT, QUERY_SCAN.MAX);
46332
46444
  const includeDeleted = options.includeDeleted ?? false;
46333
46445
  const withBodies = needsContent(ast);
@@ -46464,6 +46576,66 @@ function queryNotes(expression, options = {}) {
46464
46576
  };
46465
46577
  }
46466
46578
 
46579
+ // src/utils/searchContentDb.ts
46580
+ function buildSearchContentQuery(options) {
46581
+ const children = [{ type: "text", field: "any", value: options.query }];
46582
+ const folder = options.folder?.trim();
46583
+ if (folder) children.push({ type: "folder", value: folder });
46584
+ const account = options.account?.trim();
46585
+ if (account) children.push({ type: "account", value: account });
46586
+ if (options.modifiedSince) {
46587
+ const start = new Date(options.modifiedSince).getTime();
46588
+ if (Number.isFinite(start)) {
46589
+ children.push({
46590
+ type: "date",
46591
+ field: "modified",
46592
+ op: ">=",
46593
+ date: options.modifiedSince,
46594
+ start,
46595
+ end: start + 24 * 60 * 60 * 1e3
46596
+ });
46597
+ }
46598
+ }
46599
+ return children.length === 1 ? children[0] : { type: "and", children };
46600
+ }
46601
+ function searchContentViaDatabase(options) {
46602
+ const result = runNoteQuery(buildSearchContentQuery(options), {
46603
+ limit: options.limit,
46604
+ scanLimit: QUERY_SCAN.MAX,
46605
+ dbPath: options.dbPath
46606
+ });
46607
+ const notes = result.notes.map((hit) => ({
46608
+ id: hit.id,
46609
+ title: hit.title,
46610
+ content: "",
46611
+ tags: [],
46612
+ created: hit.created ? new Date(hit.created) : /* @__PURE__ */ new Date(0),
46613
+ modified: hit.modified ? new Date(hit.modified) : /* @__PURE__ */ new Date(0),
46614
+ ...hit.folder !== void 0 ? { folder: hit.folder } : {},
46615
+ ...hit.account !== void 0 ? { account: hit.account } : {}
46616
+ }));
46617
+ return {
46618
+ notes,
46619
+ scan: {
46620
+ scanned: result.scanned,
46621
+ eligible: result.eligible,
46622
+ scanTruncated: result.scanTruncated,
46623
+ matched: result.matched
46624
+ }
46625
+ };
46626
+ }
46627
+ function describeContentScan(scan) {
46628
+ if (!scan?.scanTruncated) return "";
46629
+ return `
46630
+
46631
+ \u2139\uFE0F Searched the ${scan.scanned} most recently modified of ${scan.eligible} notes; older notes were not searched. A title search (\`searchContent: false\`) covers every note.`;
46632
+ }
46633
+ function contentSearchFailureHint(message, dbUnavailable) {
46634
+ if (!/timed out/i.test(message)) return message;
46635
+ const remedy = dbUnavailable === "no_fda" ? " Grant Full Disk Access to the app that launches this server (run the doctor tool) so search-notes can search note bodies through the Notes database instead, which takes well under a second." : "";
46636
+ return `${message} Body search through AppleScript scans every note body before the result limit applies, so a broad term can exceed the time budget on a large library.${remedy} Otherwise narrow the search with \`folder\` or \`modifiedSince\`, or use a more specific term.`;
46637
+ }
46638
+
46467
46639
  // src/tools/doctor.ts
46468
46640
  import { spawnSync } from "child_process";
46469
46641
 
@@ -50801,6 +50973,36 @@ function noteIdentifiers(id2) {
50801
50973
  var expectedContentHashInput = external_exports.string().regex(/^sha256:[a-f0-9]{64}$/, "expectedContentHash must come from get-note-content").describe(
50802
50974
  "Revision token returned by get-note-content for this exact ID. The mutation stops if the note changed since that read."
50803
50975
  );
50976
+ var SCOPE_FOLDER_ID_MESSAGE = "An exact folder id is required: x-coredata://.../ICFolder/p..., Notes UUID, or numeric key (from list-folders)";
50977
+ var scopeFolderIdInput = exactIdInput("ICFolder", SCOPE_FOLDER_ID, SCOPE_FOLDER_ID_MESSAGE, {
50978
+ maxLength: MAX.ID
50979
+ });
50980
+ var scopeGuardInputs = {
50981
+ ifFolderId: scopeFolderIdInput.optional().describe(
50982
+ "Precondition: the note must currently be in exactly this folder (id from list-folders). Re-checked immediately before the write."
50983
+ ),
50984
+ ifAncestorFolderId: scopeFolderIdInput.optional().describe(
50985
+ "Precondition: the note must be inside this folder or any of its subfolders. Re-checked immediately before the write."
50986
+ ),
50987
+ forbiddenAncestorFolderIds: exactIdArrayInput(
50988
+ "ICFolder",
50989
+ SCOPE_FOLDER_ID,
50990
+ SCOPE_FOLDER_ID_MESSAGE,
50991
+ {
50992
+ maxLength: MAX.ID,
50993
+ maxItems: MAX_FORBIDDEN_FOLDERS
50994
+ }
50995
+ ).optional().describe(
50996
+ "Precondition: the note must not be inside any of these folders or their subfolders (for move-note, neither may the destination). Re-checked immediately before the write."
50997
+ )
50998
+ };
50999
+ function scopeFrom(args) {
51000
+ return {
51001
+ ifFolderId: args.ifFolderId,
51002
+ ifAncestorFolderId: args.ifAncestorFolderId,
51003
+ forbiddenAncestorFolderIds: args.forbiddenAncestorFolderIds
51004
+ };
51005
+ }
50804
51006
  var timeoutSecondsInput = external_exports.number().int().min(CALL_TIMEOUT_SECONDS.min).max(CALL_TIMEOUT_SECONDS.max).optional().describe(
50805
51007
  `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.`
50806
51008
  );
@@ -50981,10 +51183,12 @@ registerTool(
50981
51183
  registerTool(
50982
51184
  "search-notes",
50983
51185
  {
50984
- description: "Use when: finding notes by a keyword in the title (or body with searchContent=true) and you need their ids.\nReturns: matching notes with title, folder, and id.\nDo not use when: you already have a note id (use get-note-content) or want every note (use list-notes).\nPrefer this first to obtain ids for subsequent read/update/delete/move calls.",
51186
+ description: "Use when: finding notes by a keyword in the title (or body with searchContent=true) and you need their ids.\nReturns: matching notes with title, folder, and id; `source` says whether a body search read the Notes database or fell back to AppleScript.\nDo not use when: you already have a note id (use get-note-content), want every note (use list-notes), or need boolean or metadata filters (use query-notes).\nPrefer this first to obtain ids for subsequent read/update/delete/move calls.\nNote: with Full Disk Access, body search reads the Notes database (fast; the most recent 5000 notes, Recently Deleted excluded); without it, it falls back to AppleScript, which scans every body and can time out on broad terms.",
50985
51187
  inputSchema: {
50986
51188
  query: external_exports.string().min(1, "Search query is required").max(MAX.QUERY),
50987
- searchContent: external_exports.boolean().optional().describe("Search note content instead of titles"),
51189
+ searchContent: external_exports.boolean().optional().describe(
51190
+ "Search note content (title line included) instead of titles. Uses the Notes database when Full Disk Access is available, else AppleScript"
51191
+ ),
50988
51192
  account: external_exports.string().max(MAX.ACCOUNT).optional().describe("Account to search in"),
50989
51193
  folder: external_exports.string().max(MAX.FOLDER).optional().describe("Limit search to a specific folder"),
50990
51194
  modifiedSince: external_exports.string().max(64).optional().describe(
@@ -50996,21 +51200,59 @@ registerTool(
50996
51200
  },
50997
51201
  outputSchema: {
50998
51202
  notes: external_exports.array(external_exports.object({}).passthrough()).optional(),
50999
- count: external_exports.number().optional()
51203
+ count: external_exports.number().optional(),
51204
+ source: external_exports.enum(["database", "applescript"]).optional(),
51205
+ scanTruncated: external_exports.boolean().optional()
51000
51206
  }
51001
51207
  },
51002
51208
  withErrorHandling(({ query: query2, searchContent = false, account, folder, modifiedSince, limit }) => {
51003
51209
  const effectiveLimit = resolveSearchLimit(limit);
51004
51210
  const limitWasDefault = limit === void 0;
51211
+ let source;
51212
+ let dbScan;
51213
+ let dbUnavailable;
51214
+ const runSearch = () => {
51215
+ if (searchContent) {
51216
+ try {
51217
+ const db = searchContentViaDatabase({
51218
+ query: query2,
51219
+ account: notesManager.searchAccountScope(account),
51220
+ folder,
51221
+ modifiedSince,
51222
+ limit: effectiveLimit
51223
+ });
51224
+ source = "database";
51225
+ dbScan = db.scan;
51226
+ return db.notes;
51227
+ } catch (error2) {
51228
+ if (!(error2 instanceof NoteQueryStoreError)) throw error2;
51229
+ dbUnavailable = error2.kind;
51230
+ }
51231
+ source = "applescript";
51232
+ }
51233
+ try {
51234
+ return notesManager.searchNotes(
51235
+ query2,
51236
+ searchContent,
51237
+ account,
51238
+ folder,
51239
+ modifiedSince,
51240
+ effectiveLimit
51241
+ );
51242
+ } catch (error2) {
51243
+ if (!searchContent || !(error2 instanceof Error)) throw error2;
51244
+ throw new Error(contentSearchFailureHint(error2.message, dbUnavailable));
51245
+ }
51246
+ };
51005
51247
  const {
51006
51248
  result: notes,
51007
51249
  syncBefore,
51008
51250
  syncInterference
51009
- } = withSyncAwarenessSync(
51010
- "search-notes",
51011
- () => notesManager.searchNotes(query2, searchContent, account, folder, modifiedSince, effectiveLimit)
51012
- );
51013
- const searchType = searchContent ? "content" : "titles";
51251
+ } = withSyncAwarenessSync("search-notes", runSearch);
51252
+ const searchType = searchContent ? source === "database" ? "content via the Notes database" : "content" : "titles";
51253
+ const sourceFields = source ? { source } : {};
51254
+ const scanFields = dbScan ? { scanTruncated: dbScan.scanTruncated } : {};
51255
+ const scanNote = describeContentScan(dbScan);
51014
51256
  const folderInfo = folder ? ` in folder "${folder}"` : "";
51015
51257
  const dateInfo = modifiedSince ? ` modified since ${modifiedSince}` : "";
51016
51258
  const { info: limitInfo, truncationNote } = describeSearchLimit(
@@ -51031,8 +51273,8 @@ ${syncWarnings.join(" ")}` : "";
51031
51273
  if (notes.length === 0) {
51032
51274
  const scopeHint = describeSearchScope(searchContent, notes.length);
51033
51275
  return successResponse(
51034
- `No notes found matching "${query2}" in ${searchType}${folderInfo}${dateInfo}${scopeHint}${syncNote}`,
51035
- { notes: [], count: 0 }
51276
+ `No notes found matching "${query2}" in ${searchType}${folderInfo}${dateInfo}${scopeHint}${scanNote}${syncNote}`,
51277
+ { notes: [], count: 0, ...sourceFields, ...scanFields }
51036
51278
  );
51037
51279
  }
51038
51280
  const noteList = notes.map((n) => {
@@ -51046,8 +51288,13 @@ ${syncWarnings.join(" ")}` : "";
51046
51288
  }).join("\n");
51047
51289
  return successResponse(
51048
51290
  `Found ${notes.length} notes (searched ${searchType}${folderInfo}${dateInfo}${limitInfo}):
51049
- ${noteList}${truncationNote}${syncNote}`,
51050
- { notes: withStableIdentifiers(notes, "ICNote"), count: notes.length }
51291
+ ${noteList}${truncationNote}${scanNote}${syncNote}`,
51292
+ {
51293
+ notes: withStableIdentifiers(notes, "ICNote"),
51294
+ count: notes.length,
51295
+ ...sourceFields,
51296
+ ...scanFields
51297
+ }
51051
51298
  );
51052
51299
  }, "Error searching notes")
51053
51300
  );
@@ -51682,7 +51929,7 @@ ${lines.join("\n")}` : ".") + partial2,
51682
51929
  registerTool(
51683
51930
  "update-note",
51684
51931
  {
51685
- description: "Use when: replacing the body of one exact Apple 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, the note changed since the read, or the note has attachments.\nSafety: requires the exact note id and expectedContentHash from get-note-content. The server checks rich metadata revision, atomically checks the AppleScript body, blocks native objects/checklists, and verifies actual link destinations after saving. Preserve returned HTML links unless allowLinkChanges is explicitly requested. Notes.app normalizes HTML, so rich formatting is not claimed as byte-identical.",
51932
+ description: "Use when: replacing the body of one exact Apple 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, the note changed since the read, or the note has attachments.\nSafety: requires the exact note id and expectedContentHash from get-note-content. The server checks rich metadata revision, atomically checks the AppleScript body, blocks native objects/checklists, and verifies actual link destinations after saving. Optional ifFolderId, ifAncestorFolderId, and forbiddenAncestorFolderIds are re-checked inside the same AppleScript as the write. Preserve returned HTML links unless allowLinkChanges is explicitly requested. Notes.app normalizes HTML, so rich formatting is not claimed as byte-identical.",
51686
51933
  inputSchema: {
51687
51934
  id: noteIdInput,
51688
51935
  expectedContentHash: expectedContentHashInput,
@@ -51696,7 +51943,8 @@ registerTool(
51696
51943
  "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."
51697
51944
  ),
51698
51945
  format: external_exports.enum(["plaintext", "html"]).optional().default("plaintext").describe("Content format: 'plaintext' (default) or 'html' for rich formatting"),
51699
- timeoutSeconds: timeoutSecondsInput
51946
+ timeoutSeconds: timeoutSecondsInput,
51947
+ ...scopeGuardInputs
51700
51948
  },
51701
51949
  outputSchema: {
51702
51950
  ok: external_exports.boolean().optional(),
@@ -51715,7 +51963,8 @@ registerTool(
51715
51963
  newTitle,
51716
51964
  newContent,
51717
51965
  format = "plaintext",
51718
- allowLinkChanges = false
51966
+ allowLinkChanges = false,
51967
+ ...scopeArgs
51719
51968
  }) => {
51720
51969
  const snapshot = readExactNoteSnapshot(id2);
51721
51970
  if ("error" in snapshot) return errorResponse(snapshot.error);
@@ -51736,11 +51985,15 @@ registerTool(
51736
51985
  newTitle,
51737
51986
  newContent,
51738
51987
  format,
51739
- snapshot.rich.revision
51988
+ snapshot.rich.revision,
51989
+ scopeFrom(scopeArgs)
51740
51990
  );
51741
51991
  if (result.status === "conflict") {
51742
51992
  return errorResponse(revisionConflictMessage(snapshot.note.title));
51743
51993
  }
51994
+ if (result.status === "scope_conflict") {
51995
+ return errorResponse(scopeConflictMessage(result.reason));
51996
+ }
51744
51997
  if (result.status === "attachments") {
51745
51998
  return errorResponse(
51746
51999
  `Note "${snapshot.note.title}" gained an attachment before saving. No content was replaced.`
@@ -51796,7 +52049,8 @@ function guardedAppend({
51796
52049
  separator = "\n\n",
51797
52050
  format = "plaintext",
51798
52051
  scopeText,
51799
- htmlSeparator
52052
+ htmlSeparator,
52053
+ ...scopeArgs
51800
52054
  }, onRoute) {
51801
52055
  const contentToHtml = (text2) => {
51802
52056
  if (format === "html") return text2;
@@ -51824,6 +52078,11 @@ function guardedAppend({
51824
52078
  return errorResponse("Provide scopeText: a unique existing phrase for native append");
51825
52079
  if (!VERIFIED_BACKGROUND.has("append-native"))
51826
52080
  return errorResponse("Native append has not passed live validation; see get-capabilities");
52081
+ const nativeScope = scopeFrom(scopeArgs);
52082
+ if (hasScopeGuard(nativeScope)) {
52083
+ const reason = notesManager.checkNoteScope(id2, nativeScope);
52084
+ if (reason) return errorResponse(scopeConflictMessage(reason));
52085
+ }
51827
52086
  onRoute?.("native");
51828
52087
  const result2 = appendNative(notesManager, {
51829
52088
  id: id2,
@@ -51855,11 +52114,15 @@ function guardedAppend({
51855
52114
  void 0,
51856
52115
  combinedBody,
51857
52116
  "html",
51858
- snapshot.rich.revision
52117
+ snapshot.rich.revision,
52118
+ scopeFrom(scopeArgs)
51859
52119
  );
51860
52120
  if (result.status === "conflict") {
51861
52121
  return errorResponse(revisionConflictMessage(snapshot.note.title));
51862
52122
  }
52123
+ if (result.status === "scope_conflict") {
52124
+ return errorResponse(scopeConflictMessage(result.reason));
52125
+ }
51863
52126
  if (result.status === "attachments") {
51864
52127
  return errorResponse(
51865
52128
  `Note "${snapshot.note.title}" gained an attachment before saving. No content was appended.`
@@ -51900,7 +52163,7 @@ function guardedAppend({
51900
52163
  registerTool(
51901
52164
  "append-to-note",
51902
52165
  {
51903
- 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'.",
52166
+ 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. Optional ifFolderId, ifAncestorFolderId, and forbiddenAncestorFolderIds are re-checked inside the write AppleScript for ordinary notes, and as a separate read just before a native append. 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'.",
51904
52167
  inputSchema: {
51905
52168
  id: noteIdInput,
51906
52169
  expectedContentHash: expectedContentHashInput,
@@ -51911,7 +52174,8 @@ registerTool(
51911
52174
  ),
51912
52175
  separator: external_exports.string().max(20).optional().default("\n\n").describe("String placed between existing content and new content (default: two newlines)"),
51913
52176
  format: external_exports.enum(["plaintext", "html"]).optional().default("plaintext").describe("Format of the content being appended: 'plaintext' (default) or 'html'"),
51914
- timeoutSeconds: timeoutSecondsInput
52177
+ timeoutSeconds: timeoutSecondsInput,
52178
+ ...scopeGuardInputs
51915
52179
  },
51916
52180
  outputSchema: {
51917
52181
  ok: external_exports.boolean().optional(),
@@ -51997,11 +52261,12 @@ registerTool(
51997
52261
  registerTool(
51998
52262
  "delete-note",
51999
52263
  {
52000
- 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. Refuses a note already in Recently Deleted, where a delete would be permanent.",
52264
+ 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. Refuses a note already in Recently Deleted, where a delete would be permanent. Optional ifFolderId, ifAncestorFolderId, and forbiddenAncestorFolderIds are re-checked inside that AppleScript too.",
52001
52265
  inputSchema: {
52002
52266
  id: noteIdInput,
52003
52267
  expectedContentHash: expectedContentHashInput,
52004
- timeoutSeconds: timeoutSecondsInput
52268
+ timeoutSeconds: timeoutSecondsInput,
52269
+ ...scopeGuardInputs
52005
52270
  },
52006
52271
  outputSchema: {
52007
52272
  ok: external_exports.boolean().optional(),
@@ -52011,16 +52276,19 @@ registerTool(
52011
52276
  previousContentHash: external_exports.string().optional()
52012
52277
  }
52013
52278
  },
52014
- withErrorHandling(({ id: id2, expectedContentHash }) => {
52279
+ withErrorHandling(({ id: id2, expectedContentHash, ...scopeArgs }) => {
52015
52280
  const snapshot = readExactNoteSnapshot(id2);
52016
52281
  if ("error" in snapshot) return errorResponse(snapshot.error);
52017
52282
  if (snapshot.contentHash !== expectedContentHash) {
52018
52283
  return errorResponse(revisionConflictMessage(snapshot.note.title));
52019
52284
  }
52020
- const result = notesManager.deleteNoteByIdIfUnchanged(id2, snapshot.body);
52285
+ const result = notesManager.deleteNoteByIdIfUnchanged(id2, snapshot.body, scopeFrom(scopeArgs));
52021
52286
  if (result.status === "conflict") {
52022
52287
  return errorResponse(revisionConflictMessage(snapshot.note.title));
52023
52288
  }
52289
+ if (result.status === "scope_conflict") {
52290
+ return errorResponse(scopeConflictMessage(result.reason));
52291
+ }
52024
52292
  if (result.status === "in-recently-deleted") {
52025
52293
  return errorResponse(inRecentlyDeletedMessage(snapshot.note.title));
52026
52294
  }
@@ -52048,12 +52316,13 @@ registerTool(
52048
52316
  registerTool(
52049
52317
  "move-note",
52050
52318
  {
52051
- description: "Use when: moving one exact note to a different folder by id.\nReturns: confirmation and exact-ID readback.\nDo not use when: you only have a title or want to move many notes (batch-move-notes).\nNote: Notes.app's native move preserves the note id, creation date, body, and attachments. The destination folder must already exist.",
52319
+ description: "Use when: moving one exact note to a different folder by id.\nReturns: confirmation and exact-ID readback.\nDo not use when: you only have a title or want to move many notes (batch-move-notes).\nNote: Notes.app's native move preserves the note id, creation date, body, and attachments. The destination folder must already exist. Optional ifFolderId, ifAncestorFolderId, and forbiddenAncestorFolderIds (which also covers the destination) are re-checked inside the move AppleScript.",
52052
52320
  inputSchema: {
52053
52321
  id: noteIdInput,
52054
52322
  folder: external_exports.string().min(1, "Destination folder is required").max(MAX.FOLDER),
52055
52323
  account: external_exports.string().max(MAX.ACCOUNT).optional().describe("Account containing the note/folder"),
52056
- timeoutSeconds: timeoutSecondsInput
52324
+ timeoutSeconds: timeoutSecondsInput,
52325
+ ...scopeGuardInputs
52057
52326
  },
52058
52327
  outputSchema: {
52059
52328
  ok: external_exports.boolean().optional(),
@@ -52063,12 +52332,12 @@ registerTool(
52063
52332
  verified: external_exports.boolean().optional()
52064
52333
  }
52065
52334
  },
52066
- withErrorHandling(({ id: id2, folder, account }) => {
52335
+ withErrorHandling(({ id: id2, folder, account, ...scopeArgs }) => {
52067
52336
  const note = notesManager.getNoteById(id2);
52068
52337
  if (!note) {
52069
52338
  return errorResponse(`Note with ID "${id2}" not found`);
52070
52339
  }
52071
- const success = notesManager.moveNoteById(id2, folder, account);
52340
+ const success = notesManager.moveNoteById(id2, folder, account, scopeFrom(scopeArgs));
52072
52341
  if (!success) {
52073
52342
  return errorResponse(
52074
52343
  `Failed to move note "${note.title}" to folder "${folder}". Folder may not exist.`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.8.42",
3
+ "version": "2.8.44",
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",