apple-notes-mcp 2.9.13 → 2.9.14

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 +18 -1
  2. package/build/index.js +287 -128
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -425,6 +425,7 @@ Searches for notes by title or content.
425
425
  | `folder` | string | No | Limit search to a specific folder (supports nested paths like `"Work/Clients"`) |
426
426
  | `modifiedSince` | string | No | ISO 8601 date string to filter notes modified on or after this date (e.g., `"2025-01-01"`) |
427
427
  | `limit` | number | No | Maximum number of results to return. **Defaults to 50** — a broad query reads several properties per match via AppleScript (~200ms/note), so an unbounded search over hundreds of matches can exceed Notes' 30s timeout and return an error instead of results. Pass a higher value to see more; the applied limit (and whether it truncated the results) is disclosed in the response. |
428
+ | `includeWordCount` | boolean | No | Add `wordCount` to each result (`null` when the note is locked or its body unreadable). A database content search already has the text. Otherwise the bodies are read in one batched read-only database query, never one AppleScript call per note; that needs Full Disk Access and also adds `matchedIn`. Without it, the response carries `wordCountUnavailable` and the results are unchanged. |
428
429
 
429
430
  **Example - Search titles:**
430
431
  ```json
@@ -453,6 +454,17 @@ Searches for notes by title or content.
453
454
 
454
455
  **Returns:** List of matching notes with titles, folder names, and IDs. Use the returned ID for subsequent operations like `get-note-content`, `update-note`, etc. A content search also returns `source` (`"database"` or `"applescript"`), and `scanTruncated` when the database path left older notes unsearched.
455
456
 
457
+ When the note text came from the database (a database content search, or any search with `includeWordCount`), each result also carries `matchedIn`: `["title"]`, `["body"]`, or `["title", "body"]`, saying where the query text occurs. The body is the text after the first line. `matchedIn` is absent for locked notes and for AppleScript searches without `includeWordCount`. The text output appends the same details to each line, for example `· matched in title, body · 245 words`.
458
+
459
+ **Example - Content search with word counts:**
460
+ ```json
461
+ {
462
+ "query": "budget",
463
+ "searchContent": true,
464
+ "includeWordCount": true
465
+ }
466
+ ```
467
+
456
468
  ---
457
469
 
458
470
  #### `query-notes`
@@ -470,6 +482,7 @@ can match titles and bodies together.
470
482
  | `limit` | number | No | Maximum notes to return. Defaults to 50, maximum 500. The response reports the total match count. |
471
483
  | `scanLimit` | number | No | How many of the most recently modified notes to examine. Defaults to 500, maximum 5000. The response says when older notes were left unscanned. |
472
484
  | `includeDeleted` | boolean | No | Also scan notes in Recently Deleted, notes pending deletion, and folderless notes. Defaults to `false`. |
485
+ | `includeWordCount` | boolean | No | Add `wordCount` to each returned note, the same count `words:` filters on (`null` when locked or unreadable). Free when the query already reads bodies; a metadata-only query (for example `pinned`) reads just the returned notes' bodies in one extra read-only query. Defaults to `false`. |
473
486
 
474
487
  **Syntax:**
475
488
 
@@ -514,7 +527,11 @@ Their snippets are always empty.
514
527
 
515
528
  **Returns:** Matching notes, most recently modified first, each with `id`,
516
529
  `title`, `folder`, `account`, `modified`, `created`, and a `snippet` centred on
517
- the first matched phrase. The ids are the same `x-coredata://…/ICNote/p…` form
530
+ the first matched phrase. Each note also has `matchedIn` when the query has a
531
+ positive text term: `["title"]`, `["body"]`, or both, saying where those terms
532
+ occur (a `title:` term is only looked for in the title, a `body:` term only in
533
+ the body). It is absent for locked or undecodable notes, and an empty list
534
+ means the note matched through a non-text branch such as `pinned OR x`. The ids are the same `x-coredata://…/ICNote/p…` form
518
535
  every other tool accepts. `structuredContent` also reports `matched` (total
519
536
  matches), `scanned`, `eligible`, `scanTruncated`, `truncated`, and `unreadable`
520
537
  (bodies that could not be decoded). A malformed query returns an error naming
package/build/index.js CHANGED
@@ -46933,13 +46933,6 @@ function describeSearchScope(searchContent, resultCount) {
46933
46933
  return "\n\n\u2139\uFE0F Only note titles were searched, so a term that appears in note bodies would not match. Retry with `searchContent: true` to search bodies instead.";
46934
46934
  }
46935
46935
 
46936
- // src/utils/noteQueryStore.ts
46937
- import { execFileSync as execFileSync14 } from "child_process";
46938
- import * as fs8 from "fs";
46939
- import * as os8 from "os";
46940
- import * as path8 from "path";
46941
- import { gunzipSync as gunzipSync8 } from "zlib";
46942
-
46943
46936
  // src/utils/noteQuery.ts
46944
46937
  var QUERY_LIMITS = {
46945
46938
  /** Maximum number of tokens (terms, operators, parentheses). */
@@ -47371,21 +47364,50 @@ function evaluateNoteQuery(node, note) {
47371
47364
  }
47372
47365
  }
