apple-notes-mcp 2.9.0 → 2.9.1

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 +16 -0
  2. package/build/index.js +209 -53
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -809,10 +809,26 @@ Deletes a note (moves to Recently Deleted in Notes.app).
809
809
  | `id` | string | Yes | Exact CoreData note ID returned by a read or search |
810
810
  | `expectedContentHash` | string | Yes | `contentHash` from the exact note version being deleted |
811
811
  | `ifFolderId`, `ifAncestorFolderId`, `forbiddenAncestorFolderIds` | string, string, string[] | No | Folder preconditions; see [Folder scope guards](#folder-scope-guards) |
812
+ | `guardNoteId` | string | No | A second note (usually a verified copy) that must still be intact; needs Full Disk Access |
813
+ | `expectedGuardContentHash` | string | With `guardNoteId` | `contentHash` of the guard note from `get-note-content` |
814
+ | `requireActiveNoteId` | string | No | A second note that must still exist, be unlocked, stay outside Recently Deleted, and not be a Quick Note; its content is not fingerprinted. Needs Full Disk Access |
812
815
 
813
816
  Title-only deletion is rejected. If the note changed after the supplied hash
814
817
  was read, deletion is also rejected.
815
818
 
819
+ **Copy-then-retire.** To delete an original only while its copy is still good,
820
+ read both notes, verify the copy, and pass the copy as `guardNoteId` with its
821
+ `contentHash` as `expectedGuardContentHash`. The copy's revision is re-read just
822
+ before the delete, and its body, lock state, and folder are checked again
823
+ inside the delete AppleScript, with the same fail-closed Recently Deleted test
824
+ as the note being deleted. The guard note must not be a Quick Note, a flag only
825
+ the database holds, so the guard needs Full Disk Access. A copy the database
826
+ has not saved yet passes on the live checks alone. The pair is still not one
827
+ transaction: the rich revision (which also covers checklists and attachments)
828
+ is a pre-check, and the in-script check covers the body and state Notes.app
829
+ exposes. `requireActiveNoteId` is the narrower form for a destination you
830
+ wrote yourself rather than copied.
831
+
816
832
  **Example - Using ID (recommended):**
817
833
  ```json
818
834
  {
package/build/index.js CHANGED
@@ -42140,6 +42140,19 @@ function trashFolderIdList() {
42140
42140
  const ids = readTrashFolderIds().filter((id2) => FOLDER_ID_PATTERN.test(id2));
42141
42141
  return `{${ids.map((id2) => `"${id2}"`).join(", ")}}`;
42142
42142
  }
42143
+ function inRecentlyDeletedScript(containerVar, flagVar, trashIds) {
42144
+ return `
42145
+ set ${flagVar} to true
42146
+ try
42147
+ if (class of ${containerVar}) is folder then set ${flagVar} to false
42148
+ end try
42149
+ try
42150
+ if ${trashIds} contains (id of ${containerVar}) then set ${flagVar} to true
42151
+ end try
42152
+ try
42153
+ if (name of ${containerVar}) is "${RECENTLY_DELETED_FOLDER_NAME}" then set ${flagVar} to true
42154
+ end try`;
42155
+ }
42143
42156
  function buildTrashNoteIdsCollector() {
42144
42157
  return `
42145
42158
  set __trashNoteIds to {}
@@ -43024,29 +43037,53 @@ var AppleNotesManager = class {
43024
43037
  * Deletes one exact note only when its complete body still matches the body
43025
43038
  * the caller reviewed. The comparison and delete are one AppleScript action,
43026
43039
  * so a concurrent edit cannot slip between the guard and deletion.
43027
- */
43028
- deleteNoteByIdIfUnchanged(id2, expectedBody, scope2) {
43040
+ *
43041
+ * `guards` are other notes that must still be active when the delete runs
43042
+ * (the copy-then-retire guard of delete-note): each must exist, be unlocked,
43043
+ * and sit in a folder other than Recently Deleted, and one with
43044
+ * `expectedBody` must still have that body. They are checked in the same
43045
+ * script, just before the delete; `index` in a guard outcome points into
43046
+ * `guards`.
43047
+ */
43048
+ deleteNoteByIdIfUnchanged(id2, expectedBody, scope2, guards = []) {
43029
43049
  const safeId = sanitizeNoteId(id2);
43030
43050
  validateLength(expectedBody, MAX_CONTENT_LENGTH, "Expected note content");
43031
43051
  const safeExpectedBody = escapeHtmlForAppleScript(expectedBody);
43052
+ const trashIds = trashFolderIdList();
43053
+ const guardChecks = guards.map((guard, index) => {
43054
+ const safeGuardId = sanitizeNoteId(guard.id);
43055
+ const ref = `__guardRef${index}`;
43056
+ const folderVar = `__guardFolder${index}`;
43057
+ const inactive = (reason) => `return "SAFETY_GUARD_INACTIVE:${index}:${reason}"`;
43058
+ let bodyCheck = "";
43059
+ if (guard.expectedBody !== void 0) {
43060
+ validateLength(guard.expectedBody, MAX_CONTENT_LENGTH, "Expected guard note content");
43061
+ const safeGuardBody = escapeHtmlForAppleScript(guard.expectedBody);
43062
+ bodyCheck = `
43063
+ set __guardBody to body of ${ref}
43064
+ considering case
43065
+ if __guardBody is not "${safeGuardBody}" and __guardBody is not "${safeGuardBody}" & linefeed then return "SAFETY_GUARD_CONFLICT:${index}"
43066
+ end considering`;
43067
+ }
43068
+ return `
43069
+ if not (exists note id "${safeGuardId}") then ${inactive("missing")}
43070
+ set ${ref} to note id "${safeGuardId}"
43071
+ if password protected of ${ref} then ${inactive("locked")}
43072
+ set ${folderVar} to missing value
43073
+ try
43074
+ set ${folderVar} to container of ${ref}
43075
+ end try
43076
+ if ${folderVar} is missing value then ${inactive("folder unknown")}${inRecentlyDeletedScript(folderVar, "__guardInTrash", trashIds)}
43077
+ if __guardInTrash then ${inactive("in Recently Deleted")}${bodyCheck}`;
43078
+ }).join("");
43032
43079
  const script = buildAppLevelScript(`
43033
43080
  set noteRef to note id "${safeId}"${buildScopeGuardScript("noteRef", scope2)}
43034
43081
  set originalFolder to missing value
43035
43082
  try
43036
43083
  set originalFolder to container of noteRef
43037
43084
  end try
43038
- if originalFolder is missing value then return "SAFETY_CONTAINER_UNKNOWN"
43039
- set __inTrash to true
43040
- try
43041
- if (class of originalFolder) is folder then set __inTrash to false
43042
- end try
43043
- try
43044
- if ${trashFolderIdList()} contains (id of originalFolder) then set __inTrash to true
43045
- end try
43046
- try
43047
- if (name of originalFolder) is "${RECENTLY_DELETED_FOLDER_NAME}" then set __inTrash to true
43048
- end try
43049
- if __inTrash then return "SAFETY_IN_RECENTLY_DELETED"
43085
+ if originalFolder is missing value then return "SAFETY_CONTAINER_UNKNOWN"${inRecentlyDeletedScript("originalFolder", "__inTrash", trashIds)}
43086
+ if __inTrash then return "SAFETY_IN_RECENTLY_DELETED"${guardChecks}
43050
43087
  set currentBody to body of noteRef
43051
43088
  considering case
43052
43089
  if currentBody is not "${safeExpectedBody}" and currentBody is not "${safeExpectedBody}" & linefeed then return "SAFETY_CONFLICT"
@@ -43065,6 +43102,11 @@ var AppleNotesManager = class {
43065
43102
  const deleteScopeFailure = parseScopeFailure(result.output);
43066
43103
  if (deleteScopeFailure) return { status: "scope_conflict", reason: deleteScopeFailure };
43067
43104
  const status = result.output.trim();
43105
+ const guardOutcome = /^SAFETY_GUARD_(CONFLICT|INACTIVE):(\d+)(?::(.+))?$/.exec(status);
43106
+ if (guardOutcome && Number(guardOutcome[2]) < guards.length) {
43107
+ const index = Number(guardOutcome[2]);
43108
+ return guardOutcome[1] === "CONFLICT" ? { status: "guard-conflict", index } : { status: "guard-inactive", index, reason: guardOutcome[3] ?? "inactive" };
43109
+ }
43068
43110
  if (status === "SAFETY_CONFLICT") return { status: "conflict" };
43069
43111
  if (status === "SAFETY_IN_RECENTLY_DELETED") return { status: "in-recently-deleted" };
43070
43112
  if (status === "SAFETY_CONTAINER_UNKNOWN") return { status: "container-unknown" };
@@ -45568,6 +45610,23 @@ function toSpecialRow(row, uuid2, paths, accountNames, kind) {
45568
45610
  if (kind === "locked" && row.hint) note.passwordHint = row.hint;
45569
45611
  return note;
45570
45612
  }
45613
+ var EXACT_NOTE_ID = /^x-coredata:\/\/([0-9A-F-]+)\/ICNote\/p(\d+)$/i;
45614
+ function quickNoteFlag(noteId3, dbPath2 = NOTES_DB_PATH7) {
45615
+ const match = EXACT_NOTE_ID.exec(noteId3);
45616
+ if (!match) throw new NoteStoreError(`Not an exact note id: ${noteId3}`, "invalid_input");
45617
+ const columns = readColumns(dbPath2);
45618
+ const sql = `SELECT json_object('uuid', (SELECT Z_UUID FROM Z_METADATA LIMIT 1), 'quick', (SELECT ${flag(columns, "n", "ZISSYSTEMPAPER")} FROM ZICCLOUDSYNCINGOBJECT n WHERE n.Z_PK = @pk AND n.Z_ENT = ${entity("ICNote")}));`;
45619
+ const [row] = parseJsonLines(
45620
+ runReadOnlySql(dbPath2, sql, { pk: { int: Number(match[2]) } })
45621
+ );
45622
+ if (row?.uuid?.toUpperCase() !== match[1].toUpperCase()) {
45623
+ throw new NoteStoreError(
45624
+ `Note id ${noteId3} belongs to a different Notes database.`,
45625
+ "invalid_input"
45626
+ );
45627
+ }
45628
+ return row.quick === null ? null : row.quick === 1;
45629
+ }
45571
45630
  var TAG_REQUIRED = ["Z_PK", "Z_ENT", "ZFOLDER", "ZNOTE1", "ZTYPEUTI1", "ZALTTEXT", "ZIDENTIFIER"];
45572
45631
  function buildTagInventorySql(columns, scoped) {
45573
45632
  requireColumns(columns, TAG_REQUIRED, "list-native-tags");
@@ -52210,6 +52269,59 @@ function inRecentlyDeletedMessage(title) {
52210
52269
  function revisionConflictMessage(title) {
52211
52270
  return `Note "${title}" changed after it was read. Read it again and review the newer version before retrying.`;
52212
52271
  }
52272
+ function quickNoteGuardRefusal(label, id2) {
52273
+ let quick;
52274
+ try {
52275
+ quick = quickNoteFlag(id2);
52276
+ } catch (error2) {
52277
+ const detail = error2 instanceof Error ? error2.message : String(error2);
52278
+ if (error2 instanceof NoteStoreError && error2.kind === "no_fda") {
52279
+ return `${label} note cannot be checked: the delete-note guard needs Full Disk Access to rule out a Quick Note. Nothing was deleted. ${detail}`;
52280
+ }
52281
+ return `${label} note could not be checked (${detail}). Nothing was deleted.`;
52282
+ }
52283
+ return quick ? `${label} note is a Quick Note; a guard must be an ordinary note. Nothing was deleted.` : null;
52284
+ }
52285
+ function prepareDeleteGuards(args) {
52286
+ const { id: id2, guardNoteId, expectedGuardContentHash, requireActiveNoteId } = args;
52287
+ if (guardNoteId === void 0 !== (expectedGuardContentHash === void 0)) {
52288
+ return { error: "Pass guardNoteId and expectedGuardContentHash together." };
52289
+ }
52290
+ if (guardNoteId === id2 || requireActiveNoteId === id2) {
52291
+ return { error: "A guard note must be a different note from the one being deleted." };
52292
+ }
52293
+ if (guardNoteId !== void 0 && guardNoteId === requireActiveNoteId) {
52294
+ return { error: "requireActiveNoteId repeats guardNoteId; pass only guardNoteId." };
52295
+ }
52296
+ const prepared = { guards: [], labels: [] };
52297
+ if (guardNoteId !== void 0) {
52298
+ const refusal = quickNoteGuardRefusal("Guard", guardNoteId);
52299
+ if (refusal) return { error: refusal };
52300
+ const guard = readExactNoteSnapshot(guardNoteId);
52301
+ if ("error" in guard) return { error: `Guard note: ${guard.error}` };
52302
+ if (guard.contentHash !== expectedGuardContentHash) {
52303
+ return {
52304
+ error: `Guard note "${guard.note.title}" changed after it was read. Verify the copy again before retiring the original. Nothing was deleted.`
52305
+ };
52306
+ }
52307
+ prepared.guards.push({ id: guardNoteId, expectedBody: guard.body });
52308
+ prepared.labels.push("Guard");
52309
+ prepared.guardContentHash = guard.contentHash;
52310
+ }
52311
+ if (requireActiveNoteId !== void 0) {
52312
+ const refusal = quickNoteGuardRefusal("Required active", requireActiveNoteId);
52313
+ if (refusal) return { error: refusal };
52314
+ const active = notesManager.getNoteById(requireActiveNoteId);
52315
+ if (!active)
52316
+ return { error: `Required active note with ID "${requireActiveNoteId}" not found.` };
52317
+ if (active.passwordProtected) {
52318
+ return { error: `Required active note "${active.title}" is password-protected.` };
52319
+ }
52320
+ prepared.guards.push({ id: requireActiveNoteId });
52321
+ prepared.labels.push("Required active");
52322
+ }
52323
+ return prepared;
52324
+ }
52213
52325
  var folderNameSchema = {
52214
52326
  name: external_exports.string().min(1, "Folder name is required").max(MAX.FOLDER),
52215
52327
  account: external_exports.string().max(MAX.ACCOUNT).optional().describe(
@@ -53445,10 +53557,17 @@ registerTool(
53445
53557
  registerTool(
53446
53558
  "delete-note",
53447
53559
  {
53448
- 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.",
53560
+ 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. Copy-then-retire: pass guardNoteId and expectedGuardContentHash (the verified copy's contentHash) to delete only while the copy still has that revision, is unlocked, is outside Recently Deleted, and is not a Quick Note; requireActiveNoteId requires the same of a second note without fingerprinting it. The guard needs Full Disk Access to rule out a Quick Note. The copy's body, lock state, and folder are checked again inside the delete AppleScript, but the pair is not one transaction.",
53449
53561
  inputSchema: {
53450
53562
  id: noteIdInput,
53451
53563
  expectedContentHash: expectedContentHashInput,
53564
+ guardNoteId: noteIdInput.optional().describe(
53565
+ "A second note (usually the verified copy) that must still match expectedGuardContentHash, be unlocked, stay outside Recently Deleted, and not be a Quick Note. Needs Full Disk Access"
53566
+ ),
53567
+ expectedGuardContentHash: expectedContentHashInput.optional().describe("get-note-content contentHash of guardNoteId; required with guardNoteId"),
53568
+ requireActiveNoteId: noteIdInput.optional().describe(
53569
+ "A second note that must still exist, be unlocked, stay outside Recently Deleted, and not be a Quick Note. Its content is not fingerprinted. Needs Full Disk Access"
53570
+ ),
53452
53571
  timeoutSeconds: timeoutSecondsInput,
53453
53572
  ...scopeGuardInputs
53454
53573
  },
@@ -53457,48 +53576,85 @@ registerTool(
53457
53576
  id: external_exports.string().optional(),
53458
53577
  title: external_exports.string().optional(),
53459
53578
  wasShared: external_exports.boolean().optional(),
53460
- previousContentHash: external_exports.string().optional()
53579
+ previousContentHash: external_exports.string().optional(),
53580
+ guardNoteId: external_exports.string().optional(),
53581
+ guardContentHash: external_exports.string().optional(),
53582
+ requireActiveNoteId: external_exports.string().optional()
53461
53583
  }
53462
53584
  },
53463
- withErrorHandling(({ id: id2, expectedContentHash, ...scopeArgs }) => {
53464
- const snapshot = readExactNoteSnapshot(id2);
53465
- if ("error" in snapshot) return errorResponse(snapshot.error);
53466
- if (snapshot.contentHash !== expectedContentHash) {
53467
- return errorResponse(revisionConflictMessage(snapshot.note.title));
53468
- }
53469
- const result = notesManager.deleteNoteByIdIfUnchanged(id2, snapshot.body, scopeFrom(scopeArgs));
53470
- if (result.status === "conflict") {
53471
- return errorResponse(revisionConflictMessage(snapshot.note.title));
53472
- }
53473
- if (result.status === "scope_conflict") {
53474
- return errorResponse(scopeConflictMessage(result.reason));
53475
- }
53476
- if (result.status === "in-recently-deleted") {
53477
- return errorResponse(inRecentlyDeletedMessage(snapshot.note.title));
53478
- }
53479
- if (result.status === "container-unknown") {
53480
- return errorResponse(containerUnknownMessage(snapshot.note.title));
53481
- }
53482
- if (result.status === "not-deleted") {
53483
- return errorResponse(NOT_DELETED_MESSAGE);
53484
- }
53485
- if (result.status !== "deleted") {
53486
- return errorResponse(
53487
- `The delete result for note "${snapshot.note.title}" is uncertain. Inspect exact ID ${id2} before retrying.`
53488
- );
53489
- }
53490
- const sharedWarning = snapshot.note.shared ? "\n\n\u26A0\uFE0F This note was shared with collaborators. They will no longer have access." : "";
53491
- return successResponse(
53492
- `Note moved to Recently Deleted: "${snapshot.note.title}"${sharedWarning}`,
53493
- {
53494
- ok: true,
53585
+ withErrorHandling(
53586
+ ({
53587
+ id: id2,
53588
+ expectedContentHash,
53589
+ guardNoteId,
53590
+ expectedGuardContentHash,
53591
+ requireActiveNoteId,
53592
+ ...scopeArgs
53593
+ }) => {
53594
+ const prepared = prepareDeleteGuards({
53495
53595
  id: id2,
53496
- title: snapshot.note.title,
53497
- wasShared: snapshot.note.shared ?? false,
53498
- previousContentHash: expectedContentHash
53596
+ guardNoteId,
53597
+ expectedGuardContentHash,
53598
+ requireActiveNoteId
53599
+ });
53600
+ if ("error" in prepared) return errorResponse(prepared.error);
53601
+ const snapshot = readExactNoteSnapshot(id2);
53602
+ if ("error" in snapshot) return errorResponse(snapshot.error);
53603
+ if (snapshot.contentHash !== expectedContentHash) {
53604
+ return errorResponse(revisionConflictMessage(snapshot.note.title));
53499
53605
  }
53500
- );
53501
- }, "Error deleting note")
53606
+ const result = notesManager.deleteNoteByIdIfUnchanged(
53607
+ id2,
53608
+ snapshot.body,
53609
+ scopeFrom(scopeArgs),
53610
+ prepared.guards
53611
+ );
53612
+ if (result.status === "guard-conflict") {
53613
+ return errorResponse(
53614
+ `${prepared.labels[result.index]} note changed just before the delete. Nothing was deleted; verify it again before retrying.`
53615
+ );
53616
+ }
53617
+ if (result.status === "guard-inactive") {
53618
+ return errorResponse(
53619
+ `${prepared.labels[result.index]} note is no longer active (${result.reason}). Nothing was deleted.`
53620
+ );
53621
+ }
53622
+ if (result.status === "conflict") {
53623
+ return errorResponse(revisionConflictMessage(snapshot.note.title));
53624
+ }
53625
+ if (result.status === "scope_conflict") {
53626
+ return errorResponse(scopeConflictMessage(result.reason));
53627
+ }
53628
+ if (result.status === "in-recently-deleted") {
53629
+ return errorResponse(inRecentlyDeletedMessage(snapshot.note.title));
53630
+ }
53631
+ if (result.status === "container-unknown") {
53632
+ return errorResponse(containerUnknownMessage(snapshot.note.title));
53633
+ }
53634
+ if (result.status === "not-deleted") {
53635
+ return errorResponse(NOT_DELETED_MESSAGE);
53636
+ }
53637
+ if (result.status !== "deleted") {
53638
+ return errorResponse(
53639
+ `The delete result for note "${snapshot.note.title}" is uncertain. Inspect exact ID ${id2} before retrying.`
53640
+ );
53641
+ }
53642
+ const sharedWarning = snapshot.note.shared ? "\n\n\u26A0\uFE0F This note was shared with collaborators. They will no longer have access." : "";
53643
+ return successResponse(
53644
+ `Note moved to Recently Deleted: "${snapshot.note.title}"${sharedWarning}`,
53645
+ {
53646
+ ok: true,
53647
+ id: id2,
53648
+ title: snapshot.note.title,
53649
+ wasShared: snapshot.note.shared ?? false,
53650
+ previousContentHash: expectedContentHash,
53651
+ ...guardNoteId ? { guardNoteId, guardContentHash: prepared.guardContentHash } : {},
53652
+ ...requireActiveNoteId ? { requireActiveNoteId } : {}
53653
+ }
53654
+ );
53655
+ },
53656
+ "Error deleting note"
53657
+ )
53502
53658
  );
53503
53659
  registerTool(
53504
53660
  "move-note",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.9.0",
3
+ "version": "2.9.1",
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",