apple-notes-mcp 2.8.3 → 2.8.5

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 +13 -8
  2. package/build/index.js +470 -107
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -286,10 +286,11 @@ Retrieves the full content of a specific note.
286
286
 
287
287
  **Returns:** The HTML content of the note, its exact `id`, and a
288
288
  `contentHash`. Pass that hash back as `expectedContentHash` for a later update,
289
- append, or delete; the write is rejected if the note changed after this read.
290
- The `structuredContent` also includes `hashtags` any inline `#hashtag` tags parsed
291
- from the body. Apple Notes tags are inline hashtags, not a scriptable property;
292
- see [docs/APPLESCRIPT-LIMITATIONS.md](https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/APPLESCRIPT-LIMITATIONS.md#tags--hashtags-29). Smart Folders are not scriptable.
289
+ append, or delete; the write is rejected if the note's body or rich metadata
290
+ changed after this read. With Full Disk Access, embedded URLs omitted by
291
+ AppleScript are restored and returned in `links`. The response also reports
292
+ actual `nativeTags`, `richContentComplete`, and `writable`. Textual `hashtags`
293
+ remain a separate field and are not proof that Notes registered native tags.
293
294
 
294
295
  **⚠️ The returned body can be lossy — do not write it back verbatim.** Inline
295
296
  base64 images larger than `APPLE_NOTES_MCP_MAX_INLINE_IMAGE_BYTES` (default
@@ -385,6 +386,7 @@ Updates an existing note's content and/or title.
385
386
  | `newTitle` | string | No | New title (if changing the title; ignored when `format` is `"html"`) |
386
387
  | `newContent` | string | Yes | New content for the note body |
387
388
  | `format` | string | No | Content format: `"plaintext"` (default) or `"html"`. When `"html"`, content replaces the entire note body as raw HTML and `newTitle` is ignored (the first HTML element serves as the title) |
389
+ | `allowLinkChanges` | boolean | No | Set to `true` only when intentionally changing or removing existing links |
388
390
 
389
391
  Title-only updates are rejected because Apple Notes titles are not unique.
390
392
 
@@ -414,8 +416,10 @@ byte-identical rich formatting.
414
416
 
415
417
  **Note:** `newContent` **replaces the entire note body** — it is not appended. To add to a note, prefer [`append-to-note`](#append-to-note), which does the read-and-concatenate for you and always round-trips the body as HTML. If you do read-modify-write by hand, note that `get-note-content` replaces oversized inline images with text placeholders (see [`get-note-content`](#get-note-content)) — writing that body back bakes the placeholders in.
416
418
 
417
- **Attachments:** `update-note` refuses to replace any note that contains an
418
- attachment. Edit those notes in Notes.app or create a separate note instead.
419
+ **Rich-content safety:** `update-note` refuses to replace a note when its rich
420
+ metadata is unavailable or it contains attachments, native tags, inline
421
+ objects, or checklists that AppleScript cannot preserve. Existing link
422
+ destinations must remain present unless `allowLinkChanges` is explicitly set.
419
423
 
420
424
  ---
421
425
 
@@ -509,8 +513,9 @@ Title-only appends are rejected.
509
513
  visible text after saving, not byte-identical rich formatting. Warns when the
510
514
  note is shared with collaborators.
511
515
 
512
- **Safety:** The append is rejected if the note changed since it was read or if
513
- the note contains an attachment.
516
+ **Safety:** The append is rejected if the note changed since it was read, rich
517
+ metadata is unavailable, or the note contains attachments or other native
518
+ objects. Existing link destinations are verified after saving.
514
519
 
515
520
  ---
516
521
 
package/build/index.js CHANGED
@@ -4199,8 +4199,8 @@ var require_fast_uri = __commonJS({
4199
4199
  } catch {
4200
4200
  return void 0;
4201
4201
  }
4202
- const { normalized, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = normalizeStringWithStatus(value, opts);
4203
- return malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? void 0 : normalized;
4202
+ const { normalized: normalized2, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = normalizeStringWithStatus(value, opts);
4203
+ return malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? void 0 : normalized2;
4204
4204
  }
4205
4205
  var fastUri = {
4206
4206
  SCHEMES,
@@ -24465,14 +24465,14 @@ var require_turndown_cjs = __commonJS({
24465
24465
  } else if (node.nodeType === 1) {
24466
24466
  replacement = replacementForNode.call(self, node);
24467
24467
  }
24468
- return join7(output, replacement);
24468
+ return join8(output, replacement);
24469
24469
  }, "");
24470
24470
  }
24471
24471
  function postProcess(output) {
24472
24472
  var self = this;
24473
24473
  this.rules.forEach(function(rule) {
24474
24474
  if (typeof rule.append === "function") {
24475
- output = join7(output, rule.append(self.options));
24475
+ output = join8(output, rule.append(self.options));
24476
24476
  }
24477
24477
  });
24478
24478
  return output.replace(/^[\t\r\n]+/, "").replace(/[\t\r\n\s]+$/, "");
@@ -24484,7 +24484,7 @@ var require_turndown_cjs = __commonJS({
24484
24484
  if (whitespace.leading || whitespace.trailing) content = content.trim();
24485
24485
  return whitespace.leading + rule.replacement(content, node, this.options) + whitespace.trailing;
24486
24486
  }
24487
- function join7(output, replacement) {
24487
+ function join8(output, replacement) {
24488
24488
  var s1 = trimTrailingNewlines(output);
24489
24489
  var s2 = trimLeadingNewlines(replacement);
24490
24490
  var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
@@ -30297,7 +30297,7 @@ var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => {
30297
30297
  });
30298
30298
  const generateFastpass = (shape) => {
30299
30299
  const doc = new Doc(["shape", "payload", "ctx"]);
30300
- const normalized = _normalized.value;
30300
+ const normalized2 = _normalized.value;
30301
30301
  const parseStr = (key) => {
30302
30302
  const k = esc(key);
30303
30303
  return `shape[${k}]._zod.run({ value: input[${k}], issues: [] }, ctx)`;
@@ -30305,12 +30305,12 @@ var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => {
30305
30305
  doc.write(`const input = payload.value;`);
30306
30306
  const ids = /* @__PURE__ */ Object.create(null);
30307
30307
  let counter = 0;
30308
- for (const key of normalized.keys) {
30308
+ for (const key of normalized2.keys) {
30309
30309
  ids[key] = `key_${counter++}`;
30310
30310
  }
30311
30311
  doc.write(`const newResult = {}`);
30312
- for (const key of normalized.keys) {
30313
- if (normalized.optionalKeys.has(key)) {
30312
+ for (const key of normalized2.keys) {
30313
+ if (normalized2.optionalKeys.has(key)) {
30314
30314
  const id = ids[key];
30315
30315
  doc.write(`const ${id} = ${parseStr(key)};`);
30316
30316
  const k = esc(key);
@@ -39484,6 +39484,311 @@ function getChecklistItems(noteId) {
39484
39484
  return { items };
39485
39485
  }
39486
39486
 
39487
+ // src/utils/noteRichText.ts
39488
+ import { execFileSync as execFileSync3 } from "node:child_process";
39489
+ import { createHash } from "node:crypto";
39490
+ import { homedir as homedir2 } from "node:os";
39491
+ import { join as join2 } from "node:path";
39492
+ import { gunzipSync as gunzipSync2 } from "node:zlib";
39493
+ var dbPath = join2(homedir2(), "Library/Group Containers/group.com.apple.notes/NoteStore.sqlite");
39494
+ var safeUrl = (url) => /^(?:https?:\/\/|notes:\/\/|applenotes:|mailto:)/i.test(url) && !Array.from(url).some((char) => char.charCodeAt(0) < 32);
39495
+ var escapeAttribute = (text) => text.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
39496
+ var normalized = (text) => text.replace(/[\s\ufffc]/gu, "");
39497
+ function styleValue(field) {
39498
+ if (!(field.value instanceof Uint8Array)) return field.value;
39499
+ if (field.fieldNumber === 2) {
39500
+ const data = field.value, kept = [];
39501
+ let offset = 0;
39502
+ try {
39503
+ while (offset < data.length) {
39504
+ const start = offset;
39505
+ let tag, length = -1;
39506
+ [tag, offset] = decodeVarint(data, offset);
39507
+ const wire = tag & 7;
39508
+ if (wire === 0) [, offset] = decodeVarint(data, offset);
39509
+ else if (wire === 1) offset += 8;
39510
+ else if (wire === 5) offset += 4;
39511
+ else if (wire === 2) {
39512
+ [length, offset] = decodeVarint(data, offset);
39513
+ offset += length;
39514
+ } else return Buffer.from(data).toString("hex");
39515
+ if (offset > data.length) return Buffer.from(data).toString("hex");
39516
+ if (!(tag >>> 3 === 9 && wire === 2 && length === 16)) kept.push(data.slice(start, offset));
39517
+ }
39518
+ return Buffer.concat(kept).toString("hex");
39519
+ } catch {
39520
+ return Buffer.from(data).toString("hex");
39521
+ }
39522
+ }
39523
+ return Buffer.from(field.value).toString("hex");
39524
+ }
39525
+ function parseRichNote(data, nativeTags = []) {
39526
+ const doc = decodeMessage(data);
39527
+ const wrapper = embeddedMessage(getField(doc, 2));
39528
+ const body = wrapper && embeddedMessage(getField(wrapper, 3));
39529
+ const text = body && stringValue(getField(body, 2));
39530
+ if (!body || text === void 0) throw new Error("Unsupported Notes document structure");
39531
+ const links = [];
39532
+ const nativeObjectIds = [];
39533
+ const objects = [];
39534
+ const checklistItems = [];
39535
+ const styleRuns = [];
39536
+ let position = 0;
39537
+ let hasNativeObjects = false;
39538
+ let hasChecklist = false;
39539
+ for (const run of getFields(body, 5)) {
39540
+ const fields = embeddedMessage(run);
39541
+ if (!fields) throw new Error("Invalid Notes attribute run");
39542
+ const length = varintValue(getField(fields, 1));
39543
+ if (length === void 0 || length < 0 || position + length > text.length)
39544
+ throw new Error("Invalid Notes run length");
39545
+ styleRuns.push({
39546
+ start: position,
39547
+ length,
39548
+ signature: JSON.stringify(
39549
+ fields.filter((f) => f.fieldNumber >= 2 && f.fieldNumber <= 12 || f.fieldNumber === 14).map((f) => [f.fieldNumber, styleValue(f)])
39550
+ )
39551
+ });
39552
+ const url = stringValue(getField(fields, 9));
39553
+ if (url) {
39554
+ if (!safeUrl(url)) throw new Error("Unsupported link scheme in note");
39555
+ const previous = links.at(-1);
39556
+ if (previous?.url === url && previous.start + previous.length === position) {
39557
+ previous.length += length;
39558
+ previous.text += text.slice(position, position + length);
39559
+ } else
39560
+ links.push({ start: position, length, text: text.slice(position, position + length), url });
39561
+ }
39562
+ hasNativeObjects ||= Boolean(getField(fields, 12));
39563
+ const attachment = embeddedMessage(getField(fields, 12));
39564
+ const attachmentId = attachment && stringValue(getField(attachment, 1));
39565
+ if (attachmentId) nativeObjectIds.push(attachmentId);
39566
+ if (attachmentId)
39567
+ objects.push({
39568
+ id: attachmentId,
39569
+ type: stringValue(getField(attachment, 2)) || "unknown",
39570
+ start: position,
39571
+ length
39572
+ });
39573
+ const paragraph = embeddedMessage(getField(fields, 2));
39574
+ hasChecklist ||= Boolean(paragraph && varintValue(getField(paragraph, 1)) === 103);
39575
+ if (paragraph && varintValue(getField(paragraph, 1)) === 103) {
39576
+ const checklist = embeddedMessage(getField(paragraph, 5));
39577
+ const rawId = checklist && getField(checklist, 1)?.value;
39578
+ const itemId = rawId instanceof Uint8Array ? Buffer.from(rawId).toString("hex") : "";
39579
+ const start = text.lastIndexOf("\n", position - 1) + 1;
39580
+ if (itemId && !checklistItems.some((item) => item.id === itemId))
39581
+ checklistItems.push({
39582
+ id: itemId,
39583
+ start,
39584
+ text: text.slice(
39585
+ start,
39586
+ text.indexOf("\n", start) === -1 ? text.length : text.indexOf("\n", start)
39587
+ ),
39588
+ done: checklist ? varintValue(getField(checklist, 2)) === 1 : false
39589
+ });
39590
+ }
39591
+ position += length;
39592
+ }
39593
+ if (position !== text.length) throw new Error("Incomplete Notes attribute runs");
39594
+ return {
39595
+ text,
39596
+ links,
39597
+ nativeTags: hasNativeObjects ? nativeTags : [],
39598
+ nativeObjectIds,
39599
+ hasNativeObjects,
39600
+ hasChecklist,
39601
+ revision: createHash("sha256").update(data).digest("hex"),
39602
+ objects,
39603
+ checklistItems,
39604
+ styleRuns
39605
+ };
39606
+ }
39607
+ function readRichNote(id) {
39608
+ const pk = /^x-coredata:\/\/[0-9a-f-]+\/ICNote\/p([0-9]+)$/i.exec(id)?.[1];
39609
+ if (!pk) throw new Error("Invalid exact note ID");
39610
+ const sql = `BEGIN; SELECT hex(ZDATA) FROM ZICNOTEDATA WHERE ZNOTE=${pk}; SELECT json_group_object(ZIDENTIFIER,ZALTTEXT) FROM ZICCLOUDSYNCINGOBJECT WHERE ZNOTE1=${pk} AND ZTYPEUTI1='com.apple.notes.inlinetextattachment.hashtag'; SELECT json_group_array(json_object('id',ZIDENTIFIER,'pk',Z_PK,'type',COALESCE(ZTYPEUTI1,ZTYPEUTI),'mergeable',hex(COALESCE(ZMERGEABLEDATA1,ZMERGEABLEDATA)),'view',ZATTACHMENTVIEWTYPE)) FROM ZICCLOUDSYNCINGOBJECT WHERE ZNOTE1=${pk} OR ZNOTE=${pk}; COMMIT;`;
39611
+ const rows = execFileSync3("/usr/bin/sqlite3", ["-readonly", dbPath, sql], {
39612
+ encoding: "utf8",
39613
+ timeout: 5e3,
39614
+ maxBuffer: 64 * 1024 * 1024,
39615
+ stdio: ["pipe", "pipe", "pipe"]
39616
+ }).trim().split("\n");
39617
+ if (!rows[0] || !/^[0-9a-f]+$/i.test(rows[0])) throw new Error("No Notes document data");
39618
+ const tags = JSON.parse(rows[1] || "{}");
39619
+ if (!tags || Array.isArray(tags) || typeof tags !== "object" || Object.values(tags).some((tag) => typeof tag !== "string"))
39620
+ throw new Error("Invalid native tags");
39621
+ const rich = parseRichNote(
39622
+ gunzipSync2(Buffer.from(rows[0], "hex"), { maxOutputLength: 32 * 1024 * 1024 })
39623
+ );
39624
+ const tagMap = tags;
39625
+ const objectData = JSON.parse(rows[2] || "[]");
39626
+ if (!Array.isArray(objectData) || objectData.some(
39627
+ (row) => !row || typeof row.id !== "string" || !Number.isInteger(row.pk) || typeof row.mergeable !== "string" || !/^[0-9a-f]*$/i.test(row.mergeable)
39628
+ ))
39629
+ throw new Error("Invalid native object metadata");
39630
+ rich.objectData = objectData.filter((row) => rich.nativeObjectIds.includes(row.id)).sort((a, b) => a.id.localeCompare(b.id));
39631
+ rich.revision = createHash("sha256").update(rich.revision).update(JSON.stringify(rich.objectData)).digest("hex");
39632
+ rich.nativeTagObjectIds = {};
39633
+ for (const id2 of rich.nativeObjectIds)
39634
+ if (tagMap[id2]) {
39635
+ const tag = tagMap[id2].replace(/^#/, "");
39636
+ (rich.nativeTagObjectIds[tag] ||= []).push(id2);
39637
+ }
39638
+ rich.nativeTags = [
39639
+ ...new Set(
39640
+ rich.nativeObjectIds.flatMap((id2) => tagMap[id2] ? [tagMap[id2].replace(/^#/, "")] : [])
39641
+ )
39642
+ ];
39643
+ return rich;
39644
+ }
39645
+ function decodeEntity(value) {
39646
+ const named = {
39647
+ amp: "&",
39648
+ lt: "<",
39649
+ gt: ">",
39650
+ quot: '"',
39651
+ apos: "'",
39652
+ nbsp: " "
39653
+ };
39654
+ if (!value.startsWith("&") || value === "&") return value;
39655
+ const name = value.slice(1).replace(/;$/, "");
39656
+ if (name.startsWith("#")) {
39657
+ const cp = name[1]?.toLowerCase() === "x" ? Number.parseInt(name.slice(2), 16) : Number.parseInt(name.slice(1), 10);
39658
+ if (!Number.isInteger(cp) || cp < 0 || cp > 1114111) throw new Error("Invalid HTML entity");
39659
+ return String.fromCodePoint(cp);
39660
+ }
39661
+ if (!(name in named)) throw new Error("Unsupported HTML entity");
39662
+ return named[name];
39663
+ }
39664
+ function visibleCharacters(html) {
39665
+ const chars = [];
39666
+ let token = 0;
39667
+ for (const part of html.matchAll(/<[^>]*>|[^<]+/g)) {
39668
+ token++;
39669
+ if (part[0].startsWith("<")) continue;
39670
+ for (const item of part[0].matchAll(
39671
+ /&(?:#[0-9]+;?|#x[0-9a-f]+;?|(?:amp|lt|gt|quot|apos|nbsp)(?:;|(?![a-z0-9=])))|[\s\S]/gi
39672
+ )) {
39673
+ const value = decodeEntity(item[0]);
39674
+ for (let i = 0; i < value.length; i++) {
39675
+ if (/\s/u.test(value[i])) continue;
39676
+ chars.push({
39677
+ value: value[i],
39678
+ start: part.index + item.index,
39679
+ end: part.index + item.index + item[0].length,
39680
+ token
39681
+ });
39682
+ }
39683
+ }
39684
+ }
39685
+ return chars;
39686
+ }
39687
+ function restoreNoteLinks(html, rich) {
39688
+ const base = html.replace(/<\/?a\b[^>]*>/gi, "");
39689
+ const chars = visibleCharacters(base);
39690
+ if (chars.map((c) => c.value).join("") !== normalized(rich.text))
39691
+ throw new Error("Notes HTML and rich text do not match; retry after sync");
39692
+ const positions = [];
39693
+ for (let i = 0; i < rich.text.length; i++)
39694
+ if (!/[\s\ufffc]/u.test(rich.text[i])) positions.push(i);
39695
+ const inserts = [];
39696
+ for (const link of rich.links) {
39697
+ if (!safeUrl(link.url)) throw new Error("Unsupported link scheme in note");
39698
+ let span;
39699
+ for (let i = 0; i < chars.length; i++) {
39700
+ if (positions[i] < link.start || positions[i] >= link.start + link.length) continue;
39701
+ const c = chars[i];
39702
+ if (span?.token === c.token) span.end = c.end;
39703
+ else {
39704
+ if (span) inserts.push(span);
39705
+ span = { start: c.start, end: c.end, token: c.token, url: link.url };
39706
+ }
39707
+ }
39708
+ if (span) inserts.push(span);
39709
+ }
39710
+ let result = base;
39711
+ for (const span of inserts.sort((a, b) => b.start - a.start))
39712
+ result = result.slice(0, span.start) + `<a href="${escapeAttribute(span.url)}">` + result.slice(span.start, span.end) + "</a>" + result.slice(span.end);
39713
+ return result;
39714
+ }
39715
+ function enrichNoteRead(id, rawBody) {
39716
+ let metadata;
39717
+ try {
39718
+ const rich = readRichNote(id);
39719
+ metadata = rich;
39720
+ const content = restoreNoteLinks(rawBody, rich);
39721
+ const writable = !rich.hasNativeObjects && !rich.hasChecklist;
39722
+ return {
39723
+ content,
39724
+ links: rich.links,
39725
+ nativeTags: rich.nativeTags,
39726
+ complete: writable,
39727
+ writable,
39728
+ revision: rich.revision,
39729
+ ...!writable ? {
39730
+ warning: "Native tags, inline objects or checklists are present. Their state is not writable through AppleScript; full-body edits are blocked to preserve them."
39731
+ } : {}
39732
+ };
39733
+ } catch {
39734
+ return {
39735
+ content: rawBody,
39736
+ links: metadata?.links ?? [],
39737
+ nativeTags: metadata?.nativeTags ?? [],
39738
+ complete: false,
39739
+ writable: false,
39740
+ revision: metadata?.revision ?? "unavailable",
39741
+ warning: "Rich Notes metadata could not be read or matched. Links/native tags may be missing from this view. Full-body edits are blocked; check Full Disk Access and retry after sync."
39742
+ };
39743
+ }
39744
+ }
39745
+ function richContentHash(rawBody, rich) {
39746
+ return `sha256:${createHash("sha256").update(rawBody).update("\0").update(rich.revision).digest("hex")}`;
39747
+ }
39748
+ function htmlLinks(html) {
39749
+ const links = [];
39750
+ for (const match of html.matchAll(
39751
+ /<a\b[^>]*\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>([\s\S]*?)<\/a>/gi
39752
+ )) {
39753
+ const url = (match[1] ?? match[2]).replace(/&(?:#[0-9]+|#x[0-9a-f]+|[a-z]+);/gi, decodeEntity);
39754
+ if (!safeUrl(url)) throw new Error("Unsupported link scheme");
39755
+ links.push({
39756
+ text: visibleCharacters(match[3]).map((c) => c.value).join(""),
39757
+ url
39758
+ });
39759
+ }
39760
+ return links;
39761
+ }
39762
+ function linkSignature(links) {
39763
+ return JSON.stringify(
39764
+ links.flatMap(
39765
+ (link) => normalized(link.text).split("").map((char) => [char, link.url])
39766
+ )
39767
+ );
39768
+ }
39769
+ function assertLinkedWrite(rich, content, format, allowLinkChanges = false) {
39770
+ if (!rich.writable) throw new Error(rich.warning || "Rich note cannot be safely rewritten");
39771
+ if (rich.links.length && format !== "html" && !allowLinkChanges)
39772
+ throw new Error(
39773
+ "This note has links. Use format=html and preserve the linked HTML returned by get-note-content."
39774
+ );
39775
+ const next = format === "html" ? htmlLinks(content) : [];
39776
+ if (allowLinkChanges) return;
39777
+ const needed = /* @__PURE__ */ new Map();
39778
+ for (const link of rich.links) {
39779
+ const key = linkSignature([link]);
39780
+ needed.set(key, (needed.get(key) || 0) + 1);
39781
+ }
39782
+ const incoming = linkSignature(next);
39783
+ for (const [key, count] of needed) {
39784
+ const sequence = key.slice(1, -1);
39785
+ if (incoming.split(sequence).length - 1 < count)
39786
+ throw new Error(
39787
+ "Update would remove or change an existing link. Preserve its label and URL from get-note-content."
39788
+ );
39789
+ }
39790
+ }
39791
+
39487
39792
  // src/utils/attachmentFs.ts
39488
39793
  import {
39489
39794
  existsSync as existsSync2,
@@ -39495,11 +39800,11 @@ import {
39495
39800
  rmSync,
39496
39801
  statSync
39497
39802
  } from "fs";
39498
- import { dirname, isAbsolute, join as join2, relative, resolve, sep } from "path";
39499
- import { homedir as homedir2, tmpdir } from "os";
39803
+ import { dirname, isAbsolute, join as join3, relative, resolve, sep } from "path";
39804
+ import { homedir as homedir3, tmpdir } from "os";
39500
39805
  function allowedSaveRoots() {
39501
39806
  return [
39502
- resolve(homedir2()),
39807
+ resolve(homedir3()),
39503
39808
  resolve(tmpdir()),
39504
39809
  "/Volumes",
39505
39810
  "/private/var/folders",
@@ -39573,7 +39878,7 @@ function assertSafeSavePath(p, roots = allowedSaveRoots()) {
39573
39878
  if (suffix.split(sep).includes("..")) {
39574
39879
  throw new Error(`Refusing to write outside allowed locations (home, temp, /Volumes): "${abs}"`);
39575
39880
  }
39576
- const canonicalDest = suffix ? join2(canonicalAncestor, suffix) : canonicalAncestor;
39881
+ const canonicalDest = suffix ? join3(canonicalAncestor, suffix) : canonicalAncestor;
39577
39882
  const allowed = canonicalRoots(roots);
39578
39883
  if (!isWithinRoots(canonicalAncestor, allowed) || !isWithinRoots(canonicalDest, allowed)) {
39579
39884
  throw new Error(
@@ -39623,8 +39928,8 @@ function cleanupTempDir(dir) {
39623
39928
  // src/services/appleNotesManager.ts
39624
39929
  var import_turndown = __toESM(require_turndown_cjs(), 1);
39625
39930
  import { existsSync as existsSync3 } from "fs";
39626
- import { homedir as homedir3 } from "os";
39627
- import { join as join3 } from "path";
39931
+ import { homedir as homedir4 } from "os";
39932
+ import { join as join4 } from "path";
39628
39933
  var FIELD_SEP = "";
39629
39934
  var RECORD_SEP = "";
39630
39935
  var AS_FIELD_SEP = "(ASCII character 31)";
@@ -39702,8 +40007,8 @@ function parseAppleScriptDate(appleScriptDate) {
39702
40007
  return isNaN(dt.getTime()) ? /* @__PURE__ */ new Date() : dt;
39703
40008
  }
39704
40009
  const withoutPrefix = s.replace(/^date\s+/, "");
39705
- const normalized = withoutPrefix.replace(" at ", " ");
39706
- const parsed = new Date(normalized);
40010
+ const normalized2 = withoutPrefix.replace(" at ", " ");
40011
+ const parsed = new Date(normalized2);
39707
40012
  return isNaN(parsed.getTime()) ? /* @__PURE__ */ new Date() : parsed;
39708
40013
  }
39709
40014
  function buildAppleScriptDateVar(date3, varName = "thresholdDate") {
@@ -39825,11 +40130,11 @@ function getNoteLinkFromDB(coreDataId) {
39825
40130
  const match = coreDataId.match(/\/p(\d+)$/);
39826
40131
  if (!match) return null;
39827
40132
  const pk = parseInt(match[1], 10);
39828
- const dbPath = join3(homedir3(), "Library/Group Containers/group.com.apple.notes/NoteStore.sqlite");
39829
- if (!existsSync3(dbPath)) return null;
40133
+ const dbPath2 = join4(homedir4(), "Library/Group Containers/group.com.apple.notes/NoteStore.sqlite");
40134
+ if (!existsSync3(dbPath2)) return null;
39830
40135
  try {
39831
40136
  const { DatabaseSync } = __require("node:sqlite");
39832
- const db = new DatabaseSync(dbPath, { readOnly: true });
40137
+ const db = new DatabaseSync(dbPath2, { readOnly: true });
39833
40138
  try {
39834
40139
  const row = db.prepare("SELECT ZIDENTIFIER FROM ZICCLOUDSYNCINGOBJECT WHERE Z_PK = ?").get(pk);
39835
40140
  const identifier = row?.ZIDENTIFIER;
@@ -40320,8 +40625,13 @@ var AppleNotesManager = class {
40320
40625
  * race that would exist if JavaScript checked the note and then issued a
40321
40626
  * separate unconditional `set body` command.
40322
40627
  */
40323
- updateNoteByIdIfUnchanged(id, currentTitle, expectedBody, newTitle, newContent, format = "plaintext") {
40628
+ updateNoteByIdIfUnchanged(id, currentTitle, expectedBody, newTitle, newContent, format = "plaintext", expectedRichRevision) {
40324
40629
  const safeId = sanitizeNoteId(id);
40630
+ if (expectedRichRevision) {
40631
+ const rich = readRichNote(id);
40632
+ if (rich.revision !== expectedRichRevision) return { status: "conflict" };
40633
+ if (rich.hasNativeObjects || rich.hasChecklist) return { status: "attachments" };
40634
+ }
40325
40635
  if (newTitle) validateLength(newTitle, MAX_TITLE_LENGTH, "Note title");
40326
40636
  validateLength(newContent, MAX_CONTENT_LENGTH, "Note content");
40327
40637
  validateLength(expectedBody, MAX_CONTENT_LENGTH, "Expected note content");
@@ -41887,8 +42197,9 @@ var AppleNotesManager = class {
41887
42197
  getNoteMarkdown(title, account) {
41888
42198
  const html = this.getNoteContent(title, account);
41889
42199
  if (!html) return "";
41890
- let markdown = this.htmlToMarkdown(html);
41891
42200
  const note = this.getNoteDetails(title, account);
42201
+ const rich = note?.id ? enrichNoteRead(note.id, html) : void 0;
42202
+ let markdown = this.htmlToMarkdown(rich?.content || html);
41892
42203
  if (note?.id) {
41893
42204
  const result = getChecklistItems(note.id);
41894
42205
  if (result.items) {
@@ -41913,7 +42224,8 @@ var AppleNotesManager = class {
41913
42224
  getNoteMarkdownById(id) {
41914
42225
  const html = this.getNoteContentById(id);
41915
42226
  if (!html) return "";
41916
- let markdown = this.htmlToMarkdown(html);
42227
+ const rich = enrichNoteRead(id, html);
42228
+ let markdown = this.htmlToMarkdown(rich.content);
41917
42229
  const result = getChecklistItems(id);
41918
42230
  if (result.items) {
41919
42231
  markdown = this.enrichMarkdownWithChecklists(markdown, result.items);
@@ -41923,7 +42235,7 @@ var AppleNotesManager = class {
41923
42235
  };
41924
42236
 
41925
42237
  // src/utils/syncDetection.ts
41926
- import { execFileSync as execFileSync3 } from "child_process";
42238
+ import { execFileSync as execFileSync4 } from "child_process";
41927
42239
  import * as fs2 from "fs";
41928
42240
  import * as path2 from "path";
41929
42241
  import * as os2 from "os";
@@ -41968,7 +42280,7 @@ function getSyncStatus(useCache = true) {
41968
42280
  WHERE object.ZCLOUDSTATE = state.Z_PK
41969
42281
  );
41970
42282
  `;
41971
- const result = execFileSync3(
42283
+ const result = execFileSync4(
41972
42284
  "sqlite3",
41973
42285
  ["-readonly", NOTES_DB_PATH2, query.replace(/\n/g, " ")],
41974
42286
  {
@@ -42027,7 +42339,7 @@ function withSyncAwarenessSync(operation, fn) {
42027
42339
  }
42028
42340
 
42029
42341
  // src/utils/noteMetadata.ts
42030
- import { execFileSync as execFileSync4 } from "child_process";
42342
+ import { execFileSync as execFileSync5 } from "child_process";
42031
42343
  import * as fs3 from "fs";
42032
42344
  import * as path3 from "path";
42033
42345
  import * as os3 from "os";
@@ -42048,7 +42360,7 @@ var COLUMN_MAP = [
42048
42360
  { key: "smartFolderQuery", column: "ZSMARTFOLDERQUERYJSON", type: "text" }
42049
42361
  ];
42050
42362
  function runSqlite(query) {
42051
- return execFileSync4("sqlite3", ["-readonly", NOTES_DB_PATH3, query], {
42363
+ return execFileSync5("sqlite3", ["-readonly", NOTES_DB_PATH3, query], {
42052
42364
  encoding: "utf8",
42053
42365
  timeout: 5e3,
42054
42366
  stdio: ["pipe", "pipe", "pipe"]
@@ -42319,12 +42631,12 @@ function formatDoctorReport(r) {
42319
42631
 
42320
42632
  // src/services/fileConfig.ts
42321
42633
  import { existsSync as existsSync6, readFileSync as readFileSync2 } from "fs";
42322
- import { join as join6 } from "path";
42323
- import { homedir as homedir6 } from "os";
42634
+ import { join as join7 } from "path";
42635
+ import { homedir as homedir7 } from "os";
42324
42636
  function fileConfigPath(env = process.env) {
42325
42637
  const override = env.APPLE_NOTES_MCP_CONFIG_FILE;
42326
42638
  if (override && override.trim()) return override.trim();
42327
- return join6(homedir6(), "Library", "Application Support", "apple-notes-mcp", "config.json");
42639
+ return join7(homedir7(), "Library", "Application Support", "apple-notes-mcp", "config.json");
42328
42640
  }
42329
42641
  function loadFileConfig(env = process.env, path4 = fileConfigPath(env)) {
42330
42642
  const applied = [];
@@ -42532,12 +42844,9 @@ function withJsonSchema2020_12(transport2) {
42532
42844
  }
42533
42845
 
42534
42846
  // src/utils/noteRevision.ts
42535
- import { createHash } from "node:crypto";
42536
- function hashNoteContent(content) {
42537
- return `sha256:${createHash("sha256").update(content, "utf8").digest("hex")}`;
42538
- }
42847
+ var INLINE_TAG = /^<\/?(?:b|i|u|s|strike|em|strong|span|a|font|sub|sup|code|tt|small|big|mark)\b/i;
42539
42848
  function comparableVisibleText(html) {
42540
- 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(
42849
+ return html.replace(/<br\s*\/?\s*>/gi, " ").replace(/<[^>]*>/g, (tag) => INLINE_TAG.test(tag) ? "" : " ").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(
42541
42850
  /&#x([0-9a-f]+);/gi,
42542
42851
  (_match, codePoint) => String.fromCodePoint(Number.parseInt(codePoint, 16))
42543
42852
  ).replace(/\s+/g, " ").trim();
@@ -42610,7 +42919,8 @@ function readExactNoteSnapshot(id) {
42610
42919
  }
42611
42920
  const body = notesManager.getNoteContentById(id);
42612
42921
  if (!body) return { error: `Failed to read content of note "${note.title}"` };
42613
- return { note, body, contentHash: hashNoteContent(body) };
42922
+ const rich = enrichNoteRead(id, body);
42923
+ return { note, body, rich, contentHash: richContentHash(body, rich) };
42614
42924
  }
42615
42925
  function revisionConflictMessage(title) {
42616
42926
  return `Note "${title}" changed after it was read. Read it again and review the newer version before retrying.`;
@@ -42640,7 +42950,7 @@ registerTool(
42640
42950
  ),
42641
42951
  format: external_exports.enum(["plaintext", "html"]).optional().default("plaintext").describe("Content format: 'plaintext' (default) or 'html' for rich formatting"),
42642
42952
  tags: external_exports.array(external_exports.string().max(MAX.TAG)).max(MAX.TAGS).optional().describe(
42643
- "Returned-only metadata \u2014 NOT written to Notes.app. Apple Notes tags can't be set via AppleScript, so any values passed here are echoed back in the response but do not appear on the created note. Use #hashtags inside the content body instead (Notes.app turns those into real tags)."
42953
+ "Returned-only metadata \u2014 NOT written to Notes.app. Apple Notes tags can't be set via AppleScript, so any values passed here are echoed back in the response but do not appear on the created note. Use #hashtags in the body for searchable text; this does not create native tag objects. Native tags need the Notes Shortcuts action."
42644
42954
  ),
42645
42955
  folder: external_exports.string().max(MAX.FOLDER).optional().describe(
42646
42956
  "Folder to create the note in (supports nested paths like 'Work/Clients'). The folder must already exist \u2014 this tool does not create it; call create-folder first, which is idempotent and creates intermediate segments."
@@ -42674,7 +42984,7 @@ registerTool(
42674
42984
  `A note may have been created, but its exact ID could not be verified. Do not retry automatically. Returned ID: ${note.id}`
42675
42985
  );
42676
42986
  }
42677
- const contentHash = hashNoteContent(createdBody);
42987
+ const contentHash = richContentHash(createdBody, enrichNoteRead(note.id, createdBody));
42678
42988
  const checklistWarning = detectChecklistAttempt(content) ?? "";
42679
42989
  return successResponse(`Note created: "${note.title}" [id: ${note.id}]${checklistWarning}`, {
42680
42990
  ok: true,
@@ -42763,7 +43073,7 @@ ${noteList}${truncationNote}${syncNote}`,
42763
43073
  registerTool(
42764
43074
  "get-note-content",
42765
43075
  {
42766
- 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.",
43076
+ 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, nativeTags, restored links, richContentComplete/writable, and strippedImages/truncated when the body was capped. Read the warning when writable is false.\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.",
42767
43077
  inputSchema: {
42768
43078
  id: external_exports.string().max(MAX.ID).optional().describe("Note ID (preferred - more reliable than title)"),
42769
43079
  title: external_exports.string().max(MAX.TITLE).optional().describe("Note title (use id instead when available)"),
@@ -42777,6 +43087,13 @@ registerTool(
42777
43087
  content: external_exports.string().optional(),
42778
43088
  contentHash: external_exports.string().optional(),
42779
43089
  hashtags: external_exports.array(external_exports.string()).optional(),
43090
+ nativeTags: external_exports.array(external_exports.string()).optional(),
43091
+ links: external_exports.array(
43092
+ external_exports.object({ start: external_exports.number(), length: external_exports.number(), text: external_exports.string(), url: external_exports.string() })
43093
+ ).optional(),
43094
+ richContentComplete: external_exports.boolean().optional(),
43095
+ writable: external_exports.boolean().optional(),
43096
+ warning: external_exports.string().optional(),
42780
43097
  /** Number of oversized inline images replaced with text placeholders. */
42781
43098
  strippedImages: external_exports.number().optional(),
42782
43099
  /** True when content is lossy — see strippedImages. Never write a truncated body back. */
@@ -42798,15 +43115,21 @@ registerTool(
42798
43115
  if (!rawContent2) {
42799
43116
  return errorResponse(`Failed to read content of note "${note2.title}"`);
42800
43117
  }
42801
- const stripped2 = stripLargeInlineImages(rawContent2);
43118
+ const rich2 = enrichNoteRead(id, rawContent2);
43119
+ const stripped2 = stripLargeInlineImages(rich2.content);
42802
43120
  const content2 = stripped2.html;
42803
43121
  const hashtags2 = parseHashtags(content2);
42804
- const warning2 = strippedImagesWarning(stripped2);
43122
+ const warning2 = [strippedImagesWarning(stripped2), rich2.warning].filter(Boolean).join("\n\n");
42805
43123
  return successResponse(warning2 ? content2 + warning2 : content2, {
42806
43124
  id,
42807
43125
  title: note2.title,
42808
43126
  content: content2,
42809
- contentHash: hashNoteContent(rawContent2),
43127
+ contentHash: richContentHash(rawContent2, rich2),
43128
+ links: rich2.links,
43129
+ nativeTags: rich2.nativeTags,
43130
+ richContentComplete: rich2.complete,
43131
+ writable: rich2.writable && stripped2.strippedCount === 0,
43132
+ warning: rich2.warning,
42810
43133
  hashtags: hashtags2,
42811
43134
  strippedImages: stripped2.strippedCount,
42812
43135
  truncated: stripped2.strippedCount > 0
@@ -42828,15 +43151,21 @@ registerTool(
42828
43151
  if (!rawContent) {
42829
43152
  return errorResponse(`Failed to read content of note "${title}"`);
42830
43153
  }
42831
- const stripped = stripLargeInlineImages(rawContent);
43154
+ const rich = enrichNoteRead(note.id, rawContent);
43155
+ const stripped = stripLargeInlineImages(rich.content);
42832
43156
  const content = stripped.html;
42833
43157
  const hashtags = parseHashtags(content);
42834
- const warning = strippedImagesWarning(stripped);
43158
+ const warning = [strippedImagesWarning(stripped), rich.warning].filter(Boolean).join("\n\n");
42835
43159
  return successResponse(warning ? content + warning : content, {
42836
43160
  id: note.id,
42837
43161
  title,
42838
43162
  content,
42839
- contentHash: hashNoteContent(rawContent),
43163
+ contentHash: richContentHash(rawContent, rich),
43164
+ links: rich.links,
43165
+ nativeTags: rich.nativeTags,
43166
+ richContentComplete: rich.complete,
43167
+ writable: rich.writable && stripped.strippedCount === 0,
43168
+ warning: rich.warning,
42840
43169
  hashtags,
42841
43170
  strippedImages: stripped.strippedCount,
42842
43171
  truncated: stripped.strippedCount > 0
@@ -43080,10 +43409,13 @@ registerTool(
43080
43409
  registerTool(
43081
43410
  "update-note",
43082
43411
  {
43083
- 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.",
43412
+ description: "Use when: replacing the body of one exact Apple Note after reading it by id.\nReturns: exact id, new content hash, and visible-text readback verification.\nDo not use when: you only have a title, the note changed since the read, or the note has attachments.\nSafety: requires the exact note id and expectedContentHash from get-note-content. The server checks rich metadata revision, atomically checks the AppleScript body, blocks native objects/checklists, and verifies actual link destinations after saving. Preserve returned HTML links unless allowLinkChanges is explicitly requested. Notes.app normalizes HTML, so rich formatting is not claimed as byte-identical.",
43084
43413
  inputSchema: {
43085
43414
  id: noteIdInput,
43086
43415
  expectedContentHash: expectedContentHashInput,
43416
+ allowLinkChanges: external_exports.boolean().optional().default(false).describe(
43417
+ "Set true only when the user explicitly intends to remove, relabel or change existing links. Defaults to preserving all links."
43418
+ ),
43087
43419
  newTitle: external_exports.string().max(MAX.TITLE).optional().describe(
43088
43420
  "New title for plaintext updates. Ignored when format is 'html'; include the visible title as the first line of newContent instead."
43089
43421
  ),
@@ -43102,72 +43434,90 @@ registerTool(
43102
43434
  verifiedVisibleText: external_exports.boolean().optional()
43103
43435
  }
43104
43436
  },
43105
- withErrorHandling(({ id, expectedContentHash, newTitle, newContent, format = "plaintext" }) => {
43106
- const snapshot = readExactNoteSnapshot(id);
43107
- if ("error" in snapshot) return errorResponse(snapshot.error);
43108
- if (snapshot.contentHash !== expectedContentHash) {
43109
- return errorResponse(revisionConflictMessage(snapshot.note.title));
43110
- }
43111
- const attachments = notesManager.listAttachmentsById(id);
43112
- if (attachments.length > 0) {
43113
- return errorResponse(
43114
- `Note "${snapshot.note.title}" has ${attachments.length} attachment(s). Full-body replacement is blocked; edit it in Notes.app.`
43115
- );
43116
- }
43117
- const result = notesManager.updateNoteByIdIfUnchanged(
43437
+ withErrorHandling(
43438
+ ({
43118
43439
  id,
43119
- snapshot.note.title,
43120
- snapshot.body,
43440
+ expectedContentHash,
43121
43441
  newTitle,
43122
43442
  newContent,
43123
- format
43124
- );
43125
- if (result.status === "conflict") {
43126
- return errorResponse(revisionConflictMessage(snapshot.note.title));
43127
- }
43128
- if (result.status === "attachments") {
43129
- return errorResponse(
43130
- `Note "${snapshot.note.title}" gained an attachment before saving. No content was replaced.`
43443
+ format = "plaintext",
43444
+ allowLinkChanges = false
43445
+ }) => {
43446
+ const snapshot = readExactNoteSnapshot(id);
43447
+ if ("error" in snapshot) return errorResponse(snapshot.error);
43448
+ if (snapshot.contentHash !== expectedContentHash) {
43449
+ return errorResponse(revisionConflictMessage(snapshot.note.title));
43450
+ }
43451
+ assertLinkedWrite(snapshot.rich, newContent, format, allowLinkChanges);
43452
+ const attachments = notesManager.listAttachmentsById(id);
43453
+ if (attachments.length > 0) {
43454
+ return errorResponse(
43455
+ `Note "${snapshot.note.title}" has ${attachments.length} attachment(s). Full-body replacement is blocked; edit it in Notes.app.`
43456
+ );
43457
+ }
43458
+ const result = notesManager.updateNoteByIdIfUnchanged(
43459
+ id,
43460
+ snapshot.note.title,
43461
+ snapshot.body,
43462
+ newTitle,
43463
+ newContent,
43464
+ format,
43465
+ snapshot.rich.revision
43131
43466
  );
43132
- }
43133
- if (result.status !== "updated") {
43134
- return errorResponse(
43135
- `The update result for note "${snapshot.note.title}" is uncertain. Read the exact ID before retrying.`
43467
+ if (result.status === "conflict") {
43468
+ return errorResponse(revisionConflictMessage(snapshot.note.title));
43469
+ }
43470
+ if (result.status === "attachments") {
43471
+ return errorResponse(
43472
+ `Note "${snapshot.note.title}" gained an attachment before saving. No content was replaced.`
43473
+ );
43474
+ }
43475
+ if (result.status !== "updated") {
43476
+ return errorResponse(
43477
+ `The update result for note "${snapshot.note.title}" is uncertain. Read the exact ID before retrying.`
43478
+ );
43479
+ }
43480
+ const readback = notesManager.getNoteContentById(id);
43481
+ const richReadback = enrichNoteRead(id, readback || "");
43482
+ const contentHash = readback ? richContentHash(readback, richReadback) : "";
43483
+ if (!richReadback.complete || linkSignature(richReadback.links) !== linkSignature(htmlLinks(result.writtenBody))) {
43484
+ return errorResponse(
43485
+ "The note accepted the write, but rich-link readback is not verified. Read the exact ID before retrying; do not repeat the write automatically."
43486
+ );
43487
+ }
43488
+ if (!readback || comparableVisibleText(readback) !== comparableVisibleText(result.writtenBody)) {
43489
+ return errorResponse(
43490
+ `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.`
43491
+ );
43492
+ }
43493
+ const displayTitle = resolveUpdateResponseTitle(
43494
+ snapshot.note.title,
43495
+ newTitle,
43496
+ format,
43497
+ newContent
43136
43498
  );
43137
- }
43138
- const readback = notesManager.getNoteContentById(id);
43139
- const contentHash = readback ? hashNoteContent(readback) : "";
43140
- if (!readback || comparableVisibleText(readback) !== comparableVisibleText(result.writtenBody)) {
43141
- return errorResponse(
43142
- `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.`
43499
+ const sharedWarning = snapshot.note.shared ? "\n\n\u26A0\uFE0F This note is shared with collaborators. Your changes are visible to them." : "";
43500
+ const checklistWarning = detectChecklistAttempt(newContent) ?? "";
43501
+ return successResponse(
43502
+ `Note updated; visible text verified: "${displayTitle}" [id: ${id}]${sharedWarning}${checklistWarning}`,
43503
+ {
43504
+ ok: true,
43505
+ id,
43506
+ title: displayTitle,
43507
+ shared: snapshot.note.shared ?? false,
43508
+ previousContentHash: expectedContentHash,
43509
+ contentHash,
43510
+ verifiedVisibleText: true
43511
+ }
43143
43512
  );
43144
- }
43145
- const displayTitle = resolveUpdateResponseTitle(
43146
- snapshot.note.title,
43147
- newTitle,
43148
- format,
43149
- newContent
43150
- );
43151
- const sharedWarning = snapshot.note.shared ? "\n\n\u26A0\uFE0F This note is shared with collaborators. Your changes are visible to them." : "";
43152
- const checklistWarning = detectChecklistAttempt(newContent) ?? "";
43153
- return successResponse(
43154
- `Note updated; visible text verified: "${displayTitle}" [id: ${id}]${sharedWarning}${checklistWarning}`,
43155
- {
43156
- ok: true,
43157
- id,
43158
- title: displayTitle,
43159
- shared: snapshot.note.shared ?? false,
43160
- previousContentHash: expectedContentHash,
43161
- contentHash,
43162
- verifiedVisibleText: true
43163
- }
43164
- );
43165
- }, "Error updating note")
43513
+ },
43514
+ "Error updating note"
43515
+ )
43166
43516
  );
43167
43517
  registerTool(
43168
43518
  "append-to-note",
43169
43519
  {
43170
- 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.",
43520
+ 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 or native objects.\nSafety: append rewrites the HTML body, so it uses exact-ID, rich revision, native-object, attachment, link, and readback guards. Notes.app normalizes HTML, so rich formatting is not claimed as byte-identical.",
43171
43521
  inputSchema: {
43172
43522
  id: noteIdInput,
43173
43523
  expectedContentHash: expectedContentHashInput,
@@ -43215,15 +43565,21 @@ registerTool(
43215
43565
  if (snapshot.contentHash !== expectedContentHash) {
43216
43566
  return errorResponse(revisionConflictMessage(snapshot.note.title));
43217
43567
  }
43568
+ if (!snapshot.rich.writable) {
43569
+ return errorResponse(
43570
+ snapshot.rich.warning || `Note "${snapshot.note.title}" contains native objects that cannot be preserved by a full-body append.`
43571
+ );
43572
+ }
43218
43573
  const attachments = notesManager.listAttachmentsById(id);
43219
43574
  if (attachments.length > 0) {
43220
43575
  return errorResponse(
43221
43576
  `Note "${snapshot.note.title}" has ${attachments.length} attachment(s). Append is blocked because it rewrites the full body; edit it in Notes.app.`
43222
43577
  );
43223
43578
  }
43224
- const firstDivEnd = snapshot.body.indexOf("</div>");
43225
- const titleDiv = firstDivEnd !== -1 ? snapshot.body.slice(0, firstDivEnd + 6) : "";
43226
- const bodyHtml = firstDivEnd !== -1 ? snapshot.body.slice(firstDivEnd + 6) : snapshot.body;
43579
+ assertLinkedWrite(snapshot.rich, snapshot.rich.content, "html");
43580
+ const firstDivEnd = snapshot.rich.content.indexOf("</div>");
43581
+ const titleDiv = firstDivEnd !== -1 ? snapshot.rich.content.slice(0, firstDivEnd + 6) : "";
43582
+ const bodyHtml = firstDivEnd !== -1 ? snapshot.rich.content.slice(firstDivEnd + 6) : snapshot.rich.content;
43227
43583
  const newBlock = contentToHtml(content);
43228
43584
  const sepHtml = separatorToHtml(separator);
43229
43585
  const combinedBody = position === "before" ? titleDiv + newBlock + sepHtml + bodyHtml : titleDiv + bodyHtml + sepHtml + newBlock;
@@ -43233,7 +43589,8 @@ registerTool(
43233
43589
  snapshot.body,
43234
43590
  void 0,
43235
43591
  combinedBody,
43236
- "html"
43592
+ "html",
43593
+ snapshot.rich.revision
43237
43594
  );
43238
43595
  if (result.status === "conflict") {
43239
43596
  return errorResponse(revisionConflictMessage(snapshot.note.title));
@@ -43249,7 +43606,13 @@ registerTool(
43249
43606
  );
43250
43607
  }
43251
43608
  const readback = notesManager.getNoteContentById(id);
43252
- const contentHash = readback ? hashNoteContent(readback) : "";
43609
+ const richReadback = enrichNoteRead(id, readback || "");
43610
+ const contentHash = readback ? richContentHash(readback, richReadback) : "";
43611
+ if (!richReadback.complete || linkSignature(richReadback.links) !== linkSignature(htmlLinks(result.writtenBody))) {
43612
+ return errorResponse(
43613
+ "The note accepted the write, but rich-link readback is not verified. Read the exact ID before retrying; do not repeat the write automatically."
43614
+ );
43615
+ }
43253
43616
  if (!readback || comparableVisibleText(readback) !== comparableVisibleText(result.writtenBody)) {
43254
43617
  return errorResponse(
43255
43618
  `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.`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.8.3",
3
+ "version": "2.8.5",
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",