apple-notes-mcp 2.8.19 → 2.8.21

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 +63 -0
  2. package/build/index.js +312 -123
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -663,6 +663,69 @@ Ordinary notes take the guarded HTML path and are not restricted to that subset.
663
663
 
664
664
  ---
665
665
 
666
+ #### `insert-link`
667
+
668
+ Adds one web, mail or Notes link to an exact note as its own paragraph, then
669
+ proves it from the link runs Notes actually stored. Use `mode: "raw"` to show
670
+ the URL itself, or `mode: "hyperlink"` with a `label` to show text that links to
671
+ the URL. For a link to another note by id, use
672
+ [`insert-note-link`](#insert-note-link), which looks up that note's real deep
673
+ link.
674
+
675
+ | Parameter | Type | Required | Description |
676
+ |-----------|------|----------|-------------|
677
+ | `id` | string | Yes | Exact CoreData note ID |
678
+ | `expectedContentHash` | string | Yes | `contentHash` from the exact note version being extended |
679
+ | `url` | string | Yes | Absolute `http(s)` URL with a host, or a `mailto:`, `notes://` or `applenotes:` link. No spaces, `<`, `>` or `"` |
680
+ | `mode` | string | No | `"raw"` (default) shows the URL; `"hyperlink"` shows `label` |
681
+ | `label` | string | Hyperlink only | Visible text for `mode: "hyperlink"`; refused in raw mode |
682
+ | `linked` | boolean | No | Raw mode only. `true` (default) stores a real link on the URL text. `false` writes plain text with no stored link |
683
+ | `position` | string | No | `"end"` (default) or `"after-title"` (first paragraph under the title) |
684
+ | `blankLine` | boolean | No | Leave a blank line between existing text and the link paragraph (default `true`) |
685
+ | `scopeText` | string | Native-object notes only | Unique existing phrase, as for [`append-native`](#append-native) |
686
+
687
+ **Example - Hyperlink under the title:**
688
+ ```json
689
+ {
690
+ "id": "x-coredata://ABC123/ICNote/p456",
691
+ "expectedContentHash": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
692
+ "url": "https://example.com/report",
693
+ "mode": "hyperlink",
694
+ "label": "Quarterly report",
695
+ "position": "after-title"
696
+ }
697
+ ```
698
+
699
+ **Returns:** `route` (`"applescript"` for ordinary notes, `"native"` for notes
700
+ with native objects), `linkStored`, `storedUrl` (the destination read back from
701
+ the note, for example `https://example.com/` for a bare origin), and the new
702
+ `contentHash`.
703
+
704
+ **Safety:** The same guards as [`append-to-note`](#append-to-note): a fresh
705
+ `expectedContentHash`, the attachment block, and every existing link must
706
+ survive. A linked insert must add exactly one stored link with the requested
707
+ label and destination; if the text lands but that proof fails, the error says
708
+ the write happened so it is not repeated.
709
+
710
+ **Limits:**
711
+
712
+ - Plain URL text is not linked by Notes when written this way. With
713
+ `linked: false`, the note stores no link and readers of the body see ordinary
714
+ text; Notes.app may still underline the URL on screen through its own data
715
+ detection. The result reports `linkStored: false`.
716
+ - Notes with native objects (a table, a checklist, native tags) take the native
717
+ end-append path, so only `position: "end"` with `blankLine: true` works there.
718
+ - The link always gets its own paragraph. Placing it at the end of one existing
719
+ paragraph, or inside the text, is not available.
720
+ - Rich URL preview cards (the link tile Notes makes when you paste a URL) are
721
+ not produced. No public automation route creates one: the Shortcuts Notes
722
+ actions write text, and AppleScript's `body` has no card markup.
723
+ - To start a new note with a link, use [`create-note`](#create-note) with
724
+ `format: "html"` and an `<a href>` in `content`; the link is stored the same
725
+ way.
726
+
727
+ ---
728
+
666
729
  #### `get-note-link`
667
730
 
668
731
  Returns the `notes://showNote?identifier=<uuid>` deep-link URL for a note. The URL opens the note in Notes.app on iOS and macOS and can be stored in Reminders tasks or shared links.
package/build/index.js CHANGED
@@ -39331,6 +39331,15 @@ function embeddedMessage(field) {
39331
39331
  return decodeMessage(bytes);
39332
39332
  }
39333
39333
 
39334
+ // src/utils/checklistRuns.ts
39335
+ function checklistRunLineStart(text, position, length) {
39336
+ const end = position + length;
39337
+ let anchor = position;
39338
+ while (anchor < end && text[anchor] === "\n") anchor++;
39339
+ if (anchor === end) anchor = position;
39340
+ return anchor === 0 ? 0 : text.lastIndexOf("\n", anchor - 1) + 1;
39341
+ }
39342
+
39334
39343
  // src/utils/checklistParser.ts
39335
39344
  var CHECKLIST_STYLE_TYPE = 103;
39336
39345
  var NOTES_DB_PATH = path.join(
@@ -39398,6 +39407,9 @@ function parseChecklistFromProtobuf(data) {
39398
39407
  const attributeRuns = getFields(noteBodyFields, 5);
39399
39408
  if (attributeRuns.length === 0) return null;
39400
39409
  const lines = noteText.split("\n");
39410
+ const lineStarts = [];
39411
+ for (let i = 0, start = 0; i < lines.length; start += lines[i].length + 1, i++)
39412
+ lineStarts.push(start);
39401
39413
  const items = [];
39402
39414
  let charPos = 0;
39403
39415
  const seenLines = /* @__PURE__ */ new Set();
@@ -39414,18 +39426,13 @@ function parseChecklistFromProtobuf(data) {
39414
39426
  const checklistField = getField(styleFields, 5);
39415
39427
  const checklistFields = embeddedMessage(checklistField);
39416
39428
  const done = checklistFields ? varintValue(getField(checklistFields, 2)) ?? 0 : 0;
39417
- let lineStart = 0;
39418
- for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
39419
- const lineEnd = lineStart + lines[lineIdx].length;
39420
- if (charPos >= lineStart && charPos < lineEnd + 1 && !seenLines.has(lineIdx)) {
39421
- seenLines.add(lineIdx);
39422
- items.push({
39423
- text: lines[lineIdx],
39424
- done: done === 1
39425
- });
39426
- break;
39427
- }
39428
- lineStart = lineEnd + 1;
39429
+ const lineIdx = charPos <= noteText.length ? lineStarts.indexOf(checklistRunLineStart(noteText, charPos, runLength)) : -1;
39430
+ if (lineIdx !== -1 && !seenLines.has(lineIdx)) {
39431
+ seenLines.add(lineIdx);
39432
+ items.push({
39433
+ text: lines[lineIdx],
39434
+ done: done === 1
39435
+ });
39429
39436
  }
39430
39437
  }
39431
39438
  }
@@ -39616,7 +39623,7 @@ function parseRichNote(data, nativeTags = []) {
39616
39623
  const checklist = embeddedMessage(getField(paragraph, 5));
39617
39624
  const rawId = checklist && getField(checklist, 1)?.value;
39618
39625
  const itemId = rawId instanceof Uint8Array ? Buffer.from(rawId).toString("hex") : "";
39619
- const start = text.lastIndexOf("\n", position - 1) + 1;
39626
+ const start = checklistRunLineStart(text, position, length);
39620
39627
  if (itemId && !checklistItems.some((item) => item.id === itemId))
39621
39628
  checklistItems.push({
39622
39629
  id: itemId,
@@ -43789,6 +43796,121 @@ function parseNoteTable(compressed) {
43789
43796
  return { rows: values, rowIds: rows.ids, columnIds: columns.ids };
43790
43797
  }
43791
43798
 
43799
+ // src/utils/linkInsert.ts
43800
+ var MAX_LINK_URL_LENGTH = 4096;
43801
+ var MAX_LINK_LABEL_LENGTH = 2e3;
43802
+ var LINK_SCHEMES = /^(?:https?:\/\/[^/?#\s]+|mailto:[^\s]|notes:\/\/[^\s]|applenotes:[^\s])/i;
43803
+ function validateLinkUrl(url) {
43804
+ if (!url) throw new Error("Link URL is required");
43805
+ if (url.length > MAX_LINK_URL_LENGTH)
43806
+ throw new Error(`Link URL is longer than ${MAX_LINK_URL_LENGTH} characters`);
43807
+ if (Array.from(url).some((char) => char.charCodeAt(0) <= 32 || char.charCodeAt(0) === 127))
43808
+ throw new Error("Link URL cannot contain spaces, line breaks or control characters");
43809
+ if (/[<>"]/u.test(url)) throw new Error('Link URL cannot contain <, > or "');
43810
+ if (!LINK_SCHEMES.test(url))
43811
+ throw new Error(
43812
+ "Link URL must be an absolute http(s) URL with a host, or a mailto:, notes:// or applenotes: link"
43813
+ );
43814
+ return url;
43815
+ }
43816
+ function validateLinkLabel(label) {
43817
+ if (!label.trim()) throw new Error("Hyperlink label must contain visible text");
43818
+ if (label.length > MAX_LINK_LABEL_LENGTH)
43819
+ throw new Error(`Hyperlink label is longer than ${MAX_LINK_LABEL_LENGTH} characters`);
43820
+ if (Array.from(label).some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127))
43821
+ throw new Error("Hyperlink label must be one line without control characters");
43822
+ return label;
43823
+ }
43824
+ var escapeText = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
43825
+ var escapeHref = (url) => escapeText(url).replace(/"/g, "&quot;");
43826
+ function buildLinkInsertion(request) {
43827
+ const url = validateLinkUrl(request.url);
43828
+ if (request.mode === "hyperlink") {
43829
+ if (request.label === void 0) throw new Error("Hyperlink mode requires a label");
43830
+ if (request.linked === false)
43831
+ throw new Error("linked=false applies to raw mode only; a hyperlink is always linked");
43832
+ const label = validateLinkLabel(request.label);
43833
+ return {
43834
+ html: `<div><a href="${escapeHref(url)}">${escapeText(label)}</a></div>`,
43835
+ text: label,
43836
+ link: { text: label, url }
43837
+ };
43838
+ }
43839
+ if (request.label !== void 0)
43840
+ throw new Error("A label applies to hyperlink mode only; raw mode shows the URL itself");
43841
+ if (request.linked === false)
43842
+ return { html: `<div>${escapeText(url)}</div>`, text: url, link: null };
43843
+ return {
43844
+ html: `<div><a href="${escapeHref(url)}">${escapeText(url)}</a></div>`,
43845
+ text: url,
43846
+ link: { text: url, url }
43847
+ };
43848
+ }
43849
+ function countMatchingLinks(links, expected) {
43850
+ const wanted = linkSignature([expected]);
43851
+ return links.filter((link) => linkSignature([link]) === wanted).length;
43852
+ }
43853
+ function countLinksTo(links, url) {
43854
+ const wanted = linkSignature([{ text: "x", url }]);
43855
+ return links.filter((link) => linkSignature([{ text: "x", url: link.url }]) === wanted).length;
43856
+ }
43857
+ function verifyLinkReadback(before, after, insertion, url) {
43858
+ for (const link of before) {
43859
+ if (countMatchingLinks(after, link) < countMatchingLinks(before, link))
43860
+ throw new Error("An existing link changed during the insert; read the note before retrying");
43861
+ }
43862
+ if (insertion.link) {
43863
+ const added = countMatchingLinks(after, insertion.link) - countMatchingLinks(before, insertion.link);
43864
+ if (added !== 1)
43865
+ throw new Error(
43866
+ "The inserted link was not found in the note's stored links; read the note before retrying"
43867
+ );
43868
+ const stored = after.filter(
43869
+ (link) => linkSignature([link]) === linkSignature([insertion.link])
43870
+ );
43871
+ return { linkStored: true, storedUrl: stored[stored.length - 1].url };
43872
+ }
43873
+ const gained = countLinksTo(after, url) - countLinksTo(before, url);
43874
+ return gained > 0 ? {
43875
+ linkStored: true,
43876
+ storedUrl: after.filter((link) => countLinksTo([link], url) === 1).pop().url
43877
+ } : { linkStored: false };
43878
+ }
43879
+
43880
+ // src/services/linkInsert.ts
43881
+ function insertLink(request, deps) {
43882
+ const insertion = buildLinkInsertion(request);
43883
+ const before = deps.readLinks(request.id);
43884
+ const outcome = deps.append({
43885
+ id: request.id,
43886
+ expectedContentHash: request.expectedContentHash,
43887
+ content: insertion.html,
43888
+ position: request.position === "after-title" ? "before" : "after",
43889
+ separator: request.blankLine ? "\n\n" : "",
43890
+ scopeText: request.scopeText
43891
+ });
43892
+ let readback;
43893
+ try {
43894
+ readback = verifyLinkReadback(before, deps.readLinks(request.id), insertion, request.url);
43895
+ } catch (error2) {
43896
+ throw new Error(
43897
+ `The text was written, but link verification failed: ${error2 instanceof Error ? error2.message : String(error2)}. Do not retry automatically.`
43898
+ );
43899
+ }
43900
+ return {
43901
+ ok: true,
43902
+ id: request.id,
43903
+ mode: request.mode,
43904
+ url: request.url,
43905
+ text: insertion.text,
43906
+ position: request.position,
43907
+ route: outcome.route,
43908
+ ...readback,
43909
+ previousContentHash: request.expectedContentHash,
43910
+ contentHash: outcome.contentHash
43911
+ };
43912
+ }
43913
+
43792
43914
  // src/tools/directOperations.ts
43793
43915
  import { createHash as createHash2 } from "node:crypto";
43794
43916
  import {
@@ -45270,6 +45392,115 @@ registerTool(
45270
45392
  "Error updating note"
45271
45393
  )
45272
45394
  );
45395
+ function guardedAppend({
45396
+ id: id2,
45397
+ expectedContentHash,
45398
+ content,
45399
+ position = "after",
45400
+ separator = "\n\n",
45401
+ format = "plaintext",
45402
+ scopeText,
45403
+ htmlSeparator
45404
+ }, onRoute) {
45405
+ const contentToHtml = (text) => {
45406
+ if (format === "html") return text;
45407
+ return text.split("\n").map((line) => {
45408
+ const escaped = line.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
45409
+ return `<div>${escaped || "<br>"}</div>`;
45410
+ }).join("");
45411
+ };
45412
+ const separatorToHtml = (sep2) => {
45413
+ if (format === "html") return sep2;
45414
+ if (sep2 === "\n\n") return "<div><br></div>";
45415
+ const escaped = sep2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
45416
+ return `<div>${escaped}</div>`;
45417
+ };
45418
+ const snapshot = readExactNoteSnapshot(id2);
45419
+ if ("error" in snapshot) return errorResponse(snapshot.error);
45420
+ if (snapshot.contentHash !== expectedContentHash) {
45421
+ return errorResponse(revisionConflictMessage(snapshot.note.title));
45422
+ }
45423
+ if (!snapshot.rich.writable) {
45424
+ if (separator !== "\n\n")
45425
+ return errorResponse("Native append supports the default blank-line separator only");
45426
+ if (position !== "after") return errorResponse("Native append supports the end of a note only");
45427
+ if (!scopeText)
45428
+ return errorResponse("Provide scopeText: a unique existing phrase for native append");
45429
+ if (!VERIFIED_BACKGROUND.has("append-native"))
45430
+ return errorResponse("Native append has not passed live validation; see get-capabilities");
45431
+ onRoute?.("native");
45432
+ const result2 = appendNative(notesManager, {
45433
+ id: id2,
45434
+ expectedContentHash,
45435
+ scopeText,
45436
+ content,
45437
+ format
45438
+ });
45439
+ return successResponse("Native append verified without replacing existing objects", result2);
45440
+ }
45441
+ onRoute?.("applescript");
45442
+ const attachments = notesManager.listAttachmentsById(id2);
45443
+ if (attachments.length > 0) {
45444
+ return errorResponse(
45445
+ `Note "${snapshot.note.title}" has ${attachments.length} attachment(s). Append is blocked because it rewrites the full body; edit it in Notes.app.`
45446
+ );
45447
+ }
45448
+ assertLinkedWrite(snapshot.rich, snapshot.rich.content, "html");
45449
+ const firstDivEnd = snapshot.rich.content.indexOf("</div>");
45450
+ const titleDiv = firstDivEnd !== -1 ? snapshot.rich.content.slice(0, firstDivEnd + 6) : "";
45451
+ const bodyHtml = firstDivEnd !== -1 ? snapshot.rich.content.slice(firstDivEnd + 6) : snapshot.rich.content;
45452
+ const newBlock = contentToHtml(content);
45453
+ const sepHtml = htmlSeparator ?? separatorToHtml(separator);
45454
+ const combinedBody = position === "before" ? titleDiv + newBlock + sepHtml + bodyHtml : titleDiv + bodyHtml + sepHtml + newBlock;
45455
+ const result = notesManager.updateNoteByIdIfUnchanged(
45456
+ id2,
45457
+ snapshot.note.title,
45458
+ snapshot.body,
45459
+ void 0,
45460
+ combinedBody,
45461
+ "html",
45462
+ snapshot.rich.revision
45463
+ );
45464
+ if (result.status === "conflict") {
45465
+ return errorResponse(revisionConflictMessage(snapshot.note.title));
45466
+ }
45467
+ if (result.status === "attachments") {
45468
+ return errorResponse(
45469
+ `Note "${snapshot.note.title}" gained an attachment before saving. No content was appended.`
45470
+ );
45471
+ }
45472
+ if (result.status !== "updated") {
45473
+ return errorResponse(
45474
+ `The append result for note "${snapshot.note.title}" is uncertain. Read the exact ID before retrying.`
45475
+ );
45476
+ }
45477
+ const readback = notesManager.getNoteContentById(id2);
45478
+ const richReadback = enrichNoteRead(id2, readback || "");
45479
+ const contentHash = readback ? richContentHash(readback, richReadback) : "";
45480
+ if (!richReadback.complete || linkSignature(richReadback.links) !== linkSignature(htmlLinks(result.writtenBody))) {
45481
+ return errorResponse(
45482
+ "The note accepted the write, but rich-link readback is not verified. Read the exact ID before retrying; do not repeat the write automatically."
45483
+ );
45484
+ }
45485
+ if (!readback || comparableVisibleText(readback) !== comparableVisibleText(result.writtenBody)) {
45486
+ return errorResponse(
45487
+ `The note accepted an append, but exact-ID readback visible text did not match. Do not retry automatically; inspect note ID ${id2} in Notes.app.`
45488
+ );
45489
+ }
45490
+ const sharedWarning = snapshot.note.shared ? "\n\n\u26A0\uFE0F This note is shared with collaborators. Your changes are visible to them." : "";
45491
+ return successResponse(
45492
+ `Note appended; visible text verified: "${snapshot.note.title}"${sharedWarning}`,
45493
+ {
45494
+ ok: true,
45495
+ id: id2,
45496
+ title: snapshot.note.title,
45497
+ shared: snapshot.note.shared ?? false,
45498
+ previousContentHash: expectedContentHash,
45499
+ contentHash,
45500
+ verifiedVisibleText: true
45501
+ }
45502
+ );
45503
+ }
45273
45504
  registerTool(
45274
45505
  "append-to-note",
45275
45506
  {
@@ -45295,118 +45526,76 @@ registerTool(
45295
45526
  verifiedVisibleText: external_exports.boolean().optional()
45296
45527
  }
45297
45528
  },
45298
- withErrorHandling(
45299
- ({
45300
- id: id2,
45301
- expectedContentHash,
45302
- content,
45303
- position = "after",
45304
- separator = "\n\n",
45305
- format = "plaintext",
45306
- scopeText
45307
- }) => {
45308
- const contentToHtml = (text) => {
45309
- if (format === "html") return text;
45310
- return text.split("\n").map((line) => {
45311
- const escaped = line.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
45312
- return `<div>${escaped || "<br>"}</div>`;
45313
- }).join("");
45314
- };
45315
- const separatorToHtml = (sep2) => {
45316
- if (format === "html") return sep2;
45317
- if (sep2 === "\n\n") return "<div><br></div>";
45318
- const escaped = sep2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
45319
- return `<div>${escaped}</div>`;
45320
- };
45321
- const snapshot = readExactNoteSnapshot(id2);
45322
- if ("error" in snapshot) return errorResponse(snapshot.error);
45323
- if (snapshot.contentHash !== expectedContentHash) {
45324
- return errorResponse(revisionConflictMessage(snapshot.note.title));
45325
- }
45326
- if (!snapshot.rich.writable) {
45327
- if (separator !== "\n\n")
45328
- return errorResponse("Native append supports the default blank-line separator only");
45329
- if (position !== "after")
45330
- return errorResponse("Native append supports the end of a note only");
45331
- if (!scopeText)
45332
- return errorResponse("Provide scopeText: a unique existing phrase for native append");
45333
- if (!VERIFIED_BACKGROUND.has("append-native"))
45334
- return errorResponse(
45335
- "Native append has not passed live validation; see get-capabilities"
45529
+ withErrorHandling((args) => guardedAppend(args), "Error appending to note")
45530
+ );
45531
+ registerTool(
45532
+ "insert-link",
45533
+ {
45534
+ description: "Use when: adding one web, mail or Notes link to an exact note, either as the raw URL (mode 'raw') or as label text that links to the URL (mode 'hyperlink').\nReturns: exact id, the route used, whether Notes stored a link on the inserted text, the stored destination read back from the note, and the new content hash.\nDo not use when: linking to another note by id (insert-note-link fetches its real deep link), or you want a rich URL preview card (not available: no public automation route creates one).\nSafety: same guards as append-to-note (fresh expectedContentHash, attachment block, existing links must survive). Notes with native objects use native end-append and need scopeText; there only position 'end' with a blank line is supported. The link is placed in its own paragraph at the end or directly after the title; placing it inside an existing paragraph is not supported.",
45535
+ inputSchema: {
45536
+ id: noteIdInput,
45537
+ expectedContentHash: expectedContentHashInput,
45538
+ url: external_exports.string().min(1, "url is required").max(MAX_LINK_URL_LENGTH).describe(
45539
+ "Link destination: absolute http(s) URL with a host, or a mailto:, notes:// or applenotes: link. No spaces."
45540
+ ),
45541
+ mode: external_exports.enum(["raw", "hyperlink"]).optional().default("raw").describe(
45542
+ "'raw' (default) shows the URL itself; 'hyperlink' shows `label` linking to the URL"
45543
+ ),
45544
+ label: external_exports.string().max(MAX_LINK_LABEL_LENGTH).optional().describe("Visible text for mode 'hyperlink' (required there, refused in raw mode)"),
45545
+ linked: external_exports.boolean().optional().describe(
45546
+ "Raw mode only. true (default) stores a real link on the URL text. false writes plain text with no stored link; Notes may still detect it when displaying, and the result reports linkStored."
45547
+ ),
45548
+ position: external_exports.enum(["end", "after-title"]).optional().default("end").describe("'end' (default) or 'after-title' (first paragraph below the title)"),
45549
+ blankLine: external_exports.boolean().optional().default(true).describe(
45550
+ "Leave a blank line between existing text and the link paragraph (default true; native-object notes require true)"
45551
+ ),
45552
+ scopeText: external_exports.string().min(12).max(500).optional().describe("Existing unique phrase; required only for notes with native objects")
45553
+ },
45554
+ outputSchema: {
45555
+ ok: external_exports.boolean().optional(),
45556
+ id: external_exports.string().optional(),
45557
+ mode: external_exports.string().optional(),
45558
+ url: external_exports.string().optional(),
45559
+ text: external_exports.string().optional(),
45560
+ position: external_exports.string().optional(),
45561
+ route: external_exports.string().optional(),
45562
+ linkStored: external_exports.boolean().optional(),
45563
+ storedUrl: external_exports.string().optional(),
45564
+ previousContentHash: external_exports.string().optional(),
45565
+ contentHash: external_exports.string().optional()
45566
+ }
45567
+ },
45568
+ withErrorHandling((args) => {
45569
+ const result = insertLink(
45570
+ {
45571
+ ...args,
45572
+ mode: args.mode ?? "raw",
45573
+ position: args.position ?? "end",
45574
+ blankLine: args.blankLine ?? true
45575
+ },
45576
+ {
45577
+ readLinks: (noteId3) => readRichNote(noteId3).links,
45578
+ append: (request) => {
45579
+ let route = "applescript";
45580
+ const response = guardedAppend(
45581
+ {
45582
+ ...request,
45583
+ format: "html",
45584
+ htmlSeparator: request.separator ? "<div><br></div>" : ""
45585
+ },
45586
+ (r) => route = r
45336
45587
  );
45337
- const result2 = appendNative(notesManager, {
45338
- id: id2,
45339
- expectedContentHash,
45340
- scopeText,
45341
- content,
45342
- format
45343
- });
45344
- return successResponse("Native append verified without replacing existing objects", result2);
45345
- }
45346
- const attachments = notesManager.listAttachmentsById(id2);
45347
- if (attachments.length > 0) {
45348
- return errorResponse(
45349
- `Note "${snapshot.note.title}" has ${attachments.length} attachment(s). Append is blocked because it rewrites the full body; edit it in Notes.app.`
45350
- );
45351
- }
45352
- assertLinkedWrite(snapshot.rich, snapshot.rich.content, "html");
45353
- const firstDivEnd = snapshot.rich.content.indexOf("</div>");
45354
- const titleDiv = firstDivEnd !== -1 ? snapshot.rich.content.slice(0, firstDivEnd + 6) : "";
45355
- const bodyHtml = firstDivEnd !== -1 ? snapshot.rich.content.slice(firstDivEnd + 6) : snapshot.rich.content;
45356
- const newBlock = contentToHtml(content);
45357
- const sepHtml = separatorToHtml(separator);
45358
- const combinedBody = position === "before" ? titleDiv + newBlock + sepHtml + bodyHtml : titleDiv + bodyHtml + sepHtml + newBlock;
45359
- const result = notesManager.updateNoteByIdIfUnchanged(
45360
- id2,
45361
- snapshot.note.title,
45362
- snapshot.body,
45363
- void 0,
45364
- combinedBody,
45365
- "html",
45366
- snapshot.rich.revision
45367
- );
45368
- if (result.status === "conflict") {
45369
- return errorResponse(revisionConflictMessage(snapshot.note.title));
45370
- }
45371
- if (result.status === "attachments") {
45372
- return errorResponse(
45373
- `Note "${snapshot.note.title}" gained an attachment before saving. No content was appended.`
45374
- );
45375
- }
45376
- if (result.status !== "updated") {
45377
- return errorResponse(
45378
- `The append result for note "${snapshot.note.title}" is uncertain. Read the exact ID before retrying.`
45379
- );
45380
- }
45381
- const readback = notesManager.getNoteContentById(id2);
45382
- const richReadback = enrichNoteRead(id2, readback || "");
45383
- const contentHash = readback ? richContentHash(readback, richReadback) : "";
45384
- if (!richReadback.complete || linkSignature(richReadback.links) !== linkSignature(htmlLinks(result.writtenBody))) {
45385
- return errorResponse(
45386
- "The note accepted the write, but rich-link readback is not verified. Read the exact ID before retrying; do not repeat the write automatically."
45387
- );
45388
- }
45389
- if (!readback || comparableVisibleText(readback) !== comparableVisibleText(result.writtenBody)) {
45390
- return errorResponse(
45391
- `The note accepted an append, but exact-ID readback visible text did not match. Do not retry automatically; inspect note ID ${id2} in Notes.app.`
45392
- );
45393
- }
45394
- const sharedWarning = snapshot.note.shared ? "\n\n\u26A0\uFE0F This note is shared with collaborators. Your changes are visible to them." : "";
45395
- return successResponse(
45396
- `Note appended; visible text verified: "${snapshot.note.title}"${sharedWarning}`,
45397
- {
45398
- ok: true,
45399
- id: id2,
45400
- title: snapshot.note.title,
45401
- shared: snapshot.note.shared ?? false,
45402
- previousContentHash: expectedContentHash,
45403
- contentHash,
45404
- verifiedVisibleText: true
45588
+ if (response.isError) throw new Error(response.content[0].text);
45589
+ return { route, contentHash: String(response.structuredContent?.contentHash ?? "") };
45405
45590
  }
45406
- );
45407
- },
45408
- "Error appending to note"
45409
- )
45591
+ }
45592
+ );
45593
+ const stored = result.linkStored ? `stored link to ${result.storedUrl}` : "no stored link (plain text)";
45594
+ return successResponse(
45595
+ `Link inserted (${result.mode}, ${result.position}, ${result.route}); ${stored}`,
45596
+ { ...result }
45597
+ );
45598
+ }, "Error inserting link")
45410
45599
  );
45411
45600
  registerTool(
45412
45601
  "delete-note",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.8.19",
3
+ "version": "2.8.21",
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",