apple-notes-mcp 2.7.5 → 2.8.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.
package/build/index.js CHANGED
@@ -39395,6 +39395,15 @@ function sanitizeId(id) {
39395
39395
  }
39396
39396
  return escapeForAppleScript(id);
39397
39397
  }
39398
+ function sanitizeNoteId(id) {
39399
+ const noteIdPattern = /^x-coredata:\/\/[0-9A-Fa-f-]+\/ICNote\/p\d+$/;
39400
+ if (!noteIdPattern.test(id)) {
39401
+ throw new Error(
39402
+ `Invalid note ID format: "${id.substring(0, 80)}". Expected canonical Apple Note ID (x-coredata://.../ICNote/p...).`
39403
+ );
39404
+ }
39405
+ return escapeForAppleScript(id);
39406
+ }
39398
39407
  function sanitizeAccountName(account) {
39399
39408
  validateLength(account, MAX_ACCOUNT_LENGTH, "Account name");
39400
39409
  return escapePlainStringForAppleScript(account);
@@ -39687,10 +39696,19 @@ var AppleNotesManager = class {
39687
39696
  }
39688
39697
  const rawOutput = result.output.trim();
39689
39698
  const noteId = extractCoreDataId(rawOutput, "note") || rawOutput;
39699
+ if (!noteId) {
39700
+ console.error(`Created note "${title}" but Notes.app returned no canonical note ID`);
39701
+ return null;
39702
+ }
39703
+ try {
39704
+ sanitizeNoteId(noteId);
39705
+ } catch {
39706
+ console.error(`Created note "${title}" but Notes.app returned an invalid note ID`);
39707
+ return null;
39708
+ }
39690
39709
  const now = /* @__PURE__ */ new Date();
39691
39710
  return {
39692
- id: noteId || generateFallbackId(),
39693
- // Use real ID, fallback to unique temp ID
39711
+ id: noteId,
39694
39712
  title,
39695
39713
  content,
39696
39714
  tags,
@@ -40014,141 +40032,75 @@ var AppleNotesManager = class {
40014
40032
  };
40015
40033
  }
40016
40034
  /**
40017
- * Deletes a note by its title.
40018
- *
40019
- * Note: This permanently deletes the note. It may be recoverable
40020
- * from the "Recently Deleted" folder in Notes.app.
40021
- *
40022
- * @param title - Exact title of the note to delete
40023
- * @param account - Account containing the note (defaults to Notes.app's default account)
40024
- * @returns true if deletion succeeded, false otherwise
40025
- */
40026
- deleteNote(title, account) {
40027
- const targetAccount = this.resolveAccount(account);
40028
- const safeTitle = escapePlainStringForAppleScript(title);
40029
- const deleteCommand = `delete note "${safeTitle}"`;
40030
- const script = buildAccountScopedScript({ account: targetAccount }, deleteCommand);
40031
- const result = executeMutationAppleScript(script);
40032
- if (!result.success) {
40033
- throwIfAccountResolutionFailed(result.error);
40034
- console.error(`Failed to delete note "${title}":`, result.error);
40035
- return false;
40036
- }
40037
- return true;
40038
- }
40039
- /**
40040
- * Deletes a note by its CoreData ID.
40041
- *
40042
- * This is more reliable than deleteNote() because IDs are unique
40043
- * across all accounts, while titles can be duplicated.
40044
- *
40045
- * @param id - CoreData URL identifier for the note
40046
- * @returns true if deletion succeeded, false otherwise
40047
- */
40048
- deleteNoteById(id) {
40049
- const safeId = sanitizeId(id);
40050
- const deleteCommand = `delete note id "${safeId}"`;
40051
- const script = buildAppLevelScript(deleteCommand);
40052
- const result = executeMutationAppleScript(script);
40053
- if (!result.success) {
40054
- console.error(`Failed to delete note with ID "${id}":`, result.error);
40055
- return false;
40056
- }
40057
- return true;
40058
- }
40059
- /**
40060
- * Updates an existing note's content and optionally its title.
40061
- *
40062
- * Apple Notes derives the title from the first line of the body,
40063
- * so updating content also allows title changes. If newTitle is
40064
- * not provided, the original title is preserved.
40065
- *
40066
- * When format is 'html', newTitle is ignored — the caller must include
40067
- * the title in the HTML content.
40068
- *
40069
- * Note: Password-protected notes will fail with an AppleScript error.
40070
- * Callers should check for password protection beforehand using
40071
- * getNoteDetails() or isNotePasswordProtected().
40035
+ * Replaces one exact note body only if the body is still the snapshot the
40036
+ * caller reviewed and the note has no attachments.
40072
40037
  *
40073
- * @param title - Current title of the note to update
40074
- * @param newTitle - New title (optional, keeps existing if not provided; ignored in html format)
40075
- * @param newContent - New content for the note body
40076
- * @param account - Account containing the note (defaults to Notes.app's default account)
40077
- * @param format - Content format: "plaintext" wraps in div tags (default), "html" uses content as-is
40078
- * @returns true if update succeeded, false otherwise
40038
+ * Both guards and the write execute inside one AppleScript. This closes the
40039
+ * race that would exist if JavaScript checked the note and then issued a
40040
+ * separate unconditional `set body` command.
40079
40041
  */
40080
- updateNote(title, newTitle, newContent, account, format = "plaintext") {
40042
+ updateNoteByIdIfUnchanged(id, currentTitle, expectedBody, newTitle, newContent, format = "plaintext") {
40043
+ const safeId = sanitizeNoteId(id);
40081
40044
  if (newTitle) validateLength(newTitle, MAX_TITLE_LENGTH, "Note title");
40082
40045
  validateLength(newContent, MAX_CONTENT_LENGTH, "Note content");
40083
- const targetAccount = this.resolveAccount(account);
40084
- const safeCurrentTitle = escapePlainStringForAppleScript(title);
40085
- let fullBody;
40046
+ validateLength(expectedBody, MAX_CONTENT_LENGTH, "Expected note content");
40047
+ let writtenBody;
40086
40048
  if (format === "html") {
40087
- fullBody = escapeHtmlForAppleScript(newContent);
40049
+ writtenBody = newContent;
40088
40050
  } else {
40089
- const effectiveTitle = newTitle || title;
40090
- const safeEffectiveTitle = escapeForAppleScript(effectiveTitle);
40091
- const safeContent = escapeForAppleScript(newContent);
40092
- fullBody = `<div>${safeEffectiveTitle}</div><div>${safeContent}</div>`;
40051
+ const effectiveTitle = newTitle || currentTitle;
40052
+ const encodePlaintext = (value) => escapeForAppleScript(value).replace(/\\"/g, '"');
40053
+ writtenBody = `<div>${encodePlaintext(effectiveTitle)}</div><div>${encodePlaintext(newContent)}</div>`;
40093
40054
  }
40094
- const updateCommand = `set body of note "${safeCurrentTitle}" to "${fullBody}"`;
40095
- const script = buildAccountScopedScript({ account: targetAccount }, updateCommand);
40055
+ const safeExpectedBody = escapeHtmlForAppleScript(expectedBody);
40056
+ const safeWrittenBody = escapeHtmlForAppleScript(writtenBody);
40057
+ const script = buildAppLevelScript(`
40058
+ set noteRef to note id "${safeId}"
40059
+ if (count of attachments of noteRef) is greater than 0 then return "SAFETY_ATTACHMENTS"
40060
+ set currentBody to body of noteRef
40061
+ considering case
40062
+ if currentBody is not "${safeExpectedBody}" and currentBody is not "${safeExpectedBody}" & linefeed then return "SAFETY_CONFLICT"
40063
+ set body of noteRef to "${safeWrittenBody}"
40064
+ end considering
40065
+ return "SAFETY_UPDATED"
40066
+ `);
40096
40067
  const result = executeMutationAppleScript(script);
40097
40068
  if (!result.success) {
40098
- throwIfAccountResolutionFailed(result.error);
40099
- console.error(`Failed to update note "${title}":`, result.error);
40100
- return false;
40069
+ console.error(`Failed guarded update for note ID "${id}":`, result.error);
40070
+ return { status: "failed" };
40101
40071
  }
40102
- return true;
40072
+ const status = result.output.trim();
40073
+ if (status === "SAFETY_CONFLICT") return { status: "conflict" };
40074
+ if (status === "SAFETY_ATTACHMENTS") return { status: "attachments" };
40075
+ if (status !== "SAFETY_UPDATED") return { status: "failed" };
40076
+ return { status: "updated", writtenBody };
40103
40077
  }
40104
40078
  /**
40105
- * Updates an existing note by its CoreData ID.
40106
- *
40107
- * This is more reliable than updateNote() because IDs are unique,
40108
- * while titles can be duplicated.
40109
- *
40110
- * When format is 'html', newTitle is ignored — the caller must include
40111
- * the title in the HTML content.
40112
- *
40113
- * Note: Password-protected notes will fail with an AppleScript error.
40114
- * Callers should check for password protection beforehand using
40115
- * getNoteById() or isNotePasswordProtectedById().
40116
- *
40117
- * @param id - CoreData URL identifier for the note
40118
- * @param newTitle - New title (optional, keeps existing if not provided; ignored in html format)
40119
- * @param newContent - New content for the note body
40120
- * @param format - Content format: "plaintext" wraps in div tags (default), "html" uses content as-is
40121
- * @returns true if update succeeded, false otherwise
40079
+ * Deletes one exact note only when its complete body still matches the body
40080
+ * the caller reviewed. The comparison and delete are one AppleScript action,
40081
+ * so a concurrent edit cannot slip between the guard and deletion.
40122
40082
  */
40123
- updateNoteById(id, newTitle, newContent, format = "plaintext") {
40124
- if (newTitle) validateLength(newTitle, MAX_TITLE_LENGTH, "Note title");
40125
- validateLength(newContent, MAX_CONTENT_LENGTH, "Note content");
40126
- let fullBody;
40127
- if (format === "html") {
40128
- fullBody = escapeHtmlForAppleScript(newContent);
40129
- } else {
40130
- let effectiveTitle = newTitle;
40131
- if (!effectiveTitle) {
40132
- const note = this.getNoteById(id);
40133
- if (!note) {
40134
- console.error(`Cannot update note: note with ID "${id}" not found`);
40135
- return false;
40136
- }
40137
- effectiveTitle = note.title;
40138
- }
40139
- const safeEffectiveTitle = escapeForAppleScript(effectiveTitle);
40140
- const safeContent = escapeForAppleScript(newContent);
40141
- fullBody = `<div>${safeEffectiveTitle}</div><div>${safeContent}</div>`;
40142
- }
40143
- const safeId = sanitizeId(id);
40144
- const updateCommand = `set body of note id "${safeId}" to "${fullBody}"`;
40145
- const script = buildAppLevelScript(updateCommand);
40083
+ deleteNoteByIdIfUnchanged(id, expectedBody) {
40084
+ const safeId = sanitizeNoteId(id);
40085
+ validateLength(expectedBody, MAX_CONTENT_LENGTH, "Expected note content");
40086
+ const safeExpectedBody = escapeHtmlForAppleScript(expectedBody);
40087
+ const script = buildAppLevelScript(`
40088
+ set noteRef to note id "${safeId}"
40089
+ set currentBody to body of noteRef
40090
+ considering case
40091
+ if currentBody is not "${safeExpectedBody}" and currentBody is not "${safeExpectedBody}" & linefeed then return "SAFETY_CONFLICT"
40092
+ delete noteRef
40093
+ end considering
40094
+ return "SAFETY_DELETED"
40095
+ `);
40146
40096
  const result = executeMutationAppleScript(script);
40147
40097
  if (!result.success) {
40148
- console.error(`Failed to update note with ID "${id}":`, result.error);
40149
- return false;
40098
+ console.error(`Failed guarded delete for note ID "${id}":`, result.error);
40099
+ return { status: "failed" };
40150
40100
  }
40151
- return true;
40101
+ const status = result.output.trim();
40102
+ if (status === "SAFETY_CONFLICT") return { status: "conflict" };
40103
+ return status === "SAFETY_DELETED" ? { status: "deleted" } : { status: "failed" };
40152
40104
  }
40153
40105
  /**
40154
40106
  * Builds the AppleScript body for a bulk note listing.
@@ -40532,32 +40484,6 @@ var AppleNotesManager = class {
40532
40484
  }
40533
40485
  return true;
40534
40486
  }
40535
- /**
40536
- * Moves a note to a different folder, looked up by title.
40537
- *
40538
- * Uses Notes.app's native `move` command (the same one `batchMoveNotes`
40539
- * uses), which relocates the note in place — preserving its identity, id,
40540
- * creation date, AND all embedded attachments (files/images/PDFs/scans/audio).
40541
- * The previous copy-then-delete implementation rebuilt the note from its body
40542
- * HTML, which silently dropped attachments and reset the note's identity.
40543
- *
40544
- * The note is resolved to its id first (titles can be duplicated), then moved
40545
- * by id so the title-based and id-based paths share the same native move.
40546
- *
40547
- * @param title - Title of the note to move
40548
- * @param destinationFolder - Name of the folder to move to (must already exist)
40549
- * @param account - Account containing the note (defaults to Notes.app's default account)
40550
- * @returns true if the move succeeded, false otherwise
40551
- */
40552
- moveNote(title, destinationFolder, account) {
40553
- const targetAccount = this.resolveAccount(account);
40554
- const originalNote = this.getNoteDetails(title, targetAccount);
40555
- if (!originalNote) {
40556
- console.error(`Cannot move note "${title}": note not found`);
40557
- return false;
40558
- }
40559
- return this.moveNoteById(originalNote.id, destinationFolder, targetAccount);
40560
- }
40561
40487
  /**
40562
40488
  * Moves a note to a different folder by its CoreData ID.
40563
40489
  *
@@ -40573,13 +40499,17 @@ var AppleNotesManager = class {
40573
40499
  */
40574
40500
  moveNoteById(id, destinationFolder, account) {
40575
40501
  const targetAccount = this.resolveAccount(account);
40576
- const safeId = sanitizeId(id);
40502
+ const safeId = sanitizeNoteId(id);
40577
40503
  const destFolderRef = `${buildFolderReference(destinationFolder)} of ${AS_ACCOUNT_REF}`;
40578
40504
  const moveCommand = `
40579
40505
  ${buildAccountResolution(targetAccount)}
40580
40506
  set destFolder to ${destFolderRef}
40581
40507
  set noteRef to note id "${safeId}"
40582
40508
  move noteRef to destFolder
40509
+ set movedNoteRef to note id "${safeId}"
40510
+ set actualFolder to container of movedNoteRef
40511
+ if (id of actualFolder) is not (id of destFolder) then return "SAFETY_WRONG_FOLDER"
40512
+ return "SAFETY_MOVED"
40583
40513
  `;
40584
40514
  const script = buildAppLevelScript(moveCommand);
40585
40515
  const result = executeMutationAppleScript(script);
@@ -40591,6 +40521,12 @@ var AppleNotesManager = class {
40591
40521
  );
40592
40522
  return false;
40593
40523
  }
40524
+ if (result.output.trim() !== "SAFETY_MOVED") {
40525
+ console.error(
40526
+ `Move result for note ID "${id}" did not verify destination "${destinationFolder}"`
40527
+ );
40528
+ return false;
40529
+ }
40594
40530
  return true;
40595
40531
  }
40596
40532
  // ===========================================================================
@@ -41375,95 +41311,10 @@ var AppleNotesManager = class {
41375
41311
  createBatchResult(id, success, error2) {
41376
41312
  return error2 ? { id, success, error: error2 } : { id, success };
41377
41313
  }
41378
- /**
41379
- * Deletes multiple notes by their IDs.
41380
- *
41381
- * Each deletion is attempted independently; failures don't stop other deletions.
41382
- * Returns results for each note indicating success or failure.
41383
- *
41384
- * @param ids - Array of CoreData URL identifiers for notes to delete
41385
- * @returns Array of results with id, success status, and optional error message
41386
- *
41387
- * @example
41388
- * ```typescript
41389
- * const results = manager.batchDeleteNotes([
41390
- * "x-coredata://ABC/ICNote/p1",
41391
- * "x-coredata://ABC/ICNote/p2"
41392
- * ]);
41393
- * results.forEach(r => {
41394
- * if (r.success) console.log(`Deleted ${r.id}`);
41395
- * else console.log(`Failed to delete ${r.id}: ${r.error}`);
41396
- * });
41397
- * ```
41398
- */
41399
- batchDeleteNotes(ids) {
41400
- if (ids.length === 0) return [];
41401
- const results = new Array(ids.length);
41402
- const runnable = [];
41403
- ids.forEach((id, i) => {
41404
- try {
41405
- runnable.push({ index: i, safe: sanitizeId(id) });
41406
- } catch (e) {
41407
- results[i] = this.createBatchResult(
41408
- id,
41409
- false,
41410
- e instanceof Error ? e.message : "Invalid note ID"
41411
- );
41412
- }
41413
- });
41414
- if (runnable.length > 0) {
41415
- const idList = runnable.map((r) => `"${r.safe}"`).join(", ");
41416
- const script = buildAppLevelScript(`
41417
- set out to ""
41418
- repeat with rawId in {${idList}}
41419
- set theId to (rawId as text)
41420
- set noteRef to missing value
41421
- try
41422
- set noteRef to note id theId
41423
- end try
41424
- if noteRef is missing value then
41425
- set out to out & "missing" & ${AS_RECORD_SEP}
41426
- else
41427
- set isPw to false
41428
- try
41429
- set isPw to (password protected of noteRef)
41430
- end try
41431
- if isPw then
41432
- set out to out & "pw" & ${AS_RECORD_SEP}
41433
- else
41434
- try
41435
- delete noteRef
41436
- set out to out & "ok" & ${AS_RECORD_SEP}
41437
- on error
41438
- set out to out & "fail" & ${AS_RECORD_SEP}
41439
- end try
41440
- end if
41441
- end if
41442
- end repeat
41443
- return out
41444
- `);
41445
- const res = executeMutationAppleScript(script);
41446
- if (!res.success) {
41447
- for (const r of runnable) {
41448
- results[r.index] = this.createBatchResult(
41449
- ids[r.index],
41450
- false,
41451
- res.error ?? "Batch delete failed"
41452
- );
41453
- }
41454
- } else {
41455
- const statuses = res.output.split(RECORD_SEP).map((s) => s.trim()).filter((s) => s.length > 0);
41456
- runnable.forEach((r, k) => {
41457
- results[r.index] = this.mapBatchStatus(ids[r.index], statuses[k], "delete");
41458
- });
41459
- }
41460
- }
41461
- return results;
41462
- }
41463
41314
  /**
41464
41315
  * Maps a per-item status token emitted by a batch AppleScript loop to a
41465
41316
  * BatchResult, preserving the human-readable error messages of the original
41466
- * per-note implementation. See {@link batchDeleteNotes} / {@link batchMoveNotes}.
41317
+ * per-note implementation. See {@link batchMoveNotes}.
41467
41318
  */
41468
41319
  mapBatchStatus(id, status, op) {
41469
41320
  switch (status) {
@@ -41479,6 +41330,8 @@ var AppleNotesManager = class {
41479
41330
  false,
41480
41331
  op === "delete" ? "Deletion failed" : "Move failed"
41481
41332
  );
41333
+ case "wrongfolder":
41334
+ return this.createBatchResult(id, false, "Destination folder verification failed");
41482
41335
  default:
41483
41336
  return this.createBatchResult(id, false, "Unknown error");
41484
41337
  }
@@ -41510,7 +41363,7 @@ var AppleNotesManager = class {
41510
41363
  const runnable = [];
41511
41364
  ids.forEach((id, i) => {
41512
41365
  try {
41513
- runnable.push({ index: i, safe: sanitizeId(id) });
41366
+ runnable.push({ index: i, safe: sanitizeNoteId(id) });
41514
41367
  } catch (e) {
41515
41368
  results[i] = this.createBatchResult(
41516
41369
  id,
@@ -41543,7 +41396,13 @@ var AppleNotesManager = class {
41543
41396
  else
41544
41397
  try
41545
41398
  move noteRef to destFolder
41546
- set out to out & "ok" & ${AS_RECORD_SEP}
41399
+ set movedNoteRef to note id theId
41400
+ set actualFolder to container of movedNoteRef
41401
+ if (id of actualFolder) is (id of destFolder) then
41402
+ set out to out & "ok" & ${AS_RECORD_SEP}
41403
+ else
41404
+ set out to out & "wrongfolder" & ${AS_RECORD_SEP}
41405
+ end if
41547
41406
  on error
41548
41407
  set out to out & "fail" & ${AS_RECORD_SEP}
41549
41408
  end try
@@ -42391,6 +42250,18 @@ function withJsonSchema2020_12(transport2) {
42391
42250
  return transport2;
42392
42251
  }
42393
42252
 
42253
+ // src/utils/noteRevision.ts
42254
+ import { createHash } from "node:crypto";
42255
+ function hashNoteContent(content) {
42256
+ return `sha256:${createHash("sha256").update(content, "utf8").digest("hex")}`;
42257
+ }
42258
+ function comparableVisibleText(html) {
42259
+ return html.replace(/<br\s*\/?\s*>/gi, " ").replace(/<[^>]*>/g, " ").replace(/&nbsp;|&#160;/gi, " ").replace(/&quot;/gi, '"').replace(/&#39;|&apos;/gi, "'").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&amp;/gi, "&").replace(/&#(\d+);/g, (_match, codePoint) => String.fromCodePoint(Number(codePoint))).replace(
42260
+ /&#x([0-9a-f]+);/gi,
42261
+ (_match, codePoint) => String.fromCodePoint(Number.parseInt(codePoint, 16))
42262
+ ).replace(/\s+/g, " ").trim();
42263
+ }
42264
+
42394
42265
  // src/index.ts
42395
42266
  loadFileConfig();
42396
42267
  var require2 = createRequire(import.meta.url);
@@ -42441,6 +42312,28 @@ var noteTitleSchema = {
42441
42312
  "Account name (defaults to Notes.app's default account; exact or unique-prefix match)"
42442
42313
  )
42443
42314
  };
42315
+ var noteIdInput = external_exports.string().min(1, "Note ID is required").max(MAX.ID).regex(
42316
+ /^x-coredata:\/\/[0-9A-Fa-f-]+\/ICNote\/p\d+$/,
42317
+ "A canonical Apple Note ID is required (x-coredata://.../ICNote/p...)"
42318
+ ).describe("Exact CoreData note ID returned by search-notes, list-notes, or create-note");
42319
+ var expectedContentHashInput = external_exports.string().regex(/^sha256:[a-f0-9]{64}$/, "expectedContentHash must come from get-note-content").describe(
42320
+ "Revision token returned by get-note-content for this exact ID. The mutation stops if the note changed since that read."
42321
+ );
42322
+ function readExactNoteSnapshot(id) {
42323
+ const note = notesManager.getNoteById(id);
42324
+ if (!note) return { error: `Note with ID "${id}" not found` };
42325
+ if (note.passwordProtected) {
42326
+ return {
42327
+ error: `Note "${note.title}" is password-protected and cannot be changed. Unlock it in Notes.app first.`
42328
+ };
42329
+ }
42330
+ const body = notesManager.getNoteContentById(id);
42331
+ if (!body) return { error: `Failed to read content of note "${note.title}"` };
42332
+ return { note, body, contentHash: hashNoteContent(body) };
42333
+ }
42334
+ function revisionConflictMessage(title) {
42335
+ return `Note "${title}" changed after it was read. Read it again and review the newer version before retrying.`;
42336
+ }
42444
42337
  var folderNameSchema = {
42445
42338
  name: external_exports.string().min(1, "Folder name is required").max(MAX.FOLDER),
42446
42339
  account: external_exports.string().max(MAX.ACCOUNT).optional().describe(
@@ -42480,7 +42373,9 @@ registerTool(
42480
42373
  id: external_exports.string().optional(),
42481
42374
  title: external_exports.string().optional(),
42482
42375
  folder: external_exports.string().optional(),
42483
- account: external_exports.string().optional()
42376
+ account: external_exports.string().optional(),
42377
+ contentHash: external_exports.string().optional(),
42378
+ verified: external_exports.boolean().optional()
42484
42379
  }
42485
42380
  },
42486
42381
  withErrorHandling(({ title, content, format = "plaintext", tags = [], folder, account }) => {
@@ -42491,13 +42386,23 @@ registerTool(
42491
42386
  `Failed to create note "${title}".${target} Otherwise check that Notes.app is running and this server has Automation access (run the doctor tool).`
42492
42387
  );
42493
42388
  }
42389
+ const created = notesManager.getNoteById(note.id);
42390
+ const createdBody = notesManager.getNoteContentById(note.id);
42391
+ if (!created || !createdBody) {
42392
+ return errorResponse(
42393
+ `A note may have been created, but its exact ID could not be verified. Do not retry automatically. Returned ID: ${note.id}`
42394
+ );
42395
+ }
42396
+ const contentHash = hashNoteContent(createdBody);
42494
42397
  const checklistWarning = detectChecklistAttempt(content) ?? "";
42495
42398
  return successResponse(`Note created: "${note.title}" [id: ${note.id}]${checklistWarning}`, {
42496
42399
  ok: true,
42497
42400
  id: note.id,
42498
42401
  title: note.title,
42499
42402
  folder,
42500
- account
42403
+ account,
42404
+ contentHash,
42405
+ verified: true
42501
42406
  });
42502
42407
  }, "Error creating note")
42503
42408
  );
@@ -42577,7 +42482,7 @@ ${noteList}${truncationNote}${syncNote}`,
42577
42482
  registerTool(
42578
42483
  "get-note-content",
42579
42484
  {
42580
- description: "Use when: reading the full body text of one known note, by id (preferred) or title.\nReturns: the note's content plus parsed hashtags, and strippedImages/truncated when the body was capped.\nDo not use when: you only need metadata (get-note-details) or Markdown with checklist state (get-note-markdown).\nNote: password-protected notes must be unlocked in Notes.app first.\nSafety: inline images larger than APPLE_NOTES_MCP_MAX_INLINE_IMAGE_BYTES (default 256 KB) are replaced with '[inline image omitted: ...]' text placeholders, so the returned body is lossy whenever truncated is true \u2014 do NOT write it back with update-note or the real images are replaced by that text. Use append-to-note to add content, or export the images with save-attachment / fetch-attachment first.",
42485
+ description: "Use when: reading the full body text of one known note, by id (preferred) or title.\nReturns: the exact note id, content, contentHash revision token, parsed hashtags, and strippedImages/truncated when the body was capped.\nDo not use when: you only need metadata (get-note-details) or Markdown with checklist state (get-note-markdown).\nNote: password-protected notes must be unlocked in Notes.app first.\nSafety: inline images larger than APPLE_NOTES_MCP_MAX_INLINE_IMAGE_BYTES (default 256 KB) are replaced with '[inline image omitted: ...]' text placeholders, so the returned body is lossy whenever truncated is true. Mutations refuse attachment-bearing notes; edit those in Notes.app.",
42581
42486
  inputSchema: {
42582
42487
  id: external_exports.string().max(MAX.ID).optional().describe("Note ID (preferred - more reliable than title)"),
42583
42488
  title: external_exports.string().max(MAX.TITLE).optional().describe("Note title (use id instead when available)"),
@@ -42586,8 +42491,10 @@ registerTool(
42586
42491
  )
42587
42492
  },
42588
42493
  outputSchema: {
42494
+ id: external_exports.string().optional(),
42589
42495
  title: external_exports.string().optional(),
42590
42496
  content: external_exports.string().optional(),
42497
+ contentHash: external_exports.string().optional(),
42591
42498
  hashtags: external_exports.array(external_exports.string()).optional(),
42592
42499
  /** Number of oversized inline images replaced with text placeholders. */
42593
42500
  strippedImages: external_exports.number().optional(),
@@ -42615,8 +42522,10 @@ registerTool(
42615
42522
  const hashtags2 = parseHashtags(content2);
42616
42523
  const warning2 = strippedImagesWarning(stripped2);
42617
42524
  return successResponse(warning2 ? content2 + warning2 : content2, {
42525
+ id,
42618
42526
  title: note2.title,
42619
42527
  content: content2,
42528
+ contentHash: hashNoteContent(rawContent2),
42620
42529
  hashtags: hashtags2,
42621
42530
  strippedImages: stripped2.strippedCount,
42622
42531
  truncated: stripped2.strippedCount > 0
@@ -42643,8 +42552,10 @@ registerTool(
42643
42552
  const hashtags = parseHashtags(content);
42644
42553
  const warning = strippedImagesWarning(stripped);
42645
42554
  return successResponse(warning ? content + warning : content, {
42555
+ id: note.id,
42646
42556
  title,
42647
42557
  content,
42558
+ contentHash: hashNoteContent(rawContent),
42648
42559
  hashtags,
42649
42560
  strippedImages: stripped.strippedCount,
42650
42561
  truncated: stripped.strippedCount > 0
@@ -42888,110 +42799,122 @@ registerTool(
42888
42799
  registerTool(
42889
42800
  "update-note",
42890
42801
  {
42891
- description: "Use when: changing the title and/or replacing the body of an existing note, by id (preferred) or title.\nReturns: confirmation; warns when the note is shared.\nDo not use when: creating a new note (create-note).\nSafety: newContent REPLACES the entire body \u2014 it does not append. Read the note first if you need to preserve existing text, and run list-attachments first when the note may hold files, images, scans, PDFs, or audio, since a full-body replace can drop embedded attachments. Edits to shared notes are immediately visible to all collaborators.",
42802
+ 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 atomically rejects stale content and attachment-bearing notes, then reads the same id back after saving. Notes.app normalizes HTML, so rich formatting is not claimed as byte-identical.",
42892
42803
  inputSchema: {
42893
- id: external_exports.string().max(MAX.ID).optional().describe("Note ID (preferred - more reliable than title)"),
42894
- title: external_exports.string().max(MAX.TITLE).optional().describe("Current note title (use id instead when available)"),
42804
+ id: noteIdInput,
42805
+ expectedContentHash: expectedContentHashInput,
42895
42806
  newTitle: external_exports.string().max(MAX.TITLE).optional().describe(
42896
42807
  "New title for plaintext updates. Ignored when format is 'html'; include the visible title as the first line of newContent instead."
42897
42808
  ),
42898
42809
  newContent: external_exports.string().min(1, "New content is required").max(MAX.CONTENT).describe(
42899
42810
  "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."
42900
42811
  ),
42901
- format: external_exports.enum(["plaintext", "html"]).optional().default("plaintext").describe("Content format: 'plaintext' (default) or 'html' for rich formatting"),
42902
- account: external_exports.string().max(MAX.ACCOUNT).optional().describe("Account containing the note (ignored if id is provided)")
42812
+ format: external_exports.enum(["plaintext", "html"]).optional().default("plaintext").describe("Content format: 'plaintext' (default) or 'html' for rich formatting")
42903
42813
  },
42904
42814
  outputSchema: {
42905
42815
  ok: external_exports.boolean().optional(),
42906
42816
  id: external_exports.string().optional(),
42907
42817
  title: external_exports.string().optional(),
42908
- shared: external_exports.boolean().optional()
42818
+ shared: external_exports.boolean().optional(),
42819
+ previousContentHash: external_exports.string().optional(),
42820
+ contentHash: external_exports.string().optional(),
42821
+ verifiedVisibleText: external_exports.boolean().optional()
42909
42822
  }
42910
42823
  },
42911
- withErrorHandling(({ id, title, newTitle, newContent, format = "plaintext", account }) => {
42912
- if (id) {
42913
- const note2 = notesManager.getNoteById(id);
42914
- if (!note2) {
42915
- return errorResponse(`Note with ID "${id}" not found`);
42916
- }
42917
- if (note2.passwordProtected) {
42918
- return errorResponse(
42919
- `Note "${note2.title}" is password-protected and cannot be updated. Unlock it in Notes.app first.`
42920
- );
42921
- }
42922
- const success2 = notesManager.updateNoteById(id, newTitle, newContent, format);
42923
- if (!success2) {
42924
- return errorResponse(`Failed to update note "${note2.title}"`);
42925
- }
42926
- const displayTitle = resolveUpdateResponseTitle(note2.title, newTitle, format, newContent);
42927
- const sharedWarning2 = note2.shared ? "\n\n\u26A0\uFE0F This note is shared with collaborators. Your changes will be visible to them." : "";
42928
- const checklistWarning2 = detectChecklistAttempt(newContent) ?? "";
42929
- return successResponse(`Note updated: "${displayTitle}"${sharedWarning2}${checklistWarning2}`, {
42930
- ok: true,
42931
- id,
42932
- title: displayTitle,
42933
- shared: note2.shared ?? false
42934
- });
42824
+ withErrorHandling(({ id, expectedContentHash, newTitle, newContent, format = "plaintext" }) => {
42825
+ const snapshot = readExactNoteSnapshot(id);
42826
+ if ("error" in snapshot) return errorResponse(snapshot.error);
42827
+ if (snapshot.contentHash !== expectedContentHash) {
42828
+ return errorResponse(revisionConflictMessage(snapshot.note.title));
42829
+ }
42830
+ const attachments = notesManager.listAttachmentsById(id);
42831
+ if (attachments.length > 0) {
42832
+ return errorResponse(
42833
+ `Note "${snapshot.note.title}" has ${attachments.length} attachment(s). Full-body replacement is blocked; edit it in Notes.app.`
42834
+ );
42935
42835
  }
42936
- if (!title) {
42937
- return errorResponse("Either 'id' or 'title' is required");
42836
+ const result = notesManager.updateNoteByIdIfUnchanged(
42837
+ id,
42838
+ snapshot.note.title,
42839
+ snapshot.body,
42840
+ newTitle,
42841
+ newContent,
42842
+ format
42843
+ );
42844
+ if (result.status === "conflict") {
42845
+ return errorResponse(revisionConflictMessage(snapshot.note.title));
42938
42846
  }
42939
- const note = notesManager.getNoteDetails(title, account);
42940
- if (!note) {
42847
+ if (result.status === "attachments") {
42941
42848
  return errorResponse(
42942
- `Note "${title}" not found. Use search-notes to find notes, then use the note's ID for reliable operations.`
42849
+ `Note "${snapshot.note.title}" gained an attachment before saving. No content was replaced.`
42943
42850
  );
42944
42851
  }
42945
- if (note.passwordProtected) {
42852
+ if (result.status !== "updated") {
42946
42853
  return errorResponse(
42947
- `Note "${title}" is password-protected and cannot be updated. Unlock it in Notes.app first.`
42854
+ `The update result for note "${snapshot.note.title}" is uncertain. Read the exact ID before retrying.`
42948
42855
  );
42949
42856
  }
42950
- const success = notesManager.updateNote(title, newTitle, newContent, account, format);
42951
- if (!success) {
42952
- return errorResponse(`Failed to update note "${title}"`);
42857
+ const readback = notesManager.getNoteContentById(id);
42858
+ const contentHash = readback ? hashNoteContent(readback) : "";
42859
+ if (!readback || comparableVisibleText(readback) !== comparableVisibleText(result.writtenBody)) {
42860
+ return errorResponse(
42861
+ `The note accepted an update, but exact-ID readback visible text did not match. Do not retry automatically; inspect note ID ${id} in Notes.app.`
42862
+ );
42953
42863
  }
42954
- const finalTitle = resolveUpdateResponseTitle(note.title, newTitle, format, newContent);
42955
- const sharedWarning = note.shared ? "\n\n\u26A0\uFE0F This note is shared with collaborators. Your changes will be visible to them." : "";
42864
+ const displayTitle = resolveUpdateResponseTitle(
42865
+ snapshot.note.title,
42866
+ newTitle,
42867
+ format,
42868
+ newContent
42869
+ );
42870
+ const sharedWarning = snapshot.note.shared ? "\n\n\u26A0\uFE0F This note is shared with collaborators. Your changes are visible to them." : "";
42956
42871
  const checklistWarning = detectChecklistAttempt(newContent) ?? "";
42957
- return successResponse(`Note updated: "${finalTitle}"${sharedWarning}${checklistWarning}`, {
42958
- ok: true,
42959
- title: finalTitle,
42960
- shared: note.shared ?? false
42961
- });
42872
+ return successResponse(
42873
+ `Note updated; visible text verified: "${displayTitle}" [id: ${id}]${sharedWarning}${checklistWarning}`,
42874
+ {
42875
+ ok: true,
42876
+ id,
42877
+ title: displayTitle,
42878
+ shared: snapshot.note.shared ?? false,
42879
+ previousContentHash: expectedContentHash,
42880
+ contentHash,
42881
+ verifiedVisibleText: true
42882
+ }
42883
+ );
42962
42884
  }, "Error updating note")
42963
42885
  );
42964
42886
  registerTool(
42965
42887
  "append-to-note",
42966
42888
  {
42967
- description: "Use when: adding content to an existing note without replacing it, by id (preferred) or title.\nReturns: confirmation with the note id and title.\nDo not use when: creating a new note (create-note) or replacing the entire body (update-note).\nSafety: reads the existing body first, concatenates, then writes back. Run list-attachments first if the note may hold embedded files \u2014 a full-body rewrite can drop attachments.",
42889
+ 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, the note changed since the read, or it has attachments.\nSafety: append still rewrites the full HTML body, so it uses the same exact-ID, revision, attachment, and readback guards as update-note. Notes.app normalizes HTML, so rich formatting is not claimed as byte-identical.",
42968
42890
  inputSchema: {
42969
- id: external_exports.string().max(MAX.ID).optional().describe("Note ID (preferred - more reliable than title)"),
42970
- title: external_exports.string().max(MAX.TITLE).optional().describe("Note title (use id instead when available)"),
42891
+ id: noteIdInput,
42892
+ expectedContentHash: expectedContentHashInput,
42971
42893
  content: external_exports.string().min(1, "Content to append is required").max(MAX.CONTENT).describe("Text to append to the note body"),
42972
42894
  position: external_exports.enum(["after", "before"]).optional().default("after").describe(
42973
42895
  "Where to insert: 'after' appends to the end (default), 'before' prepends to the start"
42974
42896
  ),
42975
42897
  separator: external_exports.string().max(20).optional().default("\n\n").describe("String placed between existing content and new content (default: two newlines)"),
42976
- format: external_exports.enum(["plaintext", "html"]).optional().default("plaintext").describe("Format of the content being appended: 'plaintext' (default) or 'html'"),
42977
- account: external_exports.string().max(MAX.ACCOUNT).optional().describe("Account containing the note (ignored if id is provided)")
42898
+ format: external_exports.enum(["plaintext", "html"]).optional().default("plaintext").describe("Format of the content being appended: 'plaintext' (default) or 'html'")
42978
42899
  },
42979
42900
  outputSchema: {
42980
42901
  ok: external_exports.boolean().optional(),
42981
42902
  id: external_exports.string().optional(),
42982
42903
  title: external_exports.string().optional(),
42983
- shared: external_exports.boolean().optional()
42904
+ shared: external_exports.boolean().optional(),
42905
+ previousContentHash: external_exports.string().optional(),
42906
+ contentHash: external_exports.string().optional(),
42907
+ verifiedVisibleText: external_exports.boolean().optional()
42984
42908
  }
42985
42909
  },
42986
42910
  withErrorHandling(
42987
42911
  ({
42988
42912
  id,
42989
- title,
42913
+ expectedContentHash,
42990
42914
  content,
42991
42915
  position = "after",
42992
42916
  separator = "\n\n",
42993
- format = "plaintext",
42994
- account
42917
+ format = "plaintext"
42995
42918
  }) => {
42996
42919
  const contentToHtml = (text) => {
42997
42920
  if (format === "html") return text;
@@ -43006,72 +42929,64 @@ registerTool(
43006
42929
  const escaped = sep2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
43007
42930
  return `<div>${escaped}</div>`;
43008
42931
  };
43009
- if (id) {
43010
- const note2 = notesManager.getNoteById(id);
43011
- if (!note2) {
43012
- return errorResponse(`Note with ID "${id}" not found`);
43013
- }
43014
- if (note2.passwordProtected) {
43015
- return errorResponse(
43016
- `Note "${note2.title}" is password-protected and cannot be updated. Unlock it in Notes.app first.`
43017
- );
43018
- }
43019
- const existingHtml2 = notesManager.getNoteContentById(id);
43020
- if (existingHtml2 === null || existingHtml2 === void 0) {
43021
- return errorResponse(`Failed to read content of note "${note2.title}"`);
43022
- }
43023
- const firstDivEnd2 = existingHtml2.indexOf("</div>");
43024
- const titleDiv2 = firstDivEnd2 !== -1 ? existingHtml2.slice(0, firstDivEnd2 + 6) : "";
43025
- const bodyHtml2 = firstDivEnd2 !== -1 ? existingHtml2.slice(firstDivEnd2 + 6) : existingHtml2;
43026
- const newBlock2 = contentToHtml(content);
43027
- const sepHtml2 = separatorToHtml(separator);
43028
- const combinedBody2 = position === "before" ? titleDiv2 + newBlock2 + sepHtml2 + bodyHtml2 : titleDiv2 + bodyHtml2 + sepHtml2 + newBlock2;
43029
- const success2 = notesManager.updateNoteById(id, void 0, combinedBody2, "html");
43030
- if (!success2) {
43031
- return errorResponse(`Failed to append to note "${note2.title}"`);
43032
- }
43033
- const sharedWarning2 = note2.shared ? "\n\n\u26A0\uFE0F This note is shared with collaborators. Your changes will be visible to them." : "";
43034
- return successResponse(`Note appended: "${note2.title}"${sharedWarning2}`, {
43035
- ok: true,
43036
- id,
43037
- title: note2.title,
43038
- shared: note2.shared ?? false
43039
- });
43040
- }
43041
- if (!title) {
43042
- return errorResponse("Either 'id' or 'title' is required");
42932
+ const snapshot = readExactNoteSnapshot(id);
42933
+ if ("error" in snapshot) return errorResponse(snapshot.error);
42934
+ if (snapshot.contentHash !== expectedContentHash) {
42935
+ return errorResponse(revisionConflictMessage(snapshot.note.title));
43043
42936
  }
43044
- const note = notesManager.getNoteDetails(title, account);
43045
- if (!note) {
42937
+ const attachments = notesManager.listAttachmentsById(id);
42938
+ if (attachments.length > 0) {
43046
42939
  return errorResponse(
43047
- `Note "${title}" not found. Use search-notes to find notes, then use the note's ID for reliable operations.`
42940
+ `Note "${snapshot.note.title}" has ${attachments.length} attachment(s). Append is blocked because it rewrites the full body; edit it in Notes.app.`
43048
42941
  );
43049
42942
  }
43050
- if (note.passwordProtected) {
42943
+ const firstDivEnd = snapshot.body.indexOf("</div>");
42944
+ const titleDiv = firstDivEnd !== -1 ? snapshot.body.slice(0, firstDivEnd + 6) : "";
42945
+ const bodyHtml = firstDivEnd !== -1 ? snapshot.body.slice(firstDivEnd + 6) : snapshot.body;
42946
+ const newBlock = contentToHtml(content);
42947
+ const sepHtml = separatorToHtml(separator);
42948
+ const combinedBody = position === "before" ? titleDiv + newBlock + sepHtml + bodyHtml : titleDiv + bodyHtml + sepHtml + newBlock;
42949
+ const result = notesManager.updateNoteByIdIfUnchanged(
42950
+ id,
42951
+ snapshot.note.title,
42952
+ snapshot.body,
42953
+ void 0,
42954
+ combinedBody,
42955
+ "html"
42956
+ );
42957
+ if (result.status === "conflict") {
42958
+ return errorResponse(revisionConflictMessage(snapshot.note.title));
42959
+ }
42960
+ if (result.status === "attachments") {
43051
42961
  return errorResponse(
43052
- `Note "${title}" is password-protected and cannot be updated. Unlock it in Notes.app first.`
42962
+ `Note "${snapshot.note.title}" gained an attachment before saving. No content was appended.`
43053
42963
  );
43054
42964
  }
43055
- const existingHtml = notesManager.getNoteContent(title, account);
43056
- if (existingHtml === null || existingHtml === void 0) {
43057
- return errorResponse(`Failed to read content of note "${title}"`);
42965
+ if (result.status !== "updated") {
42966
+ return errorResponse(
42967
+ `The append result for note "${snapshot.note.title}" is uncertain. Read the exact ID before retrying.`
42968
+ );
43058
42969
  }
43059
- const firstDivEnd = existingHtml.indexOf("</div>");
43060
- const titleDiv = firstDivEnd !== -1 ? existingHtml.slice(0, firstDivEnd + 6) : "";
43061
- const bodyHtml = firstDivEnd !== -1 ? existingHtml.slice(firstDivEnd + 6) : existingHtml;
43062
- const newBlock = contentToHtml(content);
43063
- const sepHtml = separatorToHtml(separator);
43064
- const combinedBody = position === "before" ? titleDiv + newBlock + sepHtml + bodyHtml : titleDiv + bodyHtml + sepHtml + newBlock;
43065
- const success = notesManager.updateNote(title, void 0, combinedBody, account, "html");
43066
- if (!success) {
43067
- return errorResponse(`Failed to append to note "${title}"`);
42970
+ const readback = notesManager.getNoteContentById(id);
42971
+ const contentHash = readback ? hashNoteContent(readback) : "";
42972
+ if (!readback || comparableVisibleText(readback) !== comparableVisibleText(result.writtenBody)) {
42973
+ return errorResponse(
42974
+ `The note accepted an append, but exact-ID readback visible text did not match. Do not retry automatically; inspect note ID ${id} in Notes.app.`
42975
+ );
43068
42976
  }
43069
- const sharedWarning = note.shared ? "\n\n\u26A0\uFE0F This note is shared with collaborators. Your changes will be visible to them." : "";
43070
- return successResponse(`Note appended: "${title}"${sharedWarning}`, {
43071
- ok: true,
43072
- title,
43073
- shared: note.shared ?? false
43074
- });
42977
+ const sharedWarning = snapshot.note.shared ? "\n\n\u26A0\uFE0F This note is shared with collaborators. Your changes are visible to them." : "";
42978
+ return successResponse(
42979
+ `Note appended; visible text verified: "${snapshot.note.title}"${sharedWarning}`,
42980
+ {
42981
+ ok: true,
42982
+ id,
42983
+ title: snapshot.note.title,
42984
+ shared: snapshot.note.shared ?? false,
42985
+ previousContentHash: expectedContentHash,
42986
+ contentHash,
42987
+ verifiedVisibleText: true
42988
+ }
42989
+ );
43075
42990
  },
43076
42991
  "Error appending to note"
43077
42992
  )
@@ -43079,67 +42994,53 @@ registerTool(
43079
42994
  registerTool(
43080
42995
  "delete-note",
43081
42996
  {
43082
- description: "Use when: permanently deleting a single note, by id (preferred) or title.\nReturns: confirmation; warns when the note was shared.\nDo not use when: deleting many notes (batch-delete-notes) or just relocating one (move-note).\nSafety: requires explicit user confirmation before deleting. Prefer search-notes/list-notes first to show the affected note id and title. Deleting a shared note removes collaborator access.",
42997
+ 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.",
43083
42998
  inputSchema: {
43084
- id: external_exports.string().max(MAX.ID).optional().describe("Note ID (preferred - more reliable than title)"),
43085
- title: external_exports.string().max(MAX.TITLE).optional().describe("Note title (use id instead when available)"),
43086
- account: external_exports.string().max(MAX.ACCOUNT).optional().describe(
43087
- "Account name (defaults to Notes.app's default account; exact or unique-prefix match, ignored if id is provided)"
43088
- )
42999
+ id: noteIdInput,
43000
+ expectedContentHash: expectedContentHashInput
43089
43001
  },
43090
43002
  outputSchema: {
43091
43003
  ok: external_exports.boolean().optional(),
43092
43004
  id: external_exports.string().optional(),
43093
43005
  title: external_exports.string().optional(),
43094
- wasShared: external_exports.boolean().optional()
43006
+ wasShared: external_exports.boolean().optional(),
43007
+ previousContentHash: external_exports.string().optional()
43095
43008
  }
43096
43009
  },
43097
- withErrorHandling(({ id, title, account }) => {
43098
- if (id) {
43099
- const note2 = notesManager.getNoteById(id);
43100
- if (!note2) {
43101
- return errorResponse(`Note with ID "${id}" not found`);
43102
- }
43103
- const success2 = notesManager.deleteNoteById(id);
43104
- if (!success2) {
43105
- return errorResponse(`Failed to delete note "${note2.title}"`);
43106
- }
43107
- const sharedWarning2 = note2.shared ? "\n\n\u26A0\uFE0F This note was shared with collaborators. They will no longer have access." : "";
43108
- return successResponse(`Note deleted: "${note2.title}"${sharedWarning2}`, {
43109
- ok: true,
43110
- id,
43111
- title: note2.title,
43112
- wasShared: note2.shared ?? false
43113
- });
43010
+ withErrorHandling(({ id, expectedContentHash }) => {
43011
+ const snapshot = readExactNoteSnapshot(id);
43012
+ if ("error" in snapshot) return errorResponse(snapshot.error);
43013
+ if (snapshot.contentHash !== expectedContentHash) {
43014
+ return errorResponse(revisionConflictMessage(snapshot.note.title));
43114
43015
  }
43115
- if (!title) {
43116
- return errorResponse("Either 'id' or 'title' is required");
43016
+ const result = notesManager.deleteNoteByIdIfUnchanged(id, snapshot.body);
43017
+ if (result.status === "conflict") {
43018
+ return errorResponse(revisionConflictMessage(snapshot.note.title));
43117
43019
  }
43118
- const note = notesManager.getNoteDetails(title, account);
43119
- if (!note) {
43020
+ if (result.status !== "deleted") {
43120
43021
  return errorResponse(
43121
- `Note "${title}" not found. Use search-notes to find notes, then use the note's ID for reliable operations.`
43022
+ `The delete result for note "${snapshot.note.title}" is uncertain. Inspect exact ID ${id} before retrying.`
43122
43023
  );
43123
43024
  }
43124
- const success = notesManager.deleteNote(title, account);
43125
- if (!success) {
43126
- return errorResponse(`Failed to delete note "${title}"`);
43127
- }
43128
- const sharedWarning = note.shared ? "\n\n\u26A0\uFE0F This note was shared with collaborators. They will no longer have access." : "";
43129
- return successResponse(`Note deleted: "${title}"${sharedWarning}`, {
43130
- ok: true,
43131
- title,
43132
- wasShared: note.shared ?? false
43133
- });
43025
+ const sharedWarning = snapshot.note.shared ? "\n\n\u26A0\uFE0F This note was shared with collaborators. They will no longer have access." : "";
43026
+ return successResponse(
43027
+ `Note moved to Recently Deleted: "${snapshot.note.title}"${sharedWarning}`,
43028
+ {
43029
+ ok: true,
43030
+ id,
43031
+ title: snapshot.note.title,
43032
+ wasShared: snapshot.note.shared ?? false,
43033
+ previousContentHash: expectedContentHash
43034
+ }
43035
+ );
43134
43036
  }, "Error deleting note")
43135
43037
  );
43136
43038
  registerTool(
43137
43039
  "move-note",
43138
43040
  {
43139
- description: "Use when: moving one note to a different folder, by id (preferred) or title.\nReturns: confirmation of the note and destination folder.\nDo not use when: moving many notes (batch-move-notes).\nNote: the note is relocated in place via Notes.app's native move, preserving its id, creation date, and all attachments. The destination folder must already exist (create-folder).",
43041
+ 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.",
43140
43042
  inputSchema: {
43141
- id: external_exports.string().max(MAX.ID).optional().describe("Note ID (preferred - more reliable than title)"),
43142
- title: external_exports.string().max(MAX.TITLE).optional().describe("Note title (use id instead when available)"),
43043
+ id: noteIdInput,
43143
43044
  folder: external_exports.string().min(1, "Destination folder is required").max(MAX.FOLDER),
43144
43045
  account: external_exports.string().max(MAX.ACCOUNT).optional().describe("Account containing the note/folder")
43145
43046
  },
@@ -43147,47 +43048,33 @@ registerTool(
43147
43048
  ok: external_exports.boolean().optional(),
43148
43049
  id: external_exports.string().optional(),
43149
43050
  title: external_exports.string().optional(),
43150
- folder: external_exports.string().optional()
43051
+ folder: external_exports.string().optional(),
43052
+ verified: external_exports.boolean().optional()
43151
43053
  }
43152
43054
  },
43153
- withErrorHandling(({ id, title, folder, account }) => {
43154
- if (id) {
43155
- const note2 = notesManager.getNoteById(id);
43156
- if (!note2) {
43157
- return errorResponse(`Note with ID "${id}" not found`);
43158
- }
43159
- const success2 = notesManager.moveNoteById(id, folder, account);
43160
- if (!success2) {
43161
- return errorResponse(
43162
- `Failed to move note "${note2.title}" to folder "${folder}". Folder may not exist.`
43163
- );
43164
- }
43165
- return successResponse(`Note moved: "${note2.title}" -> "${folder}"`, {
43166
- ok: true,
43167
- id,
43168
- title: note2.title,
43169
- folder
43170
- });
43171
- }
43172
- if (!title) {
43173
- return errorResponse("Either 'id' or 'title' is required");
43174
- }
43175
- const note = notesManager.getNoteDetails(title, account);
43055
+ withErrorHandling(({ id, folder, account }) => {
43056
+ const note = notesManager.getNoteById(id);
43176
43057
  if (!note) {
43058
+ return errorResponse(`Note with ID "${id}" not found`);
43059
+ }
43060
+ const success = notesManager.moveNoteById(id, folder, account);
43061
+ if (!success) {
43177
43062
  return errorResponse(
43178
- `Note "${title}" not found. Use search-notes to find notes, then use the note's ID for reliable operations.`
43063
+ `Failed to move note "${note.title}" to folder "${folder}". Folder may not exist.`
43179
43064
  );
43180
43065
  }
43181
- const success = notesManager.moveNote(title, folder, account);
43182
- if (!success) {
43066
+ const readback = notesManager.getNoteById(id);
43067
+ if (!readback || readback.id !== id) {
43183
43068
  return errorResponse(
43184
- `Failed to move note "${title}" to folder "${folder}". Folder may not exist.`
43069
+ `The move may have succeeded, but exact-ID readback failed. Inspect note ID ${id} before retrying.`
43185
43070
  );
43186
43071
  }
43187
- return successResponse(`Note moved: "${title}" -> "${folder}"`, {
43072
+ return successResponse(`Note moved and verified: "${readback.title}" -> "${folder}"`, {
43188
43073
  ok: true,
43189
- title,
43190
- folder
43074
+ id,
43075
+ title: readback.title,
43076
+ folder,
43077
+ verified: true
43191
43078
  });
43192
43079
  }, "Error moving note")
43193
43080
  );
@@ -43623,9 +43510,14 @@ ${attachmentList}`,
43623
43510
  registerTool(
43624
43511
  "batch-delete-notes",
43625
43512
  {
43626
- description: "Use when: permanently deleting multiple notes by id in one call.\nReturns: per-id success/failure counts.\nDo not use when: deleting a single note (delete-note).\nSafety: requires explicit user confirmation; this is destructive and not undoable. Prefer search-notes/list-notes first to confirm the exact ids being deleted.",
43513
+ description: "Use when: moving several reviewed notes to Recently Deleted.\nReturns: per-note success or conflict.\nDo not use when: deleting a single note.\nSafety: every entry requires an exact id and the content hash from get-note-content. Any note changed since review is preserved and reported as a conflict.",
43627
43514
  inputSchema: {
43628
- ids: external_exports.array(external_exports.string().max(MAX.ID)).max(MAX.BATCH_IDS).describe(`Array of note IDs to delete (max ${MAX.BATCH_IDS} per request)`)
43515
+ notes: external_exports.array(
43516
+ external_exports.object({
43517
+ id: noteIdInput,
43518
+ expectedContentHash: expectedContentHashInput
43519
+ })
43520
+ ).max(MAX.BATCH_IDS).describe(`Reviewed note IDs and revision tokens to delete (max ${MAX.BATCH_IDS})`)
43629
43521
  },
43630
43522
  outputSchema: {
43631
43523
  ok: external_exports.boolean().optional(),
@@ -43634,11 +43526,23 @@ registerTool(
43634
43526
  results: external_exports.array(external_exports.object({}).passthrough()).optional()
43635
43527
  }
43636
43528
  },
43637
- withErrorHandling(({ ids }) => {
43638
- if (ids.length === 0) {
43639
- return errorResponse("No note IDs provided");
43529
+ withErrorHandling(({ notes }) => {
43530
+ if (notes.length === 0) {
43531
+ return errorResponse("No reviewed notes provided");
43640
43532
  }
43641
- const results = notesManager.batchDeleteNotes(ids);
43533
+ const results = notes.map(({ id, expectedContentHash }) => {
43534
+ const snapshot = readExactNoteSnapshot(id);
43535
+ if ("error" in snapshot) return { id, success: false, error: snapshot.error };
43536
+ if (snapshot.contentHash !== expectedContentHash) {
43537
+ return { id, success: false, error: revisionConflictMessage(snapshot.note.title) };
43538
+ }
43539
+ const result = notesManager.deleteNoteByIdIfUnchanged(id, snapshot.body);
43540
+ if (result.status === "deleted") return { id, success: true };
43541
+ if (result.status === "conflict") {
43542
+ return { id, success: false, error: revisionConflictMessage(snapshot.note.title) };
43543
+ }
43544
+ return { id, success: false, error: "Delete result uncertain; inspect this exact ID" };
43545
+ });
43642
43546
  const succeeded = results.filter((r) => r.success).length;
43643
43547
  const failed = results.filter((r) => !r.success).length;
43644
43548
  const lines = [`Batch delete: ${succeeded} succeeded, ${failed} failed`];
@@ -43659,9 +43563,9 @@ registerTool(
43659
43563
  registerTool(
43660
43564
  "batch-move-notes",
43661
43565
  {
43662
- description: "Use when: moving multiple notes by id into one destination folder.\nReturns: per-id success/failure counts.\nDo not use when: moving a single note (move-note).\nNote: the destination folder must already exist (create-folder).",
43566
+ description: "Use when: moving multiple notes by id into one destination folder.\nReturns: per-id success/failure counts after destination-folder verification.\nDo not use when: moving a single note (move-note).\nSafety: each moved note's actual container ID is compared with the destination folder ID before success is reported. The destination folder must already exist (create-folder).",
43663
43567
  inputSchema: {
43664
- ids: external_exports.array(external_exports.string().max(MAX.ID)).max(MAX.BATCH_IDS).describe(`Array of note IDs to move (max ${MAX.BATCH_IDS} per request)`),
43568
+ ids: external_exports.array(noteIdInput).max(MAX.BATCH_IDS).describe(`Array of note IDs to move (max ${MAX.BATCH_IDS} per request)`),
43665
43569
  folder: external_exports.string().max(MAX.FOLDER).describe(
43666
43570
  'Destination folder name or nested path (e.g. "Work/Clients"). Must already exist \u2014 create-folder first.'
43667
43571
  ),