apple-notes-mcp 2.8.20 → 2.8.22

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 +95 -0
  2. package/build/index.js +372 -110
  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.
@@ -1169,6 +1232,38 @@ in the foreground.
1169
1232
  Appends one real unchecked Notes checklist item and verifies its native identity
1170
1233
  and text.
1171
1234
 
1235
+ #### `create-checklist-items`
1236
+
1237
+ Appends several real unchecked checklist items, 1 to 20, in the order given.
1238
+ It takes the same `id`, `expectedContentHash` and `scopeText` as
1239
+ `create-checklist-item`, plus `items`, an array of one-line texts. Each item is
1240
+ one run of the same verified bridge, so the call takes a few seconds per item
1241
+ (a full batch of 20 can run about a minute; if your client times out first,
1242
+ read the note before retrying rather than resending the whole batch) and is
1243
+ gated with `create-checklist-item` in `get-capabilities`.
1244
+
1245
+ After every run the server checks that exactly one new unchecked item with that
1246
+ text and a new native identity appeared, that every item appended earlier in the
1247
+ call kept its identity and text, and that nothing else in the note changed. The
1248
+ verified revision feeds the next run. A final check confirms the new items are
1249
+ the note's last checklist items in the requested order.
1250
+
1251
+ ```json
1252
+ {
1253
+ "id": "x-coredata://ABC123/ICNote/p456",
1254
+ "expectedContentHash": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
1255
+ "scopeText": "Packing list for the trip",
1256
+ "items": ["Passport", "Charger", "Rain jacket"]
1257
+ }
1258
+ ```
1259
+
1260
+ **Returns:** `items`, each with its `index`, native `id` and `text`, plus
1261
+ `orderVerified` and the new `contentHash`. The first uncertain result stops the
1262
+ call without a retry and returns `ok: false` with `landed` (the verified items),
1263
+ `stoppedAt` (the item's index, text, `outcome` of `"not-written"` or
1264
+ `"uncertain"`, and the error), and `notAttempted`. After an uncertain stop, read
1265
+ the note before retrying, and retry only the items that are not present.
1266
+
1172
1267
  #### `create-table`
1173
1268
 
1174
1269
  Appends a native table from rectangular string rows and verifies every decoded
package/build/index.js CHANGED
@@ -43796,6 +43796,121 @@ function parseNoteTable(compressed) {
43796
43796
  return { rows: values, rowIds: rows.ids, columnIds: columns.ids };
43797
43797
  }
43798
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
+
43799
43914
  // src/tools/directOperations.ts
43800
43915
  import { createHash as createHash2 } from "node:crypto";
43801
43916
  import {
@@ -44106,6 +44221,72 @@ var common = {
44106
44221
  )
44107
44222
  };
44108
44223
  var htmlEscape = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