47373
47366
  }
47374
- function positiveTextTerms(node, negated = false) {
47367
+ function positiveTextPredicates(node, negated = false) {
47375
47368
  switch (node.type) {
47376
47369
  case "and":
47377
47370
  case "or":
47378
- return node.children.flatMap((child) => positiveTextTerms(child, negated));
47371
+ return node.children.flatMap((child) => positiveTextPredicates(child, negated));
47379
47372
  case "not":
47380
- return positiveTextTerms(node.child, !negated);
47373
+ return positiveTextPredicates(node.child, !negated);
47381
47374
  case "text":
47382
- return negated ? [] : [node.value];
47375
+ return negated ? [] : [{ field: node.field, value: node.value }];
47383
47376
  default:
47384
47377
  return [];
47385
47378
  }
47386
47379
  }
47380
+ function positiveTextTerms(node, negated = false) {
47381
+ return positiveTextPredicates(node, negated).map((predicate) => predicate.value);
47382
+ }
47383
+ function matchLocations(predicates, title, text2) {
47384
+ if (predicates.length === 0) return void 0;
47385
+ if (text2 === null && predicates.some((p) => p.field !== "title")) return void 0;
47386
+ const firstBreak = text2 === null ? -1 : text2.indexOf("\n");
47387
+ const titleLower = normalizeForMatch(title);
47388
+ const firstLineLower = text2 === null ? "" : normalizeForMatch(firstBreak === -1 ? text2 : text2.slice(0, firstBreak));
47389
+ const bodyLower = text2 === null || firstBreak === -1 ? "" : normalizeForMatch(text2.slice(firstBreak + 1));
47390
+ let inTitle = false;
47391
+ let inBody = false;
47392
+ for (const predicate of predicates) {
47393
+ const needle = normalizeForMatch(predicate.value);
47394
+ if (predicate.field !== "body" && !inTitle) {
47395
+ inTitle = titleLower.includes(needle) || firstLineLower.includes(needle);
47396
+ }
47397
+ if (predicate.field !== "title" && !inBody) inBody = bodyLower.includes(needle);
47398
+ }
47399
+ const locations = [];
47400
+ if (inTitle) locations.push("title");
47401
+ if (inBody) locations.push("body");
47402
+ return locations;
47403
+ }
47387
47404
 
47388
47405
  // src/utils/noteQueryStore.ts
47406
+ import { execFileSync as execFileSync14 } from "child_process";
47407
+ import * as fs8 from "fs";
47408
+ import * as os8 from "os";
47409
+ import * as path8 from "path";
47410
+ import { gunzipSync as gunzipSync8 } from "zlib";
47389
47411
  var NOTES_DB_PATH10 = path8.join(
47390
47412
  os8.homedir(),
47391
47413
  "Library/Group Containers/group.com.apple.notes/NoteStore.sqlite"
@@ -47467,6 +47489,15 @@ function decodeNoteBody(data) {
47467
47489
  objectIds
47468
47490
  };
47469
47491
  }
47492
+ function decodeBodyHex(hex3) {
47493
+ try {
47494
+ return decodeNoteBody(
47495
+ new Uint8Array(gunzipSync8(Buffer.from(hex3, "hex"), { maxOutputLength: 32 * 1024 * 1024 }))
47496
+ );
47497
+ } catch {
47498
+ return null;
47499
+ }
47500
+ }
47470
47501
  function countWords(text2) {
47471
47502
  let count = 0;
47472
47503
  for (const word of text2.replace(/\ufffc/gu, " ").split(/\s+/u)) {
@@ -47537,6 +47568,67 @@ function presentColumns2(dbPath2) {
47537
47568
  }
47538
47569
  return cols;
47539
47570
  }
47571
+ function readStore(dbPath2, build) {
47572
+ if (!fs8.existsSync(dbPath2)) throw new NoteQueryStoreError(QUERY_FDA_MESSAGE, "no_fda");
47573
+ try {
47574
+ return runSqlite6(dbPath2, build(presentColumns2(dbPath2)));
47575
+ } catch (error2) {
47576
+ if (error2 instanceof NoteQueryStoreError) throw error2;
47577
+ const message = error2 instanceof Error ? error2.message : String(error2);
47578
+ if (message.includes("authorization denied") || message.includes("unable to open database")) {
47579
+ throw new NoteQueryStoreError(QUERY_FDA_MESSAGE, "no_fda");
47580
+ }
47581
+ console.error(`query-notes: database read failed: ${message}`);
47582
+ throw new NoteQueryStoreError("Failed to read the Notes database.", "query_error");
47583
+ }
47584
+ }
47585
+ var NOTE_TEXT_BATCH_MAX = QUERY_RESULTS.MAX;
47586
+ function buildNoteTextsSql(available, pks) {
47587
+ const missing = ["Z_PK", "Z_ENT"].filter((c) => !available.has(c));
47588
+ if (missing.length) {
47589
+ throw new NoteQueryStoreError(
47590
+ `This macOS version's Notes database lacks columns search enrichment needs (${missing.join(", ")}).`,
47591
+ "schema"
47592
+ );
47593
+ }
47594
+ const keys = [...new Set(pks)];
47595
+ if (keys.length > NOTE_TEXT_BATCH_MAX) {
47596
+ throw new NoteQueryStoreError(
47597
+ `At most ${NOTE_TEXT_BATCH_MAX} notes can be read at once.`,
47598
+ "query_error"
47599
+ );
47600
+ }
47601
+ for (const pk of keys) {
47602
+ if (!Number.isSafeInteger(pk) || pk < 1) {
47603
+ throw new NoteQueryStoreError(`Invalid note key ${String(pk)}`, "query_error");
47604
+ }
47605
+ }
47606
+ const locked = available.has("ZISPASSWORDPROTECTED") ? "COALESCE(n.ZISPASSWORDPROTECTED, 0)" : "0";
47607
+ return [
47608
+ "BEGIN;",
47609
+ "SELECT json_object('k', 'meta', 'uuid', (SELECT Z_UUID FROM Z_METADATA LIMIT 1));",
47610
+ `SELECT json_object('k', 'note', 'pk', n.Z_PK, 'locked', ${locked}, 'data', (SELECT hex(d.ZDATA) FROM ZICNOTEDATA d WHERE d.ZNOTE = n.Z_PK ORDER BY d.Z_PK DESC LIMIT 1)) FROM ZICCLOUDSYNCINGOBJECT n WHERE n.Z_ENT = ${entity2("ICNote")} AND n.Z_PK IN (${keys.length ? keys.join(", ") : "NULL"});`,
47611
+ "COMMIT;"
47612
+ ].join(" ");
47613
+ }
47614
+ function readNoteTexts(pks, options = {}) {
47615
+ const output = readStore(
47616
+ options.dbPath ?? NOTES_DB_PATH10,
47617
+ (available) => buildNoteTextsSql(available, pks)
47618
+ );
47619
+ let uuid2;
47620
+ const texts = /* @__PURE__ */ new Map();
47621
+ for (const line of output.split("\n")) {
47622
+ if (!line.trim()) continue;
47623
+ const row = JSON.parse(line);
47624
+ if (row.k === "meta") uuid2 = typeof row.uuid === "string" ? row.uuid : void 0;
47625
+ else if (row.k === "note" && typeof row.pk === "number") {
47626
+ const body = row.locked || !row.data ? null : decodeBodyHex(row.data);
47627
+ texts.set(row.pk, body ? body.text : null);
47628
+ }
47629
+ }
47630
+ return { uuid: uuid2, texts };
47631
+ }
47540
47632
  var escapeSegment = escapeFolderName;
47541
47633
  function resolveFolders(rows) {
47542
47634
  const byPk = new Map(rows.map((row) => [row.pk, row]));
@@ -47593,23 +47685,10 @@ function runNoteQuery(ast, options = {}) {
47593
47685
  const withBodies = needsContent(ast);
47594
47686
  const withTags = needsTags(ast);
47595
47687
  const dbPath2 = options.dbPath ?? NOTES_DB_PATH10;
47596
- if (!fs8.existsSync(dbPath2)) throw new NoteQueryStoreError(QUERY_FDA_MESSAGE, "no_fda");
47597
- let output;
47598
- try {
47599
- const available = presentColumns2(dbPath2);
47600
- output = runSqlite6(
47601
- dbPath2,
47602
- buildScanSql(available, { scanLimit, includeDeleted, withBodies, withTags })
47603
- );
47604
- } catch (error2) {
47605
- if (error2 instanceof NoteQueryStoreError) throw error2;
47606
- const message = error2 instanceof Error ? error2.message : String(error2);
47607
- if (message.includes("authorization denied") || message.includes("unable to open database")) {
47608
- throw new NoteQueryStoreError(QUERY_FDA_MESSAGE, "no_fda");
47609
- }
47610
- console.error(`query-notes: database read failed: ${message}`);
47611
- throw new NoteQueryStoreError("Failed to read the Notes database.", "query_error");
47612
- }
47688
+ const output = readStore(
47689
+ dbPath2,
47690
+ (available) => buildScanSql(available, { scanLimit, includeDeleted, withBodies, withTags })
47691
+ );
47613
47692
  let uuid2;
47614
47693
  let eligible = 0;
47615
47694
  const folderRows = [];
@@ -47633,6 +47712,8 @@ function runNoteQuery(ast, options = {}) {
47633
47712
  }
47634
47713
  const folders = resolveFolders(folderRows);
47635
47714
  const textTerms = positiveTextTerms(ast);
47715
+ const textPredicates = positiveTextPredicates(ast);
47716
+ const pendingWordCounts = [];
47636
47717
  const hits = [];
47637
47718
  let matched = 0;
47638
47719
  let unreadable = 0;
@@ -47650,15 +47731,7 @@ function runNoteQuery(ast, options = {}) {
47650
47731
  if (withBodies) unreadable++;
47651
47732
  return decoded;
47652
47733
  }
47653
- try {
47654
- decoded = decodeNoteBody(
47655
- new Uint8Array(
47656
- gunzipSync8(Buffer.from(row.data, "hex"), { maxOutputLength: 32 * 1024 * 1024 })
47657
- )
47658
- );
47659
- } catch {
47660
- decoded = null;
47661
- }
47734
+ decoded = decodeBodyHex(row.data);
47662
47735
  if (!decoded) unreadable++;
47663
47736
  return decoded;
47664
47737
  };
@@ -47699,6 +47772,8 @@ function runNoteQuery(ast, options = {}) {
47699
47772
  matched++;
47700
47773
  if (hits.length >= limit) continue;
47701
47774
  const body = locked ? null : decode2();
47775
+ const matchedIn = matchLocations(textPredicates, row.title ?? "", body ? body.text : null);
47776
+ if (options.includeWordCount && !withBodies) pendingWordCounts.push(row.pk);
47702
47777
  hits.push({
47703
47778
  id: `x-coredata://${uuid2}/ICNote/p${row.pk}`,
47704
47779
  title: row.title ?? "",
@@ -47707,7 +47782,16 @@ function runNoteQuery(ast, options = {}) {
47707
47782
  ...note.modified !== void 0 ? { modified: new Date(note.modified).toISOString() } : {},
47708
47783
  ...note.created !== void 0 ? { created: new Date(note.created).toISOString() } : {},
47709
47784
  snippet: locked ? "" : body ? buildSnippet(body.text, textTerms) : (row.snippet ?? "").replace(/\s+/gu, " ").trim(),
47710
- ...locked ? { locked: true } : {}
47785
+ ...locked ? { locked: true } : {},
47786
+ ...matchedIn ? { matchedIn } : {},
47787
+ ...options.includeWordCount && withBodies ? { wordCount: body ? countWords(body.text) : null } : {}
47788
+ });
47789
+ }
47790
+ if (pendingWordCounts.length) {
47791
+ const { texts } = readNoteTexts(pendingWordCounts, { dbPath: dbPath2 });
47792
+ pendingWordCounts.forEach((pk, index) => {
47793
+ const text2 = texts.get(pk);
47794
+ hits[index].wordCount = typeof text2 === "string" ? countWords(text2) : null;
47711
47795
  });
47712
47796
  }
47713
47797
  return {
@@ -47750,6 +47834,7 @@ function searchContentViaDatabase(options) {
47750
47834
  const result = runNoteQuery(buildSearchContentQuery(options), {
47751
47835
  limit: options.limit,
47752
47836
  scanLimit: QUERY_SCAN.MAX,
47837
+ includeWordCount: options.includeWordCount,
47753
47838
  dbPath: options.dbPath
47754
47839
  });
47755
47840
  const notes = result.notes.map((hit) => ({
@@ -47760,7 +47845,9 @@ function searchContentViaDatabase(options) {
47760
47845
  created: hit.created ? new Date(hit.created) : /* @__PURE__ */ new Date(0),
47761
47846
  modified: hit.modified ? new Date(hit.modified) : /* @__PURE__ */ new Date(0),
47762
47847
  ...hit.folder !== void 0 ? { folder: hit.folder } : {},
47763
- ...hit.account !== void 0 ? { account: hit.account } : {}
47848
+ ...hit.account !== void 0 ? { account: hit.account } : {},
47849
+ ...hit.matchedIn ? { matchedIn: hit.matchedIn } : {},
47850
+ ...hit.wordCount !== void 0 ? { wordCount: hit.wordCount } : {}
47764
47851
  }));
47765
47852
  return {
47766
47853
  notes,
@@ -47783,6 +47870,55 @@ function contentSearchFailureHint(message, dbUnavailable) {
47783
47870
  const remedy = dbUnavailable === "no_fda" ? " Grant Full Disk Access to the Node binary running this server (run the doctor tool for its path) so search-notes can search note bodies through the Notes database instead, which takes well under a second." : "";
47784
47871
  return `${message} Body search through AppleScript scans every note body before the result limit applies, so a broad term can exceed the time budget on a large library.${remedy} Otherwise narrow the search with \`folder\` or \`modifiedSince\`, or use a more specific term.`;
47785
47872
  }
47873
+ var NOTE_ID = /^x-coredata:\/\/([0-9A-Fa-f-]+)\/ICNote\/p(\d{1,15})$/;
47874
+ function addWordCountsFromDatabase(notes, query2, options = {}) {
47875
+ const keys = /* @__PURE__ */ new Map();
47876
+ for (const note of notes) {
47877
+ const match = NOTE_ID.exec(note.id ?? "");
47878
+ if (match) keys.set(note, { store: match[1].toUpperCase(), pk: Number(match[2]) });
47879
+ }
47880
+ const pks = [...new Set([...keys.values()].map((key) => key.pk))];
47881
+ let store;
47882
+ const texts = /* @__PURE__ */ new Map();
47883
+ try {
47884
+ for (let start = 0; start < pks.length; start += NOTE_TEXT_BATCH_MAX) {
47885
+ const read = readNoteTexts(pks.slice(start, start + NOTE_TEXT_BATCH_MAX), options);
47886
+ store = read.uuid?.toUpperCase();
47887
+ for (const [pk, text2] of read.texts) texts.set(pk, text2);
47888
+ }
47889
+ } catch (error2) {
47890
+ if (error2 instanceof NoteQueryStoreError) return { notes, unavailable: error2.kind };
47891
+ throw error2;
47892
+ }
47893
+ const predicates = [{ field: "any", value: query2 }];
47894
+ return {
47895
+ notes: notes.map((note) => {
47896
+ const key = keys.get(note);
47897
+ const text2 = key && key.store === store ? texts.get(key.pk) : void 0;
47898
+ if (typeof text2 !== "string") return { ...note, wordCount: null };
47899
+ const matchedIn = matchLocations(predicates, note.title, text2);
47900
+ return {
47901
+ ...note,
47902
+ ...matchedIn?.length ? { matchedIn } : {},
47903
+ wordCount: countWords(text2)
47904
+ };
47905
+ })
47906
+ };
47907
+ }
47908
+ function describeMatchDetails(hit) {
47909
+ const parts = [];
47910
+ if (hit.matchedIn) {
47911
+ parts.push(
47912
+ hit.matchedIn.length ? `matched in ${hit.matchedIn.join(", ")}` : "no text match (metadata)"
47913
+ );
47914
+ }
47915
+ if (hit.wordCount !== void 0) {
47916
+ parts.push(
47917
+ hit.wordCount === null ? "word count unavailable" : `${hit.wordCount} word${hit.wordCount === 1 ? "" : "s"}`
47918
+ );
47919
+ }
47920
+ return parts.map((part) => ` \xB7 ${part}`).join("");
47921
+ }
47786
47922
 
47787
47923
  // src/tools/doctor.ts
47788
47924
  import { spawnSync } from "child_process";
@@ -49333,7 +49469,7 @@ function selectParagraph(paragraphs, selector) {
49333
49469
  );
49334
49470
  return matches[occurrence - 1];
49335
49471
  }
49336
- var NOTE_ID = /^x-coredata:\/\/[0-9a-f-]+\/ICNote\/p([0-9]{1,15})$/i;
49472
+ var NOTE_ID2 = /^x-coredata:\/\/[0-9a-f-]+\/ICNote\/p([0-9]{1,15})$/i;
49337
49473
  function titleMatchSql(columns) {
49338
49474
  return `SELECT json_object('pk', n.Z_PK, 'folder', n.ZFOLDER) FROM ZICCLOUDSYNCINGOBJECT n LEFT JOIN ZICCLOUDSYNCINGOBJECT f ON f.Z_PK = n.ZFOLDER WHERE n.Z_ENT = ${entity("ICNote")} AND n.ZTITLE1 = CAST(@title AS TEXT) AND ${activeNoteSql(columns, "n", "f")} ORDER BY n.Z_PK;`;
49339
49475
  }
@@ -49344,7 +49480,7 @@ function checkNoteSelector(selector) {
49344
49480
  if (selector.folder !== void 0 && selector.title === void 0)
49345
49481
  throw new ParagraphLinkError("invalid-argument", "folder only narrows a title lookup");
49346
49482
  if (selector.id === void 0) return void 0;
49347
- const pk = NOTE_ID.exec(selector.id)?.[1];
49483
+ const pk = NOTE_ID2.exec(selector.id)?.[1];
49348
49484
  if (!pk)
49349
49485
  throw new ParagraphLinkError(
49350
49486
  "invalid-argument",
@@ -56827,7 +56963,7 @@ registerTool(
56827
56963
  registerTool(
56828
56964
  "search-notes",
56829
56965
  {
56830
- description: "Use when: finding notes by a keyword in the title (or body with searchContent=true) and you need their ids.\nReturns: matching notes with title, folder, and id; `source` says whether a body search read the Notes database or fell back to AppleScript.\nDo not use when: you already have a note id (use get-note-content), want every note (use list-notes), or need boolean or metadata filters (use query-notes).\nPrefer this first to obtain ids for subsequent read/update/delete/move calls.\nNote: with Full Disk Access, body search reads the Notes database (fast; the most recent 5000 notes, Recently Deleted excluded); without it, it falls back to AppleScript, which scans every body and can time out on broad terms.",
56966
+ description: "Use when: finding notes by a keyword in the title (or body with searchContent=true) and you need their ids.\nReturns: matching notes with title, folder, and id; `source` says whether a body search read the Notes database or fell back to AppleScript. When the note text came from the database, each result has matchedIn (title, body, or both); includeWordCount adds wordCount.\nDo not use when: you already have a note id (use get-note-content), want every note (use list-notes), or need boolean or metadata filters (use query-notes).\nPrefer this first to obtain ids for subsequent read/update/delete/move calls.\nNote: with Full Disk Access, body search reads the Notes database (fast; the most recent 5000 notes, Recently Deleted excluded); without it, it falls back to AppleScript, which scans every body and can time out on broad terms.",
56831
56967
  inputSchema: {
56832
56968
  query: external_exports.string().min(1, "Search query is required").max(MAX.QUERY),
56833
56969
  searchContent: external_exports.boolean().optional().describe(
@@ -56840,112 +56976,132 @@ registerTool(
56840
56976
  ),
56841
56977
  limit: external_exports.number().int().positive().optional().describe(
56842
56978
  "Maximum number of results to return. Defaults to 50 \u2014 a broad query reads several properties per match via AppleScript, so an unbounded search can time out. Pass a higher value to see more; the applied limit is disclosed in the response."
56979
+ ),
56980
+ includeWordCount: external_exports.boolean().optional().describe(
56981
+ "Add wordCount to each result (null when locked or unreadable). A database body search already has the text; otherwise the bodies are read in one batched read-only database query (needs Full Disk Access), which also adds matchedIn"
56843
56982
  )
56844
56983
  },
56845
56984
  outputSchema: {
56846
56985
  notes: external_exports.array(external_exports.object({}).passthrough()).optional(),
56847
56986
  count: external_exports.number().optional(),
56848
56987
  source: external_exports.enum(["database", "applescript"]).optional(),
56849
- scanTruncated: external_exports.boolean().optional()
56988
+ scanTruncated: external_exports.boolean().optional(),
56989
+ wordCountUnavailable: external_exports.string().optional()
56850
56990
  }
56851
56991
  },
56852
- withErrorHandling(({ query: query2, searchContent = false, account, folder, modifiedSince, limit }) => {
56853
- const effectiveLimit = resolveSearchLimit(limit);
56854
- const limitWasDefault = limit === void 0;
56855
- let source;
56856
- let dbScan;
56857
- let dbUnavailable;
56858
- const runSearch = () => {
56859
- if (searchContent) {
56992
+ withErrorHandling(
56993
+ ({ query: query2, searchContent = false, account, folder, modifiedSince, limit, includeWordCount }) => {
56994
+ const effectiveLimit = resolveSearchLimit(limit);
56995
+ const limitWasDefault = limit === void 0;
56996
+ let source;
56997
+ let dbScan;
56998
+ let dbUnavailable;
56999
+ const runSearch = () => {
57000
+ if (searchContent) {
57001
+ try {
57002
+ const db = searchContentViaDatabase({
57003
+ query: query2,
57004
+ account: notesManager.searchAccountScope(account),
57005
+ folder,
57006
+ modifiedSince,
57007
+ limit: effectiveLimit,
57008
+ includeWordCount
57009
+ });
57010
+ source = "database";
57011
+ dbScan = db.scan;
57012
+ return db.notes;
57013
+ } catch (error2) {
57014
+ if (!(error2 instanceof NoteQueryStoreError)) throw error2;
57015
+ dbUnavailable = error2.kind;
57016
+ }
57017
+ source = "applescript";
57018
+ }
56860
57019
  try {
56861
- const db = searchContentViaDatabase({
56862
- query: query2,
56863
- account: notesManager.searchAccountScope(account),
57020
+ return notesManager.searchNotes(
57021
+ query2,
57022
+ searchContent,
57023
+ account,
56864
57024
  folder,
56865
57025
  modifiedSince,
56866
- limit: effectiveLimit
56867
- });
56868
- source = "database";
56869
- dbScan = db.scan;
56870
- return db.notes;
57026
+ effectiveLimit
57027
+ );
56871
57028
  } catch (error2) {
56872
- if (!(error2 instanceof NoteQueryStoreError)) throw error2;
56873
- dbUnavailable = error2.kind;
57029
+ if (!searchContent || !(error2 instanceof Error)) throw error2;
57030
+ throw new Error(contentSearchFailureHint(error2.message, dbUnavailable));
56874
57031
  }
56875
- source = "applescript";
57032
+ };
57033
+ const {
57034
+ result: found,
57035
+ syncBefore,
57036
+ syncInterference
57037
+ } = withSyncAwarenessSync("search-notes", runSearch);
57038
+ let notes = found;
57039
+ let wordCountUnavailable;
57040
+ if (includeWordCount && source !== "database" && found.length > 0) {
57041
+ const enriched = addWordCountsFromDatabase(found, query2);
57042
+ notes = enriched.notes;
57043
+ wordCountUnavailable = enriched.unavailable;
57044
+ }
57045
+ const wordCountFields = wordCountUnavailable ? { wordCountUnavailable } : {};
57046
+ const wordCountNote = wordCountUnavailable ? `
57047
+
57048
+ \u2139\uFE0F Word counts were not added: ${wordCountUnavailable === "no_fda" ? "they are read from the Notes database, which needs Full Disk Access (run the doctor tool)" : "the Notes database could not be read"}.` : "";
57049
+ const searchType = searchContent ? source === "database" ? "content via the Notes database" : "content" : "titles";
57050
+ const sourceFields = source ? { source } : {};
57051
+ const scanFields = dbScan ? { scanTruncated: dbScan.scanTruncated } : {};
57052
+ const scanNote = describeContentScan(dbScan);
57053
+ const folderInfo = folder ? ` in folder "${folder}"` : "";
57054
+ const dateInfo = modifiedSince ? ` modified since ${modifiedSince}` : "";
57055
+ const { info: limitInfo, truncationNote } = describeSearchLimit(
57056
+ effectiveLimit,
57057
+ limitWasDefault,
57058
+ notes.length
57059
+ );
57060
+ const syncWarnings = [];
57061
+ if (syncBefore.syncDetected) {
57062
+ syncWarnings.push(`\u26A0\uFE0F iCloud sync was active during search.`);
56876
57063
  }
56877
- try {
56878
- return notesManager.searchNotes(
56879
- query2,
56880
- searchContent,
56881
- account,
56882
- folder,
56883
- modifiedSince,
56884
- effectiveLimit
56885
- );
56886
- } catch (error2) {
56887
- if (!searchContent || !(error2 instanceof Error)) throw error2;
56888
- throw new Error(contentSearchFailureHint(error2.message, dbUnavailable));
57064
+ if (syncInterference) {
57065
+ syncWarnings.push(`\u26A0\uFE0F Sync activity detected - results may be incomplete.`);
56889
57066
  }
56890
- };
56891
- const {
56892
- result: notes,
56893
- syncBefore,
56894
- syncInterference
56895
- } = withSyncAwarenessSync("search-notes", runSearch);
56896
- const searchType = searchContent ? source === "database" ? "content via the Notes database" : "content" : "titles";
56897
- const sourceFields = source ? { source } : {};
56898
- const scanFields = dbScan ? { scanTruncated: dbScan.scanTruncated } : {};
56899
- const scanNote = describeContentScan(dbScan);
56900
- const folderInfo = folder ? ` in folder "${folder}"` : "";
56901
- const dateInfo = modifiedSince ? ` modified since ${modifiedSince}` : "";
56902
- const { info: limitInfo, truncationNote } = describeSearchLimit(
56903
- effectiveLimit,
56904
- limitWasDefault,
56905
- notes.length
56906
- );
56907
- const syncWarnings = [];
56908
- if (syncBefore.syncDetected) {
56909
- syncWarnings.push(`\u26A0\uFE0F iCloud sync was active during search.`);
56910
- }
56911
- if (syncInterference) {
56912
- syncWarnings.push(`\u26A0\uFE0F Sync activity detected - results may be incomplete.`);
56913
- }
56914
- const syncNote = syncWarnings.length > 0 ? `
57067
+ const syncNote = syncWarnings.length > 0 ? `
56915
57068
 
56916
57069
  ${syncWarnings.join(" ")}` : "";
56917
- if (notes.length === 0) {
56918
- const scopeHint = describeSearchScope(searchContent, notes.length);
57070
+ if (notes.length === 0) {
57071
+ const scopeHint = describeSearchScope(searchContent, notes.length);
57072
+ return successResponse(
57073
+ `No notes found matching "${query2}" in ${searchType}${folderInfo}${dateInfo}${scopeHint}${scanNote}${syncNote}`,
57074
+ { notes: [], count: 0, ...sourceFields, ...scanFields }
57075
+ );
57076
+ }
57077
+ const noteList = notes.map((n) => {
57078
+ const idSuffix = `${n.id ? ` [id: ${n.id}]` : ""}${describeMatchDetails(n)}`;
57079
+ if (n.folder === "Recently Deleted") {
57080
+ return ` - ${n.title} [DELETED]${idSuffix}`;
57081
+ } else if (n.folder) {
57082
+ return ` - ${n.title} (${n.folder})${idSuffix}`;
57083
+ }
57084
+ return ` - ${n.title}${idSuffix}`;
57085
+ }).join("\n");
56919
57086
  return successResponse(
56920
- `No notes found matching "${query2}" in ${searchType}${folderInfo}${dateInfo}${scopeHint}${scanNote}${syncNote}`,
56921
- { notes: [], count: 0, ...sourceFields, ...scanFields }
57087
+ `Found ${notes.length} notes (searched ${searchType}${folderInfo}${dateInfo}${limitInfo}):
57088
+ ${noteList}${truncationNote}${scanNote}${wordCountNote}${syncNote}`,
57089
+ {
57090
+ notes: withStableIdentifiers(notes, "ICNote"),
57091
+ count: notes.length,
57092
+ ...sourceFields,
57093
+ ...scanFields,
57094
+ ...wordCountFields
57095
+ }
56922
57096
  );
56923
- }
56924
- const noteList = notes.map((n) => {
56925
- const idSuffix = n.id ? ` [id: ${n.id}]` : "";
56926
- if (n.folder === "Recently Deleted") {
56927
- return ` - ${n.title} [DELETED]${idSuffix}`;
56928
- } else if (n.folder) {
56929
- return ` - ${n.title} (${n.folder})${idSuffix}`;
56930
- }
56931
- return ` - ${n.title}${idSuffix}`;
56932
- }).join("\n");
56933
- return successResponse(
56934
- `Found ${notes.length} notes (searched ${searchType}${folderInfo}${dateInfo}${limitInfo}):
56935
- ${noteList}${truncationNote}${scanNote}${syncNote}`,
56936
- {
56937
- notes: withStableIdentifiers(notes, "ICNote"),
56938
- count: notes.length,
56939
- ...sourceFields,
56940
- ...scanFields
56941
- }
56942
- );
56943
- }, "Error searching notes")
57097
+ },
57098
+ "Error searching notes"
57099
+ )
56944
57100
  );
56945
57101
  registerTool(
56946
57102
  "query-notes",
56947
57103
  {
56948
- description: 'Use when: finding notes with a boolean expression over text and metadata \u2014 e.g. `folder:Work has:checklist -checklist:done`, `(title:invoice OR tag:finance) modified:>=2026-07-01`, `pinned words:>250`. Reads the Notes database directly, so it is fast and can match title OR body in one call.\nSyntax: bare words and "quoted phrases" match title or body (case-insensitive substring); fields title:, body:, text:, folder:, account:, tag: (values may be quoted, e.g. folder:"Work Projects"); facets has:link|attachment|checklist|drawing|image|video|audio|pdf|table|scan|tag; checklist:open|done; flags pinned, locked, shared (or is:pinned); words:>250 and created:/modified: with =, >, >=, <, <= and YYYY-MM-DD local dates. AND is implicit; OR, NOT, leading -, and parentheses are supported; operators are case-insensitive and a quoted "and" searches the literal word.\nReturns: matching notes (most recently modified first) with id, title, folder, account, modified date, and snippet, plus scan/match counts. Ids work with get-note-content and every other id-based tool.\nDo not use when: Full Disk Access is unavailable (use search-notes). Scans the most recent scanLimit notes (default 500); raise it for older notes.\nSafety: read-only; never writes the database. Excludes Recently Deleted and folderless notes unless includeDeleted is true. Locked notes match on title and metadata only; body predicates never match them.',
57104
+ description: 'Use when: finding notes with a boolean expression over text and metadata \u2014 e.g. `folder:Work has:checklist -checklist:done`, `(title:invoice OR tag:finance) modified:>=2026-07-01`, `pinned words:>250`. Reads the Notes database directly, so it is fast and can match title OR body in one call.\nSyntax: bare words and "quoted phrases" match title or body (case-insensitive substring); fields title:, body:, text:, folder:, account:, tag: (values may be quoted, e.g. folder:"Work Projects"); facets has:link|attachment|checklist|drawing|image|video|audio|pdf|table|scan|tag; checklist:open|done; flags pinned, locked, shared (or is:pinned); words:>250 and created:/modified: with =, >, >=, <, <= and YYYY-MM-DD local dates. AND is implicit; OR, NOT, leading -, and parentheses are supported; operators are case-insensitive and a quoted "and" searches the literal word.\nReturns: matching notes (most recently modified first) with id, title, folder, account, modified date, snippet, and matchedIn (where the positive text terms occur: title, body, or both; absent when the body is unreadable or the query has no text term), plus scan/match counts; includeWordCount adds wordCount. Ids work with get-note-content and every other id-based tool.\nDo not use when: Full Disk Access is unavailable (use search-notes). Scans the most recent scanLimit notes (default 500); raise it for older notes.\nSafety: read-only; never writes the database. Excludes Recently Deleted and folderless notes unless includeDeleted is true. Locked notes match on title and metadata only; body predicates never match them.',
56949
57105
  inputSchema: {
56950
57106
  query: external_exports.string().min(1, "A query expression is required").max(MAX.QUERY).describe(
56951
57107
  'Boolean query expression, e.g. `folder:"Work Projects" has:checklist -checklist:done`'
@@ -56958,6 +57114,9 @@ registerTool(
56958
57114
  ),
56959
57115
  includeDeleted: external_exports.boolean().optional().describe(
56960
57116
  "Also scan notes in Recently Deleted, notes pending deletion, and folderless notes (default false)"
57117
+ ),
57118
+ includeWordCount: external_exports.boolean().optional().describe(
57119
+ "Add wordCount to each returned note (the count words: filters on; null when locked or unreadable). Free when the query reads bodies; a metadata-only query reads just the returned notes' bodies in one extra query (default false)"
56961
57120
  )
56962
57121
  },
56963
57122
  outputSchema: {
@@ -56974,10 +57133,10 @@ registerTool(
56974
57133
  },
56975
57134
  annotations: { readOnlyHint: true }
56976
57135
  },
56977
- withErrorHandling(({ query: query2, limit, scanLimit, includeDeleted }) => {
57136
+ withErrorHandling(({ query: query2, limit, scanLimit, includeDeleted, includeWordCount }) => {
56978
57137
  let result;
56979
57138
  try {
56980
- result = queryNotes(query2, { limit, scanLimit, includeDeleted });
57139
+ result = queryNotes(query2, { limit, scanLimit, includeDeleted, includeWordCount });
56981
57140
  } catch (error2) {
56982
57141
  if (error2 instanceof NoteQueryError || error2 instanceof NoteQueryStoreError) {
56983
57142
  return errorResponse(
@@ -57008,7 +57167,7 @@ ${notes.join("\n")}` : "";
57008
57167
  const where = [n.account, n.folder].filter(Boolean).join(" / ");
57009
57168
  const snippet = n.snippet ? `
57010
57169
  ${n.snippet}` : "";
57011
- return ` - ${n.title}${where ? ` (${where})` : ""}${n.locked ? " [locked]" : ""} [id: ${n.id}]${snippet}`;
57170
+ return ` - ${n.title}${where ? ` (${where})` : ""}${n.locked ? " [locked]" : ""} [id: ${n.id}]${describeMatchDetails(n)}${snippet}`;
57012
57171
  }).join("\n");
57013
57172
  return successResponse(
57014
57173
  `Found ${result.matched} matching notes (${scope2}):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.9.13",
3
+ "version": "2.9.14",
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",