apple-notes-mcp 2.8.43 → 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 +33 -0
  2. package/build/index.js +170 -24
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -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";
@@ -42826,7 +42897,7 @@ var AppleNotesManager = class {
42826
42897
  * race that would exist if JavaScript checked the note and then issued a
42827
42898
  * separate unconditional `set body` command.
42828
42899
  */
42829
- updateNoteByIdIfUnchanged(id2, currentTitle, expectedBody, newTitle, newContent, format = "plaintext", expectedRichRevision) {
42900
+ updateNoteByIdIfUnchanged(id2, currentTitle, expectedBody, newTitle, newContent, format = "plaintext", expectedRichRevision, scope2) {
42830
42901
  const safeId = sanitizeNoteId(id2);
42831
42902
  if (expectedRichRevision) {
42832
42903
  const rich = readRichNote(id2);
@@ -42847,7 +42918,7 @@ var AppleNotesManager = class {
42847
42918
  const safeExpectedBody = escapeHtmlForAppleScript(expectedBody);
42848
42919
  const safeWrittenBody = escapeHtmlForAppleScript(writtenBody);
42849
42920
  const script = buildAppLevelScript(`
42850
- set noteRef to note id "${safeId}"
42921
+ set noteRef to note id "${safeId}"${buildScopeGuardScript("noteRef", scope2)}
42851
42922
  if (count of attachments of noteRef) is greater than 0 then return "SAFETY_ATTACHMENTS"
42852
42923
  set currentBody to body of noteRef
42853
42924
  considering case
@@ -42861,6 +42932,8 @@ var AppleNotesManager = class {
42861
42932
  console.error(`Failed guarded update for note ID "${id2}":`, result.error);
42862
42933
  return { status: "failed" };
42863
42934
  }
42935
+ const updateScopeFailure = parseScopeFailure(result.output);
42936
+ if (updateScopeFailure) return { status: "scope_conflict", reason: updateScopeFailure };
42864
42937
  const status = result.output.trim();
42865
42938
  if (status === "SAFETY_CONFLICT") return { status: "conflict" };
42866
42939
  if (status === "SAFETY_ATTACHMENTS") return { status: "attachments" };
@@ -42872,12 +42945,12 @@ var AppleNotesManager = class {
42872
42945
  * the caller reviewed. The comparison and delete are one AppleScript action,
42873
42946
  * so a concurrent edit cannot slip between the guard and deletion.
42874
42947
  */
42875
- deleteNoteByIdIfUnchanged(id2, expectedBody) {
42948
+ deleteNoteByIdIfUnchanged(id2, expectedBody, scope2) {
42876
42949
  const safeId = sanitizeNoteId(id2);
42877
42950
  validateLength(expectedBody, MAX_CONTENT_LENGTH, "Expected note content");
42878
42951
  const safeExpectedBody = escapeHtmlForAppleScript(expectedBody);
42879
42952
  const script = buildAppLevelScript(`
42880
- set noteRef to note id "${safeId}"
42953
+ set noteRef to note id "${safeId}"${buildScopeGuardScript("noteRef", scope2)}
42881
42954
  set originalFolder to missing value
42882
42955
  try
42883
42956
  set originalFolder to container of noteRef
@@ -42909,12 +42982,31 @@ var AppleNotesManager = class {
42909
42982
  console.error(`Failed guarded delete for note ID "${id2}":`, result.error);
42910
42983
  return { status: "failed" };
42911
42984
  }
42985
+ const deleteScopeFailure = parseScopeFailure(result.output);
42986
+ if (deleteScopeFailure) return { status: "scope_conflict", reason: deleteScopeFailure };
42912
42987
  const status = result.output.trim();
42913
42988
  if (status === "SAFETY_CONFLICT") return { status: "conflict" };
42914
42989
  if (status === "SAFETY_IN_RECENTLY_DELETED") return { status: "in-recently-deleted" };
42915
42990
  if (status === "SAFETY_NOT_DELETED") return { status: "not-deleted" };
42916
42991
  return status === "SAFETY_DELETED" ? { status: "deleted" } : { status: "failed" };
42917
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
+ }
42918
43010
  /**
42919
43011
  * Builds the AppleScript body for a bulk note listing.
42920
43012
  *
@@ -43501,14 +43593,14 @@ var AppleNotesManager = class {
43501
43593
  * @param account - Account containing the destination folder (defaults to Notes.app's default account)
43502
43594
  * @returns true if the move succeeded, false otherwise
43503
43595
  */
43504
- moveNoteById(id2, destinationFolder, account) {
43596
+ moveNoteById(id2, destinationFolder, account, scope2) {
43505
43597
  const targetAccount = this.resolveAccount(account);
43506
43598
  const safeId = sanitizeNoteId(id2);
43507
43599
  const destFolderRef = `${buildFolderReference(destinationFolder)} of ${AS_ACCOUNT_REF}`;
43508
43600
  const moveCommand = `
43509
43601
  ${buildAccountResolution(targetAccount)}
43510
43602
  set destFolder to ${destFolderRef}
43511
- set noteRef to note id "${safeId}"
43603
+ set noteRef to note id "${safeId}"${buildScopeGuardScript("noteRef", scope2, "destFolder")}
43512
43604
  move noteRef to destFolder
43513
43605
  set movedNoteRef to note id "${safeId}"
43514
43606
  set actualFolder to container of movedNoteRef
@@ -43525,6 +43617,8 @@ var AppleNotesManager = class {
43525
43617
  );
43526
43618
  return false;
43527
43619
  }
43620
+ const scopeFailure = parseScopeFailure(result.output);
43621
+ if (scopeFailure) throw new Error(scopeConflictMessage(scopeFailure));
43528
43622
  if (result.output.trim() !== "SAFETY_MOVED") {
43529
43623
  console.error(
43530
43624
  `Move result for note ID "${id2}" did not verify destination "${destinationFolder}"`
@@ -44426,12 +44520,12 @@ var AppleNotesManager = class {
44426
44520
  }
44427
44521
  });
44428
44522
  if (runnable.length > 0) {
44429
- const idList = runnable.map((r) => `"${r.safe}"`).join(", ");
44523
+ const idList2 = runnable.map((r) => `"${r.safe}"`).join(", ");
44430
44524
  const script = buildAppLevelScript(`
44431
44525
  ${buildAccountResolution(targetAccount)}
44432
44526
  set destFolder to ${destFolderRef}
44433
44527
  set out to ""
44434
- repeat with rawId in {${idList}}
44528
+ repeat with rawId in {${idList2}}
44435
44529
  set theId to (rawId as text)
44436
44530
  set noteRef to missing value
44437
44531
  try
@@ -50879,6 +50973,36 @@ function noteIdentifiers(id2) {
50879
50973
  var expectedContentHashInput = external_exports.string().regex(/^sha256:[a-f0-9]{64}$/, "expectedContentHash must come from get-note-content").describe(
50880
50974
  "Revision token returned by get-note-content for this exact ID. The mutation stops if the note changed since that read."
50881
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
+ }
50882
51006
  var timeoutSecondsInput = external_exports.number().int().min(CALL_TIMEOUT_SECONDS.min).max(CALL_TIMEOUT_SECONDS.max).optional().describe(
50883
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.`
50884
51008
  );
@@ -51805,7 +51929,7 @@ ${lines.join("\n")}` : ".") + partial2,
51805
51929
  registerTool(
51806
51930
  "update-note",
51807
51931
  {
51808
- 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.",
51809
51933
  inputSchema: {
51810
51934
  id: noteIdInput,
51811
51935
  expectedContentHash: expectedContentHashInput,
@@ -51819,7 +51943,8 @@ registerTool(
51819
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."
51820
51944
  ),
51821
51945
  format: external_exports.enum(["plaintext", "html"]).optional().default("plaintext").describe("Content format: 'plaintext' (default) or 'html' for rich formatting"),
51822
- timeoutSeconds: timeoutSecondsInput
51946
+ timeoutSeconds: timeoutSecondsInput,
51947
+ ...scopeGuardInputs
51823
51948
  },
51824
51949
  outputSchema: {
51825
51950
  ok: external_exports.boolean().optional(),
@@ -51838,7 +51963,8 @@ registerTool(
51838
51963
  newTitle,
51839
51964
  newContent,
51840
51965
  format = "plaintext",
51841
- allowLinkChanges = false
51966
+ allowLinkChanges = false,
51967
+ ...scopeArgs
51842
51968
  }) => {
51843
51969
  const snapshot = readExactNoteSnapshot(id2);
51844
51970
  if ("error" in snapshot) return errorResponse(snapshot.error);
@@ -51859,11 +51985,15 @@ registerTool(
51859
51985
  newTitle,
51860
51986
  newContent,
51861
51987
  format,
51862
- snapshot.rich.revision
51988
+ snapshot.rich.revision,
51989
+ scopeFrom(scopeArgs)
51863
51990
  );
51864
51991
  if (result.status === "conflict") {
51865
51992
  return errorResponse(revisionConflictMessage(snapshot.note.title));
51866
51993
  }
51994
+ if (result.status === "scope_conflict") {
51995
+ return errorResponse(scopeConflictMessage(result.reason));
51996
+ }
51867
51997
  if (result.status === "attachments") {
51868
51998
  return errorResponse(
51869
51999
  `Note "${snapshot.note.title}" gained an attachment before saving. No content was replaced.`
@@ -51919,7 +52049,8 @@ function guardedAppend({
51919
52049
  separator = "\n\n",
51920
52050
  format = "plaintext",
51921
52051
  scopeText,
51922
- htmlSeparator
52052
+ htmlSeparator,
52053
+ ...scopeArgs
51923
52054
  }, onRoute) {
51924
52055
  const contentToHtml = (text2) => {
51925
52056
  if (format === "html") return text2;
@@ -51947,6 +52078,11 @@ function guardedAppend({
51947
52078
  return errorResponse("Provide scopeText: a unique existing phrase for native append");
51948
52079
  if (!VERIFIED_BACKGROUND.has("append-native"))
51949
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
+ }
51950
52086
  onRoute?.("native");
51951
52087
  const result2 = appendNative(notesManager, {
51952
52088
  id: id2,
@@ -51978,11 +52114,15 @@ function guardedAppend({
51978
52114
  void 0,
51979
52115
  combinedBody,
51980
52116
  "html",
51981
- snapshot.rich.revision
52117
+ snapshot.rich.revision,
52118
+ scopeFrom(scopeArgs)
51982
52119
  );
51983
52120
  if (result.status === "conflict") {
51984
52121
  return errorResponse(revisionConflictMessage(snapshot.note.title));
51985
52122
  }
52123
+ if (result.status === "scope_conflict") {
52124
+ return errorResponse(scopeConflictMessage(result.reason));
52125
+ }
51986
52126
  if (result.status === "attachments") {
51987
52127
  return errorResponse(
51988
52128
  `Note "${snapshot.note.title}" gained an attachment before saving. No content was appended.`
@@ -52023,7 +52163,7 @@ function guardedAppend({
52023
52163
  registerTool(
52024
52164
  "append-to-note",
52025
52165
  {
52026
- 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'.",
52027
52167
  inputSchema: {
52028
52168
  id: noteIdInput,
52029
52169
  expectedContentHash: expectedContentHashInput,
@@ -52034,7 +52174,8 @@ registerTool(
52034
52174
  ),
52035
52175
  separator: external_exports.string().max(20).optional().default("\n\n").describe("String placed between existing content and new content (default: two newlines)"),
52036
52176
  format: external_exports.enum(["plaintext", "html"]).optional().default("plaintext").describe("Format of the content being appended: 'plaintext' (default) or 'html'"),
52037
- timeoutSeconds: timeoutSecondsInput
52177
+ timeoutSeconds: timeoutSecondsInput,
52178
+ ...scopeGuardInputs
52038
52179
  },
52039
52180
  outputSchema: {
52040
52181
  ok: external_exports.boolean().optional(),
@@ -52120,11 +52261,12 @@ registerTool(
52120
52261
  registerTool(
52121
52262
  "delete-note",
52122
52263
  {
52123
- 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.",
52124
52265
  inputSchema: {
52125
52266
  id: noteIdInput,
52126
52267
  expectedContentHash: expectedContentHashInput,
52127
- timeoutSeconds: timeoutSecondsInput
52268
+ timeoutSeconds: timeoutSecondsInput,
52269
+ ...scopeGuardInputs
52128
52270
  },
52129
52271
  outputSchema: {
52130
52272
  ok: external_exports.boolean().optional(),
@@ -52134,16 +52276,19 @@ registerTool(
52134
52276
  previousContentHash: external_exports.string().optional()
52135
52277
  }
52136
52278
  },
52137
- withErrorHandling(({ id: id2, expectedContentHash }) => {
52279
+ withErrorHandling(({ id: id2, expectedContentHash, ...scopeArgs }) => {
52138
52280
  const snapshot = readExactNoteSnapshot(id2);
52139
52281
  if ("error" in snapshot) return errorResponse(snapshot.error);
52140
52282
  if (snapshot.contentHash !== expectedContentHash) {
52141
52283
  return errorResponse(revisionConflictMessage(snapshot.note.title));
52142
52284
  }
52143
- const result = notesManager.deleteNoteByIdIfUnchanged(id2, snapshot.body);
52285
+ const result = notesManager.deleteNoteByIdIfUnchanged(id2, snapshot.body, scopeFrom(scopeArgs));
52144
52286
  if (result.status === "conflict") {
52145
52287
  return errorResponse(revisionConflictMessage(snapshot.note.title));
52146
52288
  }
52289
+ if (result.status === "scope_conflict") {
52290
+ return errorResponse(scopeConflictMessage(result.reason));
52291
+ }
52147
52292
  if (result.status === "in-recently-deleted") {
52148
52293
  return errorResponse(inRecentlyDeletedMessage(snapshot.note.title));
52149
52294
  }
@@ -52171,12 +52316,13 @@ registerTool(
52171
52316
  registerTool(
52172
52317
  "move-note",
52173
52318
  {
52174
- 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.",
52175
52320
  inputSchema: {
52176
52321
  id: noteIdInput,
52177
52322
  folder: external_exports.string().min(1, "Destination folder is required").max(MAX.FOLDER),
52178
52323
  account: external_exports.string().max(MAX.ACCOUNT).optional().describe("Account containing the note/folder"),
52179
- timeoutSeconds: timeoutSecondsInput
52324
+ timeoutSeconds: timeoutSecondsInput,
52325
+ ...scopeGuardInputs
52180
52326
  },
52181
52327
  outputSchema: {
52182
52328
  ok: external_exports.boolean().optional(),
@@ -52186,12 +52332,12 @@ registerTool(
52186
52332
  verified: external_exports.boolean().optional()
52187
52333
  }
52188
52334
  },
52189
- withErrorHandling(({ id: id2, folder, account }) => {
52335
+ withErrorHandling(({ id: id2, folder, account, ...scopeArgs }) => {
52190
52336
  const note = notesManager.getNoteById(id2);
52191
52337
  if (!note) {
52192
52338
  return errorResponse(`Note with ID "${id2}" not found`);
52193
52339
  }
52194
- const success = notesManager.moveNoteById(id2, folder, account);
52340
+ const success = notesManager.moveNoteById(id2, folder, account, scopeFrom(scopeArgs));
52195
52341
  if (!success) {
52196
52342
  return errorResponse(
52197
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.43",
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",