apple-notes-mcp 2.9.12 → 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 +23 -4
  2. package/build/index.js +456 -217
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -257,7 +257,7 @@ Creates a new note in Apple Notes.
257
257
  | `content` | string | One of `content`/`contentPath` | The body content of the note (do not repeat the title here) |
258
258
  | `contentPath` | string | One of `content`/`contentPath` | Absolute path of a local UTF-8 file to use as the body instead of `content`. Allowed in the same places [`save-attachment`](#save-attachment) may write (home, temp, `/Volumes`); symbolic links, non-regular files, invalid UTF-8 and files over 1 MiB are refused before anything is written. A leading byte-order mark is dropped |
259
259
  | `tags` | string[] | No | Returned-only metadata — **NOT written to Notes.app**. Apple Notes tags can't be set via AppleScript, so values passed here are echoed back in the response but do not appear on the created note. Use inline `#hashtags` in `content` instead (Notes.app turns those into real tags). Refused with `format: "markdown"` |
260
- | `folder` | string | No | Folder to create the note in. Supports nested paths like `"Work/Clients"`. **The folder must already exist** — create it first with [`create-folder`](#create-folder). Defaults to account root |
260
+ | `folder` | string | No | Folder to create the note in. Supports nested paths like `"Work/Clients"`. **The folder must already exist** — create it first with [`create-folder`](#create-folder). A smart folder is refused (see [`move-note`](#move-note)). Defaults to account root |
261
261
  | `account` | string | No | Account name (defaults to Notes.app's default account; matched exactly or by a *unique* prefix — an ambiguous prefix is refused). Must be an account Notes.app already has configured — see [`list-accounts`](#list-accounts) |
262
262
  | `format` | string | No | Content format: `"plaintext"` (default), `"html"`, or `"markdown"`. In all formats, the title is automatically prepended as the note's title line. In plaintext mode, newlines become `<br>`, tabs become `<br>`, and backslashes are preserved as HTML entities. `"markdown"` produces real Title/Heading/Subheading styles through a Shortcut; see [Markdown notes](#markdown-notes) |
263
263
  | `markdownRoute` | string | No | With `format: "markdown"` only: `"shortcut"` (default) or `"html"`. See [Markdown through HTML](#markdown-through-html) |
@@ -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
@@ -1032,6 +1049,8 @@ such a note for good, do it in Notes.app.
1032
1049
 
1033
1050
  Moves a note to a different folder. The note is relocated in place via Notes.app's native `move`, so its id, creation date, and all embedded attachments (files, images, scans, PDFs, audio) are preserved. The destination folder must already exist — create it first with [`create-folder`](#create-folder).
1034
1051
 
1052
+ A smart folder is never a destination: it only gathers notes by its rules, and Notes.app would move the note to Recently Deleted or store a created note where no folder shows it. `create-note`, `create-note-with-attachment`, `move-note`, `batch-move-notes`, and `create-folder` refuse one before writing anything, with `code: "unsupported"`, `committed: false`, and `reason: "smart_folder_destination"`. An ordinary folder that shares a smart folder's name is still found. Detection reads the NoteStore database, so it needs Full Disk Access; without it, destinations resolve as before.
1053
+
1035
1054
  | Parameter | Type | Required | Description |
1036
1055
  |-----------|------|----------|-------------|
1037
1056
  | `id` | string | Yes | Exact CoreData note ID returned by a read or search |
@@ -1402,7 +1421,7 @@ Creates a new folder, including a whole nested hierarchy in one call.
1402
1421
 
1403
1422
  | Parameter | Type | Required | Description |
1404
1423
  |-----------|------|----------|-------------|
1405
- | `name` | string | Yes | Folder name, or a nested path separated by `/` (e.g. `"Retro Tech/PC/CPUs"`). Every intermediate folder is created; segments that already exist are skipped |
1424
+ | `name` | string | Yes | Folder name, or a nested path separated by `/` (e.g. `"Retro Tech/PC/CPUs"`). Every intermediate folder is created; segments that already exist are skipped. A segment that names a smart folder is refused before anything is created |
1406
1425
  | `account` | string | No | Account to create folder in (defaults to Notes.app's default account; exact or unique-prefix match) |
1407
1426
 
1408
1427
  **Example:**
@@ -1590,7 +1609,7 @@ Moves multiple notes to a folder.
1590
1609
  | Parameter | Type | Required | Description |
1591
1610
  |-----------|------|----------|-------------|
1592
1611
  | `ids` | string[] | Yes | Array of note IDs to move (max 500 per request) |
1593
- | `folder` | string | Yes | Destination folder name or nested path (e.g., `"Work/Clients"`). Must already exist — create it with [`create-folder`](#create-folder) |
1612
+ | `folder` | string | Yes | Destination folder name or nested path (e.g., `"Work/Clients"`). Must already exist — create it with [`create-folder`](#create-folder). A smart folder is refused for the whole call before any note moves |
1594
1613
  | `account` | string | No | Account containing the folder |
1595
1614
 
1596
1615
  **Returns:** Summary of successes and failures. Each success is reported only
package/build/index.js CHANGED
@@ -39588,6 +39588,77 @@ function getChecklistItems(noteId3) {
39588
39588
  return { items };
39589
39589
  }
39590
39590
 
39591
+ // src/utils/errorCodes.ts
39592
+ var CodedError = class extends Error {
39593
+ envelope;
39594
+ constructor(message, envelope) {
39595
+ super(message);
39596
+ this.name = "CodedError";
39597
+ this.envelope = envelope;
39598
+ }
39599
+ };
39600
+ var RULES = [
39601
+ { code: "timeout_indeterminate", pattern: /timed out|\bETIMEDOUT\b/i },
39602
+ {
39603
+ code: "verification_failed",
39604
+ pattern: /\buncertain\b|may have (?:been|succeeded)|\baccepted (?:the|an) \w+, but|\bnot verified\b|Do not retry automatically|before any retry|read (?:the )?(?:exact )?(?:note|ID)\b[^.]*before retr/i
39605
+ },
39606
+ {
39607
+ code: "revision_conflict",
39608
+ pattern: /changed after it was read|revision changed|(?:pinned|note) state changed|changed during (?:read|preflight)|changed; read (?:it|the note) again|rich text do not match/i
39609
+ },
39610
+ { code: "full_disk_access_missing", pattern: /Full Disk Access/i },
39611
+ {
39612
+ code: "shortcut_not_installed",
39613
+ pattern: /(?:Install|Import) the supplied|no such shortcut|Run apple-notes-mcp setup/i
39614
+ },
39615
+ {
39616
+ code: "not_found",
39617
+ pattern: /not found|does not exist|\bNo \w+ found\b|does not contain any checklist items|Can[’']t get (?:note|folder|account|attachment)|Cannot find note|Scope is absent/i
39618
+ },
39619
+ { code: "ambiguous", pattern: /ambiguous|more than one|exactly once|Duplicate note IDs/i },
39620
+ {
39621
+ code: "notes_unavailable",
39622
+ pattern: /Notes\.app is (?:not responding|busy)|Lost connection to Notes|isn't running/i
39623
+ },
39624
+ {
39625
+ code: "unsupported",
39626
+ pattern: /password-protected|Locked notes|unsupported|not supported|is blocked|blocked because|has not passed live|refuses to sign|No (?:supported )?background|only one where|Real Notes link unavailable/i
39627
+ },
39628
+ {
39629
+ code: "validation_error",
39630
+ pattern: /\bis required\b|required\b|\bInvalid\b|\bmust\b|\bProvide\b|No (?:note IDs|reviewed notes) provided|response limit|Refusing to write|cannot be resolved|at most \d|between \d+ and \d+|supports the (?:end|default)|Use a distinctive|Use create-table|too large|equal cell counts|Folder path is empty/i
39631
+ }
39632
+ ];
39633
+ var NOTHING_WRITTEN = /nothing (?:was )?(?:changed|created|written)|no replacement started|No content was (?:replaced|appended)|Nothing was created/i;
39634
+ var WRITE_ACCEPTED = /\baccepted (?:the|an) \w+, but/i;
39635
+ function classifyError(message, cause) {
39636
+ if (cause instanceof CodedError) return { ...cause.envelope };
39637
+ const text2 = cause instanceof Error && cause.message && !message.includes(cause.message) ? `${message}
39638
+ ${cause.message}` : message;
39639
+ const timeoutCause = typeof cause === "object" && cause !== null && cause.code === "ETIMEDOUT";
39640
+ let code = "operation_failed";
39641
+ if (timeoutCause) code = "timeout_indeterminate";
39642
+ else if (isPermissionDenied(text2)) code = "permission_denied";
39643
+ else code = RULES.find((rule) => rule.pattern.test(text2))?.code ?? "operation_failed";
39644
+ const envelope = { code };
39645
+ if (code === "timeout_indeterminate" || code === "verification_failed") {
39646
+ envelope.indeterminate = true;
39647
+ if (WRITE_ACCEPTED.test(text2)) envelope.committed = true;
39648
+ } else if (code === "revision_conflict" || NOTHING_WRITTEN.test(text2)) {
39649
+ envelope.committed = false;
39650
+ envelope.indeterminate = false;
39651
+ }
39652
+ return envelope;
39653
+ }
39654
+ function errorResult(message, cause) {
39655
+ return {
39656
+ content: [{ type: "text", text: message }],
39657
+ structuredContent: classifyError(message, cause),
39658
+ isError: true
39659
+ };
39660
+ }
39661
+
39591
39662
  // src/utils/inlineImages.ts
39592
39663
  var DEFAULT_MAX_INLINE_IMAGE_BYTES = 256 * 1024;
39593
39664
  function maxInlineImageBytes(env = process.env) {
@@ -42358,6 +42429,24 @@ function buildFolderReference(folderPath) {
42358
42429
  }
42359
42430
  var AS_ACCOUNT_REF = "__acctRef";
42360
42431
  var LIVE_FOLDER_NOT_FOUND = "Folder not found";
42432
+ var SMART_FOLDER_DESTINATION = "SMART_FOLDER_DESTINATION";
42433
+ function readSmartFolderIds(read = readSmartFolders) {
42434
+ return (read().folders ?? []).map((folder) => folder.id).filter((id2) => FOLDER_ID_PATTERN.test(id2));
42435
+ }
42436
+ function smartFolderDestinationError(folderPath) {
42437
+ return new CodedError(
42438
+ `Refused: "${folderPath}" is a smart folder. Smart folders only gather notes by their rules and cannot hold notes or folders; choose an ordinary folder (list-folders). Nothing was changed`,
42439
+ {
42440
+ code: "unsupported",
42441
+ committed: false,
42442
+ indeterminate: false,
42443
+ reason: "smart_folder_destination"
42444
+ }
42445
+ );
42446
+ }
42447
+ function throwIfSmartFolderDestination(error2, folderPath) {
42448
+ if (error2?.includes(SMART_FOLDER_DESTINATION)) throw smartFolderDestinationError(folderPath);
42449
+ }
42361
42450
  function buildLiveFolderResolution(folderPath, varName, opts = {}) {
42362
42451
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(varName)) throw new Error("Invalid AppleScript variable");
42363
42452
  buildFolderReference(folderPath);
@@ -42366,10 +42455,26 @@ function buildLiveFolderResolution(folderPath, varName, opts = {}) {
42366
42455
  const cid = `${varName}_cid`;
42367
42456
  const any = `${varName}_any`;
42368
42457
  const parent = `${varName}_p`;
42369
- const notFound = `error "${LIVE_FOLDER_NOT_FOUND}: ${escapePlainStringForAppleScript(folderPath)}" number -1728`;
42370
- const lines = [];
42458
+ const quotedPath = escapePlainStringForAppleScript(folderPath);
42459
+ const notFound = `error "${LIVE_FOLDER_NOT_FOUND}: ${quotedPath}" number -1728`;
42460
+ const excluded = (opts.excludeFolderIds ?? []).filter((id2) => FOLDER_ID_PATTERN.test(id2));
42461
+ const skipIds = `${varName}_sx`;
42462
+ const skipped = `${varName}_sh`;
42463
+ const candidateTest = excluded.length ? [
42464
+ ` if ${skipIds} contains ${cid} then`,
42465
+ ` set ${skipped} to true`,
42466
+ ` else if exists folder id ${cid} then`
42467
+ ] : [` if exists folder id ${cid} then`];
42468
+ const unresolved = excluded.length ? [
42469
+ `if ${varName} is missing value then`,
42470
+ ` if ${skipped} then error "${SMART_FOLDER_DESTINATION}: ${quotedPath}" number -1728`,
42471
+ ` ${notFound}`,
42472
+ `end if`
42473
+ ] : [`if ${varName} is missing value then ${notFound}`];
42474
+ const lines = excluded.length ? [`set ${skipIds} to {${excluded.map((id2) => `"${id2}"`).join(", ")}}`] : [];
42371
42475
  parts.forEach((part, i) => {
42372
42476
  const name = escapePlainStringForAppleScript(part);
42477
+ if (excluded.length) lines.push(`set ${skipped} to false`);
42373
42478
  if (i === 0) {
42374
42479
  lines.push(
42375
42480
  `set ${varName} to missing value`,
@@ -42377,7 +42482,7 @@ function buildLiveFolderResolution(folderPath, varName, opts = {}) {
42377
42482
  `repeat with ${c} in (folders of ${AS_ACCOUNT_REF} whose name is "${name}")`,
42378
42483
  ` try`,
42379
42484
  ` set ${cid} to id of ${c}`,
42380
- ` if exists folder id ${cid} then`,
42485
+ ...candidateTest,
42381
42486
  ` if class of (container of ${c}) is not folder then`,
42382
42487
  ` set ${varName} to folder id ${cid}`,
42383
42488
  ` exit repeat`,
@@ -42397,7 +42502,7 @@ function buildLiveFolderResolution(folderPath, varName, opts = {}) {
42397
42502
  `repeat with ${c} in (folders of ${parent} whose name is "${name}")`,
42398
42503
  ` try`,
42399
42504
  ` set ${cid} to id of ${c}`,
42400
- ` if exists folder id ${cid} then`,
42505
+ ...candidateTest,
42401
42506
  ` set ${varName} to folder id ${cid}`,
42402
42507
  ` exit repeat`,
42403
42508
  ` end if`,
@@ -42405,7 +42510,7 @@ function buildLiveFolderResolution(folderPath, varName, opts = {}) {
42405
42510
  `end repeat`
42406
42511
  );
42407
42512
  }
42408
- lines.push(`if ${varName} is missing value then ${notFound}`);
42513
+ lines.push(...unresolved);
42409
42514
  });
42410
42515
  return lines.join("\n");
42411
42516
  }
@@ -42666,7 +42771,9 @@ var AppleNotesManager = class {
42666
42771
  const safeBody = escapeHtmlForAppleScript(`<h1>${htmlTitle}</h1>${bodyContent}`);
42667
42772
  let createCommand;
42668
42773
  if (folder) {
42669
- createCommand = `${buildLiveFolderResolution(folder, "__folder")}
42774
+ createCommand = `${buildLiveFolderResolution(folder, "__folder", {
42775
+ excludeFolderIds: this.smartFolderIds()
42776
+ })}
42670
42777
  make new note at __folder with properties {body:"${safeBody}"}`;
42671
42778
  } else {
42672
42779
  createCommand = `
@@ -42678,6 +42785,7 @@ var AppleNotesManager = class {
42678
42785
  const result = executeMutationAppleScript(script);
42679
42786
  if (!result.success) {
42680
42787
  throwIfAccountResolutionFailed(result.error);
42788
+ if (folder) throwIfSmartFolderDestination(result.error, folder);
42681
42789
  console.error(`Failed to create note "${title}":`, result.error);
42682
42790
  return null;
42683
42791
  }
@@ -43282,6 +43390,35 @@ var AppleNotesManager = class {
43282
43390
  return (resultList as text)${trashSuffix}
43283
43391
  `;
43284
43392
  }
43393
+ /**
43394
+ * Smart folder ids that a destination folder must never resolve to. Read
43395
+ * fresh on every write (a smart folder can be added at any time); empty
43396
+ * without Full Disk Access. See {@link readSmartFolderIds}.
43397
+ */
43398
+ smartFolderIds() {
43399
+ return readSmartFolderIds();
43400
+ }
43401
+ /**
43402
+ * Read-only preflight: throws the smart-folder refusal when `folderPath`
43403
+ * resolves in `account` only to a smart folder. For write paths that create
43404
+ * something before they reach their own destination check (the Markdown
43405
+ * bridge creates first and moves second). Other failures are left to the
43406
+ * write itself, which reports them as before.
43407
+ *
43408
+ * @param folderPath - Destination folder path (list-folders syntax)
43409
+ * @param account - Account to resolve in (defaults to Notes' default)
43410
+ */
43411
+ assertNotSmartFolderDestination(folderPath, account) {
43412
+ const excludeFolderIds = this.smartFolderIds();
43413
+ if (excludeFolderIds.length === 0) return;
43414
+ const script = buildAccountScopedScript(
43415
+ { account: this.resolveAccount(account) },
43416
+ `${buildLiveFolderResolution(folderPath, "__folder", { excludeFolderIds })}
43417
+ return id of __folder`
43418
+ );
43419
+ const result = executeAppleScript(script);
43420
+ if (!result.success) throwIfSmartFolderDestination(result.error, folderPath);
43421
+ }
43285
43422
  /**
43286
43423
  * Lists every smart folder with its decoded query, read-only.
43287
43424
  *
@@ -43823,37 +43960,40 @@ var AppleNotesManager = class {
43823
43960
  console.error(`Invalid folder name: "${name}"`);
43824
43961
  return null;
43825
43962
  }
43963
+ const smart = { rootOnly: true, excludeFolderIds: this.smartFolderIds() };
43826
43964
  for (let i = 0; i < parts.length; i++) {
43827
43965
  const currentPath = parts.slice(0, i + 1).map((p) => escapeFolderName(p)).join("/");
43828
43966
  const checkScript = buildAccountScopedScript(
43829
43967
  { account: targetAccount },
43830
- `${buildLiveFolderResolution(currentPath, "__folder", { rootOnly: true })}
43968
+ `${buildLiveFolderResolution(currentPath, "__folder", smart)}
43831
43969
  return id of __folder`
43832
43970
  );
43833
43971
  const checkResult = executeAppleScript(checkScript);
43834
43972
  if (checkResult.success) {
43835
43973
  continue;
43836
43974
  }
43975
+ throwIfSmartFolderDestination(checkResult.error, currentPath);
43837
43976
  const segmentName = escapePlainStringForAppleScript(parts[i]);
43838
43977
  let createCommand;
43839
43978
  if (i === 0) {
43840
43979
  createCommand = `make new folder with properties {name:"${segmentName}"}`;
43841
43980
  } else {
43842
43981
  const parentPath = parts.slice(0, i).map((p) => escapeFolderName(p)).join("/");
43843
- createCommand = `${buildLiveFolderResolution(parentPath, "__parent", { rootOnly: true })}
43982
+ createCommand = `${buildLiveFolderResolution(parentPath, "__parent", smart)}
43844
43983
  make new folder at __parent with properties {name:"${segmentName}"}`;
43845
43984
  }
43846
43985
  const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
43847
43986
  const result = executeMutationAppleScript(script);
43848
43987
  if (!result.success) {
43849
43988
  throwIfAccountResolutionFailed(result.error);
43989
+ throwIfSmartFolderDestination(result.error, name);
43850
43990
  console.error(`Failed to create folder "${name}":`, result.error);
43851
43991
  return null;
43852
43992
  }
43853
43993
  }
43854
43994
  const idScript = buildAccountScopedScript(
43855
43995
  { account: targetAccount },
43856
- `${buildLiveFolderResolution(name, "__folder", { rootOnly: true })}
43996
+ `${buildLiveFolderResolution(name, "__folder", smart)}
43857
43997
  return (id of __folder) & ${AS_FIELD_SEP} & (name of it)`
43858
43998
  );
43859
43999
  const idResult = executeAppleScript(idScript);
@@ -43912,7 +44052,9 @@ var AppleNotesManager = class {
43912
44052
  moveNoteById(id2, destinationFolder, account, scope2) {
43913
44053
  const targetAccount = this.resolveAccount(account);
43914
44054
  const safeId = sanitizeNoteId(id2);
43915
- const destFolderSetup = buildLiveFolderResolution(destinationFolder, "destFolder");
44055
+ const destFolderSetup = buildLiveFolderResolution(destinationFolder, "destFolder", {
44056
+ excludeFolderIds: this.smartFolderIds()
44057
+ });
43916
44058
  const moveCommand = `
43917
44059
  ${buildAccountResolution(targetAccount)}
43918
44060
  ${destFolderSetup}
@@ -43927,6 +44069,7 @@ var AppleNotesManager = class {
43927
44069
  const result = executeMutationAppleScript(script);
43928
44070
  if (!result.success) {
43929
44071
  throwIfAccountResolutionFailed(result.error);
44072
+ throwIfSmartFolderDestination(result.error, destinationFolder);
43930
44073
  console.error(
43931
44074
  `Cannot move note to "${destinationFolder}" (folder may not exist):`,
43932
44075
  result.error
@@ -44821,7 +44964,9 @@ var AppleNotesManager = class {
44821
44964
  batchMoveNotes(ids, folder, account) {
44822
44965
  if (ids.length === 0) return [];
44823
44966
  const targetAccount = this.resolveAccount(account);
44824
- const destFolderSetup = buildLiveFolderResolution(folder, "destFolder");
44967
+ const destFolderSetup = buildLiveFolderResolution(folder, "destFolder", {
44968
+ excludeFolderIds: this.smartFolderIds()
44969
+ });
44825
44970
  const results = new Array(ids.length);
44826
44971
  const runnable = [];
44827
44972
  ids.forEach((id2, i) => {
@@ -44877,6 +45022,7 @@ var AppleNotesManager = class {
44877
45022
  const res = executeMutationAppleScript(script);
44878
45023
  if (!res.success) {
44879
45024
  throwIfAccountResolutionFailed(res.error);
45025
+ throwIfSmartFolderDestination(res.error, folder);
44880
45026
  for (const r of runnable) {
44881
45027
  results[r.index] = this.createBatchResult(
44882
45028
  ids[r.index],
@@ -46787,13 +46933,6 @@ function describeSearchScope(searchContent, resultCount) {
46787
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.";
46788
46934
  }
46789
46935
 
46790
- // src/utils/noteQueryStore.ts
46791
- import { execFileSync as execFileSync14 } from "child_process";
46792
- import * as fs8 from "fs";
46793
- import * as os8 from "os";
46794
- import * as path8 from "path";
46795
- import { gunzipSync as gunzipSync8 } from "zlib";
46796
-
46797
46936
  // src/utils/noteQuery.ts
46798
46937
  var QUERY_LIMITS = {
46799
46938
  /** Maximum number of tokens (terms, operators, parentheses). */
@@ -47225,21 +47364,50 @@ function evaluateNoteQuery(node, note) {
47225
47364
  }
47226
47365
  }
47227
47366
  }
47228
- function positiveTextTerms(node, negated = false) {
47367
+ function positiveTextPredicates(node, negated = false) {
47229
47368
  switch (node.type) {
47230
47369
  case "and":
47231
47370
  case "or":
47232
- return node.children.flatMap((child) => positiveTextTerms(child, negated));
47371
+ return node.children.flatMap((child) => positiveTextPredicates(child, negated));
47233
47372
  case "not":
47234
- return positiveTextTerms(node.child, !negated);
47373
+ return positiveTextPredicates(node.child, !negated);
47235
47374
  case "text":
47236
- return negated ? [] : [node.value];
47375
+ return negated ? [] : [{ field: node.field, value: node.value }];
47237
47376
  default:
47238
47377
  return [];
47239
47378
  }
47240
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
+ }
47241
47404
 
47242
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";
47243
47411
  var NOTES_DB_PATH10 = path8.join(
47244
47412
  os8.homedir(),
47245
47413
  "Library/Group Containers/group.com.apple.notes/NoteStore.sqlite"
@@ -47321,6 +47489,15 @@ function decodeNoteBody(data) {
47321
47489
  objectIds
47322
47490
  };
47323
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
+ }
47324
47501
  function countWords(text2) {
47325
47502
  let count = 0;
47326
47503
  for (const word of text2.replace(/\ufffc/gu, " ").split(/\s+/u)) {
@@ -47391,6 +47568,67 @@ function presentColumns2(dbPath2) {
47391
47568
  }
47392
47569
  return cols;
47393
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
+ }
47394
47632
  var escapeSegment = escapeFolderName;
47395
47633
  function resolveFolders(rows) {
47396
47634
  const byPk = new Map(rows.map((row) => [row.pk, row]));
@@ -47447,23 +47685,10 @@ function runNoteQuery(ast, options = {}) {
47447
47685
  const withBodies = needsContent(ast);
47448
47686
  const withTags = needsTags(ast);
47449
47687
  const dbPath2 = options.dbPath ?? NOTES_DB_PATH10;
47450
- if (!fs8.existsSync(dbPath2)) throw new NoteQueryStoreError(QUERY_FDA_MESSAGE, "no_fda");
47451
- let output;
47452
- try {
47453
- const available = presentColumns2(dbPath2);
47454
- output = runSqlite6(
47455
- dbPath2,
47456
- buildScanSql(available, { scanLimit, includeDeleted, withBodies, withTags })
47457
- );
47458
- } catch (error2) {
47459
- if (error2 instanceof NoteQueryStoreError) throw error2;
47460
- const message = error2 instanceof Error ? error2.message : String(error2);
47461
- if (message.includes("authorization denied") || message.includes("unable to open database")) {
47462
- throw new NoteQueryStoreError(QUERY_FDA_MESSAGE, "no_fda");
47463
- }
47464
- console.error(`query-notes: database read failed: ${message}`);
47465
- throw new NoteQueryStoreError("Failed to read the Notes database.", "query_error");
47466
- }
47688
+ const output = readStore(
47689
+ dbPath2,
47690
+ (available) => buildScanSql(available, { scanLimit, includeDeleted, withBodies, withTags })
47691
+ );
47467
47692
  let uuid2;
47468
47693
  let eligible = 0;
47469
47694
  const folderRows = [];
@@ -47487,6 +47712,8 @@ function runNoteQuery(ast, options = {}) {
47487
47712
  }
47488
47713
  const folders = resolveFolders(folderRows);
47489
47714
  const textTerms = positiveTextTerms(ast);
47715
+ const textPredicates = positiveTextPredicates(ast);
47716
+ const pendingWordCounts = [];
47490
47717
  const hits = [];
47491
47718
  let matched = 0;
47492
47719
  let unreadable = 0;
@@ -47504,15 +47731,7 @@ function runNoteQuery(ast, options = {}) {
47504
47731
  if (withBodies) unreadable++;
47505
47732
  return decoded;
47506
47733
  }
47507
- try {
47508
- decoded = decodeNoteBody(
47509
- new Uint8Array(
47510
- gunzipSync8(Buffer.from(row.data, "hex"), { maxOutputLength: 32 * 1024 * 1024 })
47511
- )
47512
- );
47513
- } catch {
47514
- decoded = null;
47515
- }
47734
+ decoded = decodeBodyHex(row.data);
47516
47735
  if (!decoded) unreadable++;
47517
47736
  return decoded;
47518
47737
  };
@@ -47553,6 +47772,8 @@ function runNoteQuery(ast, options = {}) {
47553
47772
  matched++;
47554
47773
  if (hits.length >= limit) continue;
47555
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);
47556
47777
  hits.push({
47557
47778
  id: `x-coredata://${uuid2}/ICNote/p${row.pk}`,
47558
47779
  title: row.title ?? "",
@@ -47561,7 +47782,16 @@ function runNoteQuery(ast, options = {}) {
47561
47782
  ...note.modified !== void 0 ? { modified: new Date(note.modified).toISOString() } : {},
47562
47783
  ...note.created !== void 0 ? { created: new Date(note.created).toISOString() } : {},
47563
47784
  snippet: locked ? "" : body ? buildSnippet(body.text, textTerms) : (row.snippet ?? "").replace(/\s+/gu, " ").trim(),
47564
- ...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;
47565
47795
  });
47566
47796
  }
47567
47797
  return {
@@ -47604,6 +47834,7 @@ function searchContentViaDatabase(options) {
47604
47834
  const result = runNoteQuery(buildSearchContentQuery(options), {
47605
47835
  limit: options.limit,
47606
47836
  scanLimit: QUERY_SCAN.MAX,
47837
+ includeWordCount: options.includeWordCount,
47607
47838
  dbPath: options.dbPath
47608
47839
  });
47609
47840
  const notes = result.notes.map((hit) => ({
@@ -47614,7 +47845,9 @@ function searchContentViaDatabase(options) {
47614
47845
  created: hit.created ? new Date(hit.created) : /* @__PURE__ */ new Date(0),
47615
47846
  modified: hit.modified ? new Date(hit.modified) : /* @__PURE__ */ new Date(0),
47616
47847
  ...hit.folder !== void 0 ? { folder: hit.folder } : {},
47617
- ...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 } : {}
47618
47851
  }));
47619
47852
  return {
47620
47853
  notes,
@@ -47637,6 +47870,55 @@ function contentSearchFailureHint(message, dbUnavailable) {
47637
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." : "";
47638
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.`;
47639
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
+ }
47640
47922
 
47641
47923
  // src/tools/doctor.ts
47642
47924
  import { spawnSync } from "child_process";
@@ -48358,12 +48640,15 @@ function createMarkdownNote(manager, request, run = runBackgroundShortcut) {
48358
48640
  const segments = (path10) => JSON.stringify(splitFolderPath(path10).map((part) => part.toLocaleLowerCase()));
48359
48641
  if (request.folder) {
48360
48642
  const wanted = segments(request.folder);
48361
- if (!manager.listAccounts().some(
48362
- (account) => account.defaultFolder && manager.listFolders(account.name).some((folder) => segments(folder.name) === wanted)
48643
+ const accounts = manager.listAccounts().filter((account) => account.defaultFolder);
48644
+ if (!accounts.some(
48645
+ (account) => manager.listFolders(account.name).some((folder) => segments(folder.name) === wanted)
48363
48646
  ))
48364
48647
  throw new Error(
48365
48648
  `Folder "${request.folder}" does not exist; create it with create-folder first. Nothing was created`
48366
48649
  );
48650
+ for (const account of accounts)
48651
+ manager.assertNotSmartFolderDestination(request.folder, account.name);
48367
48652
  }
48368
48653
  const defaultFolderNotes = () => new Map(
48369
48654
  manager.listAccounts().flatMap(
@@ -49031,77 +49316,6 @@ function withJsonSchema2020_12(transport2) {
49031
49316
  return transport2;
49032
49317
  }
49033
49318
 
49034
- // src/utils/errorCodes.ts
49035
- var CodedError = class extends Error {
49036
- envelope;
49037
- constructor(message, envelope) {
49038
- super(message);
49039
- this.name = "CodedError";
49040
- this.envelope = envelope;
49041
- }
49042
- };
49043
- var RULES = [
49044
- { code: "timeout_indeterminate", pattern: /timed out|\bETIMEDOUT\b/i },
49045
- {
49046
- code: "verification_failed",
49047
- pattern: /\buncertain\b|may have (?:been|succeeded)|\baccepted (?:the|an) \w+, but|\bnot verified\b|Do not retry automatically|before any retry|read (?:the )?(?:exact )?(?:note|ID)\b[^.]*before retr/i
49048
- },
49049
- {
49050
- code: "revision_conflict",
49051
- pattern: /changed after it was read|revision changed|(?:pinned|note) state changed|changed during (?:read|preflight)|changed; read (?:it|the note) again|rich text do not match/i
49052
- },
49053
- { code: "full_disk_access_missing", pattern: /Full Disk Access/i },
49054
- {
49055
- code: "shortcut_not_installed",
49056
- pattern: /(?:Install|Import) the supplied|no such shortcut|Run apple-notes-mcp setup/i
49057
- },
49058
- {
49059
- code: "not_found",
49060
- pattern: /not found|does not exist|\bNo \w+ found\b|does not contain any checklist items|Can[’']t get (?:note|folder|account|attachment)|Cannot find note|Scope is absent/i
49061
- },
49062
- { code: "ambiguous", pattern: /ambiguous|more than one|exactly once|Duplicate note IDs/i },
49063
- {
49064
- code: "notes_unavailable",
49065
- pattern: /Notes\.app is (?:not responding|busy)|Lost connection to Notes|isn't running/i
49066
- },
49067
- {
49068
- code: "unsupported",
49069
- pattern: /password-protected|Locked notes|unsupported|not supported|is blocked|blocked because|has not passed live|refuses to sign|No (?:supported )?background|only one where|Real Notes link unavailable/i
49070
- },
49071
- {
49072
- code: "validation_error",
49073
- pattern: /\bis required\b|required\b|\bInvalid\b|\bmust\b|\bProvide\b|No (?:note IDs|reviewed notes) provided|response limit|Refusing to write|cannot be resolved|at most \d|between \d+ and \d+|supports the (?:end|default)|Use a distinctive|Use create-table|too large|equal cell counts|Folder path is empty/i
49074
- }
49075
- ];
49076
- var NOTHING_WRITTEN = /nothing (?:was )?(?:changed|created|written)|no replacement started|No content was (?:replaced|appended)|Nothing was created/i;
49077
- var WRITE_ACCEPTED = /\baccepted (?:the|an) \w+, but/i;
49078
- function classifyError(message, cause) {
49079
- if (cause instanceof CodedError) return { ...cause.envelope };
49080
- const text2 = cause instanceof Error && cause.message && !message.includes(cause.message) ? `${message}
49081
- ${cause.message}` : message;
49082
- const timeoutCause = typeof cause === "object" && cause !== null && cause.code === "ETIMEDOUT";
49083
- let code = "operation_failed";
49084
- if (timeoutCause) code = "timeout_indeterminate";
49085
- else if (isPermissionDenied(text2)) code = "permission_denied";
49086
- else code = RULES.find((rule) => rule.pattern.test(text2))?.code ?? "operation_failed";
49087
- const envelope = { code };
49088
- if (code === "timeout_indeterminate" || code === "verification_failed") {
49089
- envelope.indeterminate = true;
49090
- if (WRITE_ACCEPTED.test(text2)) envelope.committed = true;
49091
- } else if (code === "revision_conflict" || NOTHING_WRITTEN.test(text2)) {
49092
- envelope.committed = false;
49093
- envelope.indeterminate = false;
49094
- }
49095
- return envelope;
49096
- }
49097
- function errorResult(message, cause) {
49098
- return {
49099
- content: [{ type: "text", text: message }],
49100
- structuredContent: classifyError(message, cause),
49101
- isError: true
49102
- };
49103
- }
49104
-
49105
49319
  // src/utils/shutdown.ts
49106
49320
  var SHUTDOWN_DRAIN_TIMEOUT_MS = 2e3;
49107
49321
  function createShutdown(stream, exit, timeoutMs = SHUTDOWN_DRAIN_TIMEOUT_MS) {
@@ -49255,7 +49469,7 @@ function selectParagraph(paragraphs, selector) {
49255
49469
  );
49256
49470
  return matches[occurrence - 1];
49257
49471
  }
49258
- 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;
49259
49473
  function titleMatchSql(columns) {
49260
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;`;
49261
49475
  }
@@ -49266,7 +49480,7 @@ function checkNoteSelector(selector) {
49266
49480
  if (selector.folder !== void 0 && selector.title === void 0)
49267
49481
  throw new ParagraphLinkError("invalid-argument", "folder only narrows a title lookup");
49268
49482
  if (selector.id === void 0) return void 0;
49269
- const pk = NOTE_ID.exec(selector.id)?.[1];
49483
+ const pk = NOTE_ID2.exec(selector.id)?.[1];
49270
49484
  if (!pk)
49271
49485
  throw new ParagraphLinkError(
49272
49486
  "invalid-argument",
@@ -51577,7 +51791,9 @@ function registerDirectOperations(server2, manager) {
51577
51791
  {
51578
51792
  title: external_exports.string().min(1).max(2e3).refine((s) => !/[\r\n\0]/u.test(s), "One-line title"),
51579
51793
  content: external_exports.string().min(1).max(1024 * 1024).optional().describe("Optional plain-text body placed above the attachment"),
51580
- folder: external_exports.string().max(1e3).optional().describe("Existing folder or nested path; create it first with create-folder"),
51794
+ folder: external_exports.string().max(1e3).optional().describe(
51795
+ "Existing folder or nested path; create it first with create-folder. A smart folder is refused before the note is created"
51796
+ ),
51581
51797
  account: external_exports.string().max(200).optional().describe("Account name; defaults to Notes' default"),
51582
51798
  ...attachmentInput
51583
51799
  },
@@ -56607,7 +56823,7 @@ function registerTool(name, config2, cb) {
56607
56823
  registerTool(
56608
56824
  "create-note",
56609
56825
  {
56610
- description: "Use when: the user wants to create a brand-new Apple Note.\nReturns: the new note's title and id \u2014 reuse the id for follow-up reads/edits.\nDo not use when: editing an existing note (use update-note).\nNote: the title is prepended as an <h1>; true Apple Notes checklists cannot be created via AppleScript (see the content field). A 'folder' must already exist \u2014 create-folder first (it is idempotent), since this tool does not create it.",
56826
+ description: "Use when: the user wants to create a brand-new Apple Note.\nReturns: the new note's title and id \u2014 reuse the id for follow-up reads/edits.\nDo not use when: editing an existing note (use update-note).\nNote: the title is prepended as an <h1>; true Apple Notes checklists cannot be created via AppleScript (see the content field). A 'folder' must already exist \u2014 create-folder first (it is idempotent), since this tool does not create it. A smart folder is refused (code unsupported); it cannot hold notes.",
56611
56827
  inputSchema: {
56612
56828
  title: external_exports.string().min(1, "Title is required").max(MAX.TITLE),
56613
56829
  content: external_exports.string().min(1, "Content is required").max(MAX.CONTENT).optional().describe(
@@ -56747,7 +56963,7 @@ registerTool(
56747
56963
  registerTool(
56748
56964
  "search-notes",
56749
56965
  {
56750
- 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.",
56751
56967
  inputSchema: {
56752
56968
  query: external_exports.string().min(1, "Search query is required").max(MAX.QUERY),
56753
56969
  searchContent: external_exports.boolean().optional().describe(
@@ -56760,112 +56976,132 @@ registerTool(
56760
56976
  ),
56761
56977
  limit: external_exports.number().int().positive().optional().describe(
56762
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"
56763
56982
  )
56764
56983
  },
56765
56984
  outputSchema: {
56766
56985
  notes: external_exports.array(external_exports.object({}).passthrough()).optional(),
56767
56986
  count: external_exports.number().optional(),
56768
56987
  source: external_exports.enum(["database", "applescript"]).optional(),
56769
- scanTruncated: external_exports.boolean().optional()
56988
+ scanTruncated: external_exports.boolean().optional(),
56989
+ wordCountUnavailable: external_exports.string().optional()
56770
56990
  }
56771
56991
  },
56772
- withErrorHandling(({ query: query2, searchContent = false, account, folder, modifiedSince, limit }) => {
56773
- const effectiveLimit = resolveSearchLimit(limit);
56774
- const limitWasDefault = limit === void 0;
56775
- let source;
56776
- let dbScan;
56777
- let dbUnavailable;
56778
- const runSearch = () => {
56779
- 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
+ }
56780
57019
  try {
56781
- const db = searchContentViaDatabase({
56782
- query: query2,
56783
- account: notesManager.searchAccountScope(account),
57020
+ return notesManager.searchNotes(
57021
+ query2,
57022
+ searchContent,
57023
+ account,
56784
57024
  folder,
56785
57025
  modifiedSince,
56786
- limit: effectiveLimit
56787
- });
56788
- source = "database";
56789
- dbScan = db.scan;
56790
- return db.notes;
57026
+ effectiveLimit
57027
+ );
56791
57028
  } catch (error2) {
56792
- if (!(error2 instanceof NoteQueryStoreError)) throw error2;
56793
- dbUnavailable = error2.kind;
57029
+ if (!searchContent || !(error2 instanceof Error)) throw error2;
57030
+ throw new Error(contentSearchFailureHint(error2.message, dbUnavailable));
56794
57031
  }
56795
- 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.`);
56796
57063
  }
56797
- try {
56798
- return notesManager.searchNotes(
56799
- query2,
56800
- searchContent,
56801
- account,
56802
- folder,
56803
- modifiedSince,
56804
- effectiveLimit
56805
- );
56806
- } catch (error2) {
56807
- if (!searchContent || !(error2 instanceof Error)) throw error2;
56808
- throw new Error(contentSearchFailureHint(error2.message, dbUnavailable));
57064
+ if (syncInterference) {
57065
+ syncWarnings.push(`\u26A0\uFE0F Sync activity detected - results may be incomplete.`);
56809
57066
  }
56810
- };
56811
- const {
56812
- result: notes,
56813
- syncBefore,
56814
- syncInterference
56815
- } = withSyncAwarenessSync("search-notes", runSearch);
56816
- const searchType = searchContent ? source === "database" ? "content via the Notes database" : "content" : "titles";
56817
- const sourceFields = source ? { source } : {};
56818
- const scanFields = dbScan ? { scanTruncated: dbScan.scanTruncated } : {};
56819
- const scanNote = describeContentScan(dbScan);
56820
- const folderInfo = folder ? ` in folder "${folder}"` : "";
56821
- const dateInfo = modifiedSince ? ` modified since ${modifiedSince}` : "";
56822
- const { info: limitInfo, truncationNote } = describeSearchLimit(
56823
- effectiveLimit,
56824
- limitWasDefault,
56825
- notes.length
56826
- );
56827
- const syncWarnings = [];
56828
- if (syncBefore.syncDetected) {
56829
- syncWarnings.push(`\u26A0\uFE0F iCloud sync was active during search.`);
56830
- }
56831
- if (syncInterference) {
56832
- syncWarnings.push(`\u26A0\uFE0F Sync activity detected - results may be incomplete.`);
56833
- }
56834
- const syncNote = syncWarnings.length > 0 ? `
57067
+ const syncNote = syncWarnings.length > 0 ? `
56835
57068
 
56836
57069
  ${syncWarnings.join(" ")}` : "";
56837
- if (notes.length === 0) {
56838
- 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");
56839
57086
  return successResponse(
56840
- `No notes found matching "${query2}" in ${searchType}${folderInfo}${dateInfo}${scopeHint}${scanNote}${syncNote}`,
56841
- { 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
+ }
56842
57096
  );
56843
- }
56844
- const noteList = notes.map((n) => {
56845
- const idSuffix = n.id ? ` [id: ${n.id}]` : "";
56846
- if (n.folder === "Recently Deleted") {
56847
- return ` - ${n.title} [DELETED]${idSuffix}`;
56848
- } else if (n.folder) {
56849
- return ` - ${n.title} (${n.folder})${idSuffix}`;
56850
- }
56851
- return ` - ${n.title}${idSuffix}`;
56852
- }).join("\n");
56853
- return successResponse(
56854
- `Found ${notes.length} notes (searched ${searchType}${folderInfo}${dateInfo}${limitInfo}):
56855
- ${noteList}${truncationNote}${scanNote}${syncNote}`,
56856
- {
56857
- notes: withStableIdentifiers(notes, "ICNote"),
56858
- count: notes.length,
56859
- ...sourceFields,
56860
- ...scanFields
56861
- }
56862
- );
56863
- }, "Error searching notes")
57097
+ },
57098
+ "Error searching notes"
57099
+ )
56864
57100
  );
56865
57101
  registerTool(
56866
57102
  "query-notes",
56867
57103
  {
56868
- 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.',
56869
57105
  inputSchema: {
56870
57106
  query: external_exports.string().min(1, "A query expression is required").max(MAX.QUERY).describe(
56871
57107
  'Boolean query expression, e.g. `folder:"Work Projects" has:checklist -checklist:done`'
@@ -56878,6 +57114,9 @@ registerTool(
56878
57114
  ),
56879
57115
  includeDeleted: external_exports.boolean().optional().describe(
56880
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)"
56881
57120
  )
56882
57121
  },
56883
57122
  outputSchema: {
@@ -56894,10 +57133,10 @@ registerTool(
56894
57133
  },
56895
57134
  annotations: { readOnlyHint: true }
56896
57135
  },
56897
- withErrorHandling(({ query: query2, limit, scanLimit, includeDeleted }) => {
57136
+ withErrorHandling(({ query: query2, limit, scanLimit, includeDeleted, includeWordCount }) => {
56898
57137
  let result;
56899
57138
  try {
56900
- result = queryNotes(query2, { limit, scanLimit, includeDeleted });
57139
+ result = queryNotes(query2, { limit, scanLimit, includeDeleted, includeWordCount });
56901
57140
  } catch (error2) {
56902
57141
  if (error2 instanceof NoteQueryError || error2 instanceof NoteQueryStoreError) {
56903
57142
  return errorResponse(
@@ -56928,7 +57167,7 @@ ${notes.join("\n")}` : "";
56928
57167
  const where = [n.account, n.folder].filter(Boolean).join(" / ");
56929
57168
  const snippet = n.snippet ? `
56930
57169
  ${n.snippet}` : "";
56931
- 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}`;
56932
57171
  }).join("\n");
56933
57172
  return successResponse(
56934
57173
  `Found ${result.matched} matching notes (${scope2}):
@@ -58099,7 +58338,7 @@ registerTool(
58099
58338
  registerTool(
58100
58339
  "move-note",
58101
58340
  {
58102
- description: "Use when: moving one exact note to a different folder by id.\nReturns: confirmation and exact-ID readback.\nDo not use when: you only have a title or want to move many notes (batch-move-notes).\nNote: Notes.app's native move preserves the note id, creation date, body, and attachments. The destination folder must already exist. Optional ifFolderId, ifAncestorFolderId, and forbiddenAncestorFolderIds (which also covers the destination) are re-checked inside the move AppleScript.",
58341
+ description: "Use when: moving one exact note to a different folder by id.\nReturns: confirmation and exact-ID readback.\nDo not use when: you only have a title or want to move many notes (batch-move-notes).\nNote: Notes.app's native move preserves the note id, creation date, body, and attachments. The destination folder must already exist and cannot be a smart folder (refused with code unsupported before the move). Optional ifFolderId, ifAncestorFolderId, and forbiddenAncestorFolderIds (which also covers the destination) are re-checked inside the move AppleScript.",
58103
58342
  inputSchema: {
58104
58343
  id: noteIdInput,
58105
58344
  folder: external_exports.string().min(1, "Destination folder is required").max(MAX.FOLDER),
@@ -58341,7 +58580,7 @@ ${lines.join("\n")}${fullyDecoded ? "" : '\n\nSome rules were not recognized; se
58341
58580
  registerTool(
58342
58581
  "create-folder",
58343
58582
  {
58344
- description: "Use when: creating a folder, including nested paths like 'Work/Clients' (intermediate folders are created, existing ones skipped).\nReturns: confirmation.\nDo not use when: creating a note (create-note).",
58583
+ description: "Use when: creating a folder, including nested paths like 'Work/Clients' (intermediate folders are created, existing ones skipped).\nReturns: confirmation.\nNote: a path segment that names a smart folder is refused (code unsupported) before anything is created; smart folders cannot hold folders.\nDo not use when: creating a note (create-note).",
58345
58584
  inputSchema: {
58346
58585
  name: external_exports.string().min(1, "Folder name is required").max(MAX.FOLDER).describe(
58347
58586
  'Folder name or nested path separated by "/". E.g., "Retro Tech/PC/CPUs" creates all intermediate folders. Existing segments are skipped.'
@@ -58782,7 +59021,7 @@ registerTool(
58782
59021
  registerTool(
58783
59022
  "batch-move-notes",
58784
59023
  {
58785
- description: "Use when: moving multiple notes by id into one destination folder.\nReturns: per-id success/failure counts after destination-folder verification.\nDo not use when: moving a single note (move-note).\nSafety: each moved note's actual container ID is compared with the destination folder ID before success is reported. The destination folder must already exist (create-folder).",
59024
+ description: "Use when: moving multiple notes by id into one destination folder.\nReturns: per-id success/failure counts after destination-folder verification.\nDo not use when: moving a single note (move-note).\nSafety: each moved note's actual container ID is compared with the destination folder ID before success is reported. The destination folder must already exist (create-folder); a smart folder is refused for the whole call (code unsupported) before any note moves.",
58786
59025
  inputSchema: {
58787
59026
  ids: noteIdArrayInput.describe(
58788
59027
  `Array of note IDs to move (max ${MAX.BATCH_IDS} per request)`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.9.12",
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",