44224
+ var MAX_CHECKLIST_BATCH = 20;
44225
+ var byPosition = (items = []) => [...items].sort((a, b) => a.start - b.start);
44226
+ function appendChecklistItems(args, deps, readRich) {
44227
+ const existing = new Set(byPosition(readRich(args.id).checklistItems).map((item) => item.id));
44228
+ const landed = [];
44229
+ let contentHash = args.expectedContentHash;
44230
+ for (const [index, text] of args.items.entries()) {
44231
+ let wrote = false;
44232
+ try {
44233
+ const result = mutateBackground(
44234
+ { ...args, expectedContentHash: contentHash },
44235
+ "create-checklist-item",
44236
+ { text },
44237
+ (before, after) => {
44238
+ wrote = true;
44239
+ assertPreserved(before, after, { append: true });
44240
+ const added = after.checklist.slice(before.checklist.length);
44241
+ if (added.length !== 1 || added[0].text !== text || added[0].done)
44242
+ throw new Error("Native checklist item not verified");
44243
+ },
44244
+ deps
44245
+ );
44246
+ const items = byPosition(readRich(args.id).checklistItems);
44247
+ const fresh = items.filter(
44248
+ (item) => !existing.has(item.id) && !landed.some((done) => done.id === item.id)
44249
+ );
44250
+ if (fresh.length !== 1 || fresh[0].text !== text || fresh[0].done)
44251
+ throw new Error("Native checklist identity not verified");
44252
+ for (const done of landed) {
44253
+ const current = items.find((item) => item.id === done.id);
44254
+ if (!current || current.text !== done.text || current.done)
44255
+ throw new Error("An earlier appended item changed identity or text");
44256
+ }
44257
+ landed.push({ index, id: fresh[0].id, text });
44258
+ contentHash = result.contentHash;
44259
+ } catch (error2) {
44260
+ return {
44261
+ ok: false,
44262
+ id: args.id,
44263
+ landed,
44264
+ stoppedAt: {
44265
+ index,
44266
+ text,
44267
+ outcome: wrote ? "uncertain" : "not-written",
44268
+ error: error2 instanceof Error ? error2.message : String(error2)
44269
+ },
44270
+ notAttempted: args.items.slice(index + 1),
44271
+ contentHash,
44272
+ message: `Stopped at item ${index + 1} of ${args.items.length}; ${landed.length} item(s) landed and were verified. Read the exact note before retrying, and retry only items that are not present.`
44273
+ };
44274
+ }
44275
+ }
44276
+ const finalItems = byPosition(readRich(args.id).checklistItems);
44277
+ const tail = finalItems.slice(finalItems.length - landed.length);
44278
+ const orderVerified = tail.length === landed.length && tail.every((item, i) => item.id === landed[i].id && item.text === landed[i].text);
44279
+ return {
44280
+ ok: orderVerified,
44281
+ id: args.id,
44282
+ contentHash,
44283
+ items: landed,
44284
+ orderVerified,
44285
+ ...orderVerified ? {} : {
44286
+ message: "Every item landed and was verified, but they are not the note's last checklist items in the requested order. Read the note before editing it further."
44287
+ }
44288
+ };
44289
+ }
44109
44290
  function registerNativeOperations(server2, manager) {
44110
44291
  function tool(name, description, input, handler, readOnly = false) {
44111
44292
  server2.registerTool(
@@ -44231,6 +44412,20 @@ function registerNativeOperations(server2, manager) {
44231
44412
  return { ...result, items: readRichNote(args.id).checklistItems };
44232
44413
  }
44233
44414
  );
44415
+ tool(
44416
+ "create-checklist-items",
44417
+ "Use when: appending several real unchecked Notes checklist items to one note, in order.\nReturns: each landed item's native identity and text, the final order check, and the new revision; on a stop, which items landed, the item whose outcome is uncertain, and the items not attempted.\nDo not use when: one item is enough (create-checklist-item) or plain text is sufficient.\nSafety: runs the verified single-item bridge once per item, chaining each verified revision into the next; checks after every item that exactly one new unchecked item with that text and a new identity appeared and that earlier items kept theirs, and stops at the first uncertain result without retrying. Each item takes a few seconds, so a full batch can run about a minute; if the call times out on the client side, read the note before retrying and send only items that are not present.",
44418
+ {
44419
+ ...common,
44420
+ items: external_exports.array(
44421
+ external_exports.string().min(1).max(1e4).refine((s) => !/[\r\n\0]/u.test(s), "One line per checklist item")
44422
+ ).min(1).max(MAX_CHECKLIST_BATCH).describe(`Item texts in the order they should appear (1-${MAX_CHECKLIST_BATCH})`)
44423
+ },
44424
+ (args) => {
44425
+ requireValidated("create-checklist-item");
44426
+ return appendChecklistItems(args, backgroundDependencies(manager), readRichNote);
44427
+ }
44428
+ );
44234
44429
  tool(
44235
44430
  "create-table",
44236
44431
  "Use when: appending one native Notes table from a rectangular array of strings.\nReturns: the native table identity and decoded cells after readback.\nDo not use when: a text table is acceptable or the rows are not rectangular.\nSafety: never substitutes text; preserves existing rich content and reports failure unless the native table is verified.",
@@ -45277,6 +45472,115 @@ registerTool(
45277
45472
  "Error updating note"
45278
45473
  )
45279
45474
  );
45475
+ function guardedAppend({
45476
+ id: id2,
45477
+ expectedContentHash,
45478
+ content,
45479
+ position = "after",
45480
+ separator = "\n\n",
45481
+ format = "plaintext",
45482
+ scopeText,
45483
+ htmlSeparator
45484
+ }, onRoute) {
45485
+ const contentToHtml = (text) => {
45486
+ if (format === "html") return text;
45487
+ return text.split("\n").map((line) => {
45488
+ const escaped = line.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
45489
+ return `<div>${escaped || "<br>"}</div>`;
45490
+ }).join("");
45491
+ };
45492
+ const separatorToHtml = (sep2) => {
45493
+ if (format === "html") return sep2;
45494
+ if (sep2 === "\n\n") return "<div><br></div>";
45495
+ const escaped = sep2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
45496
+ return `<div>${escaped}</div>`;
45497
+ };
45498
+ const snapshot = readExactNoteSnapshot(id2);
45499
+ if ("error" in snapshot) return errorResponse(snapshot.error);
45500
+ if (snapshot.contentHash !== expectedContentHash) {
45501
+ return errorResponse(revisionConflictMessage(snapshot.note.title));
45502
+ }
45503
+ if (!snapshot.rich.writable) {
45504
+ if (separator !== "\n\n")
45505
+ return errorResponse("Native append supports the default blank-line separator only");
45506
+ if (position !== "after") return errorResponse("Native append supports the end of a note only");
45507
+ if (!scopeText)
45508
+ return errorResponse("Provide scopeText: a unique existing phrase for native append");
45509
+ if (!VERIFIED_BACKGROUND.has("append-native"))
45510
+ return errorResponse("Native append has not passed live validation; see get-capabilities");
45511
+ onRoute?.("native");
45512
+ const result2 = appendNative(notesManager, {
45513
+ id: id2,
45514
+ expectedContentHash,
45515
+ scopeText,
45516
+ content,
45517
+ format
45518
+ });
45519
+ return successResponse("Native append verified without replacing existing objects", result2);
45520
+ }
45521
+ onRoute?.("applescript");
45522
+ const attachments = notesManager.listAttachmentsById(id2);
45523
+ if (attachments.length > 0) {
45524
+ return errorResponse(
45525
+ `Note "${snapshot.note.title}" has ${attachments.length} attachment(s). Append is blocked because it rewrites the full body; edit it in Notes.app.`
45526
+ );
45527
+ }
45528
+ assertLinkedWrite(snapshot.rich, snapshot.rich.content, "html");
45529
+ const firstDivEnd = snapshot.rich.content.indexOf("</div>");
45530
+ const titleDiv = firstDivEnd !== -1 ? snapshot.rich.content.slice(0, firstDivEnd + 6) : "";
45531
+ const bodyHtml = firstDivEnd !== -1 ? snapshot.rich.content.slice(firstDivEnd + 6) : snapshot.rich.content;
45532
+ const newBlock = contentToHtml(content);
45533
+ const sepHtml = htmlSeparator ?? separatorToHtml(separator);
45534
+ const combinedBody = position === "before" ? titleDiv + newBlock + sepHtml + bodyHtml : titleDiv + bodyHtml + sepHtml + newBlock;
45535
+ const result = notesManager.updateNoteByIdIfUnchanged(
45536
+ id2,
45537
+ snapshot.note.title,
45538
+ snapshot.body,
45539
+ void 0,
45540
+ combinedBody,
45541
+ "html",
45542
+ snapshot.rich.revision
45543
+ );
45544
+ if (result.status === "conflict") {
45545
+ return errorResponse(revisionConflictMessage(snapshot.note.title));
45546
+ }
45547
+ if (result.status === "attachments") {
45548
+ return errorResponse(
45549
+ `Note "${snapshot.note.title}" gained an attachment before saving. No content was appended.`
45550
+ );
45551
+ }
45552
+ if (result.status !== "updated") {
45553
+ return errorResponse(
45554
+ `The append result for note "${snapshot.note.title}" is uncertain. Read the exact ID before retrying.`
45555
+ );
45556
+ }
45557
+ const readback = notesManager.getNoteContentById(id2);
45558
+ const richReadback = enrichNoteRead(id2, readback || "");
45559
+ const contentHash = readback ? richContentHash(readback, richReadback) : "";
45560
+ if (!richReadback.complete || linkSignature(richReadback.links) !== linkSignature(htmlLinks(result.writtenBody))) {
45561
+ return errorResponse(
45562
+ "The note accepted the write, but rich-link readback is not verified. Read the exact ID before retrying; do not repeat the write automatically."
45563
+ );
45564
+ }
45565
+ if (!readback || comparableVisibleText(readback) !== comparableVisibleText(result.writtenBody)) {
45566
+ return errorResponse(
45567
+ `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.`
45568
+ );
45569
+ }
45570
+ const sharedWarning = snapshot.note.shared ? "\n\n\u26A0\uFE0F This note is shared with collaborators. Your changes are visible to them." : "";
45571
+ return successResponse(
45572
+ `Note appended; visible text verified: "${snapshot.note.title}"${sharedWarning}`,
45573
+ {
45574
+ ok: true,
45575
+ id: id2,
45576
+ title: snapshot.note.title,
45577
+ shared: snapshot.note.shared ?? false,
45578
+ previousContentHash: expectedContentHash,
45579
+ contentHash,
45580
+ verifiedVisibleText: true
45581
+ }
45582
+ );
45583
+ }
45280
45584
  registerTool(
45281
45585
  "append-to-note",
45282
45586
  {
@@ -45302,118 +45606,76 @@ registerTool(
45302
45606
  verifiedVisibleText: external_exports.boolean().optional()
45303
45607
  }
45304
45608
  },
45305
- withErrorHandling(
45306
- ({
45307
- id: id2,
45308
- expectedContentHash,
45309
- content,
45310
- position = "after",
45311
- separator = "\n\n",
45312
- format = "plaintext",
45313
- scopeText
45314
- }) => {
45315
- const contentToHtml = (text) => {
45316
- if (format === "html") return text;
45317
- return text.split("\n").map((line) => {
45318
- const escaped = line.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
45319
- return `<div>${escaped || "<br>"}</div>`;
45320
- }).join("");
45321
- };
45322
- const separatorToHtml = (sep2) => {
45323
- if (format === "html") return sep2;
45324
- if (sep2 === "\n\n") return "<div><br></div>";
45325
- const escaped = sep2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
45326
- return `<div>${escaped}</div>`;
45327
- };
45328
- const snapshot = readExactNoteSnapshot(id2);
45329
- if ("error" in snapshot) return errorResponse(snapshot.error);
45330
- if (snapshot.contentHash !== expectedContentHash) {
45331
- return errorResponse(revisionConflictMessage(snapshot.note.title));
45332
- }
45333
- if (!snapshot.rich.writable) {
45334
- if (separator !== "\n\n")
45335
- return errorResponse("Native append supports the default blank-line separator only");
45336
- if (position !== "after")
45337
- return errorResponse("Native append supports the end of a note only");
45338
- if (!scopeText)
45339
- return errorResponse("Provide scopeText: a unique existing phrase for native append");
45340
- if (!VERIFIED_BACKGROUND.has("append-native"))
45341
- return errorResponse(
45342
- "Native append has not passed live validation; see get-capabilities"
45609
+ withErrorHandling((args) => guardedAppend(args), "Error appending to note")
45610
+ );
45611
+ registerTool(
45612
+ "insert-link",
45613
+ {
45614
+ 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.",
45615
+ inputSchema: {
45616
+ id: noteIdInput,
45617
+ expectedContentHash: expectedContentHashInput,
45618
+ url: external_exports.string().min(1, "url is required").max(MAX_LINK_URL_LENGTH).describe(
45619
+ "Link destination: absolute http(s) URL with a host, or a mailto:, notes:// or applenotes: link. No spaces."
45620
+ ),
45621
+ mode: external_exports.enum(["raw", "hyperlink"]).optional().default("raw").describe(
45622
+ "'raw' (default) shows the URL itself; 'hyperlink' shows `label` linking to the URL"
45623
+ ),
45624
+ label: external_exports.string().max(MAX_LINK_LABEL_LENGTH).optional().describe("Visible text for mode 'hyperlink' (required there, refused in raw mode)"),
45625
+ linked: external_exports.boolean().optional().describe(
45626
+ "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."
45627
+ ),
45628
+ position: external_exports.enum(["end", "after-title"]).optional().default("end").describe("'end' (default) or 'after-title' (first paragraph below the title)"),
45629
+ blankLine: external_exports.boolean().optional().default(true).describe(
45630
+ "Leave a blank line between existing text and the link paragraph (default true; native-object notes require true)"
45631
+ ),
45632
+ scopeText: external_exports.string().min(12).max(500).optional().describe("Existing unique phrase; required only for notes with native objects")
45633
+ },
45634
+ outputSchema: {
45635
+ ok: external_exports.boolean().optional(),
45636
+ id: external_exports.string().optional(),
45637
+ mode: external_exports.string().optional(),
45638
+ url: external_exports.string().optional(),
45639
+ text: external_exports.string().optional(),
45640
+ position: external_exports.string().optional(),
45641
+ route: external_exports.string().optional(),
45642
+ linkStored: external_exports.boolean().optional(),
45643
+ storedUrl: external_exports.string().optional(),
45644
+ previousContentHash: external_exports.string().optional(),
45645
+ contentHash: external_exports.string().optional()
45646
+ }
45647
+ },
45648
+ withErrorHandling((args) => {
45649
+ const result = insertLink(
45650
+ {
45651
+ ...args,
45652
+ mode: args.mode ?? "raw",
45653
+ position: args.position ?? "end",
45654
+ blankLine: args.blankLine ?? true
45655
+ },
45656
+ {
45657
+ readLinks: (noteId3) => readRichNote(noteId3).links,
45658
+ append: (request) => {
45659
+ let route = "applescript";
45660
+ const response = guardedAppend(
45661
+ {
45662
+ ...request,
45663
+ format: "html",
45664
+ htmlSeparator: request.separator ? "<div><br></div>" : ""
45665
+ },
45666
+ (r) => route = r
45343
45667
  );
45344
- const result2 = appendNative(notesManager, {
45345
- id: id2,
45346
- expectedContentHash,
45347
- scopeText,
45348
- content,
45349
- format
45350
- });
45351
- return successResponse("Native append verified without replacing existing objects", result2);
45352
- }
45353
- const attachments = notesManager.listAttachmentsById(id2);
45354
- if (attachments.length > 0) {
45355
- return errorResponse(
45356
- `Note "${snapshot.note.title}" has ${attachments.length} attachment(s). Append is blocked because it rewrites the full body; edit it in Notes.app.`
45357
- );
45358
- }
45359
- assertLinkedWrite(snapshot.rich, snapshot.rich.content, "html");
45360
- const firstDivEnd = snapshot.rich.content.indexOf("</div>");
45361
- const titleDiv = firstDivEnd !== -1 ? snapshot.rich.content.slice(0, firstDivEnd + 6) : "";
45362
- const bodyHtml = firstDivEnd !== -1 ? snapshot.rich.content.slice(firstDivEnd + 6) : snapshot.rich.content;
45363
- const newBlock = contentToHtml(content);
45364
- const sepHtml = separatorToHtml(separator);
45365
- const combinedBody = position === "before" ? titleDiv + newBlock + sepHtml + bodyHtml : titleDiv + bodyHtml + sepHtml + newBlock;
45366
- const result = notesManager.updateNoteByIdIfUnchanged(
45367
- id2,
45368
- snapshot.note.title,
45369
- snapshot.body,
45370
- void 0,
45371
- combinedBody,
45372
- "html",
45373
- snapshot.rich.revision
45374
- );
45375
- if (result.status === "conflict") {
45376
- return errorResponse(revisionConflictMessage(snapshot.note.title));
45377
- }
45378
- if (result.status === "attachments") {
45379
- return errorResponse(
45380
- `Note "${snapshot.note.title}" gained an attachment before saving. No content was appended.`
45381
- );
45382
- }
45383
- if (result.status !== "updated") {
45384
- return errorResponse(
45385
- `The append result for note "${snapshot.note.title}" is uncertain. Read the exact ID before retrying.`
45386
- );
45387
- }
45388
- const readback = notesManager.getNoteContentById(id2);
45389
- const richReadback = enrichNoteRead(id2, readback || "");
45390
- const contentHash = readback ? richContentHash(readback, richReadback) : "";
45391
- if (!richReadback.complete || linkSignature(richReadback.links) !== linkSignature(htmlLinks(result.writtenBody))) {
45392
- return errorResponse(
45393
- "The note accepted the write, but rich-link readback is not verified. Read the exact ID before retrying; do not repeat the write automatically."
45394
- );
45395
- }
45396
- if (!readback || comparableVisibleText(readback) !== comparableVisibleText(result.writtenBody)) {
45397
- return errorResponse(
45398
- `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.`
45399
- );
45400
- }
45401
- const sharedWarning = snapshot.note.shared ? "\n\n\u26A0\uFE0F This note is shared with collaborators. Your changes are visible to them." : "";
45402
- return successResponse(
45403
- `Note appended; visible text verified: "${snapshot.note.title}"${sharedWarning}`,
45404
- {
45405
- ok: true,
45406
- id: id2,
45407
- title: snapshot.note.title,
45408
- shared: snapshot.note.shared ?? false,
45409
- previousContentHash: expectedContentHash,
45410
- contentHash,
45411
- verifiedVisibleText: true
45668
+ if (response.isError) throw new Error(response.content[0].text);
45669
+ return { route, contentHash: String(response.structuredContent?.contentHash ?? "") };
45412
45670
  }
45413
- );
45414
- },
45415
- "Error appending to note"
45416
- )
45671
+ }
45672
+ );
45673
+ const stored = result.linkStored ? `stored link to ${result.storedUrl}` : "no stored link (plain text)";
45674
+ return successResponse(
45675
+ `Link inserted (${result.mode}, ${result.position}, ${result.route}); ${stored}`,
45676
+ { ...result }
45677
+ );
45678
+ }, "Error inserting link")
45417
45679
  );
45418
45680
  registerTool(
45419
45681
  "delete-note",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.8.20",
3
+ "version": "2.8.22",
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",