apple-notes-mcp 2.5.9 → 2.5.11

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.
package/README.md CHANGED
@@ -12,7 +12,7 @@ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that e
12
12
  [![MCP](https://img.shields.io/badge/MCP-server-blue)](https://modelcontextprotocol.io)
13
13
 
14
14
  <p align="center">
15
- <img src="codex/assets/screenshot.png" alt="Apple Notes MCP — create, search, and organize Apple Notes from Codex, Claude, and other AI assistants" width="680">
15
+ <img src="https://raw.githubusercontent.com/sweetrb/apple-notes-mcp/main/codex/assets/screenshot.png" alt="Apple Notes MCP — create, search, and organize Apple Notes from Codex, Claude, and other AI assistants" width="680">
16
16
  </p>
17
17
 
18
18
  ## What is This?
@@ -39,6 +39,12 @@ Install the sweetrb/apple-notes-mcp MCP server so you can help me manage my Appl
39
39
 
40
40
  Claude will handle the installation and configuration automatically.
41
41
 
42
+ Or register it yourself with one deterministic command:
43
+
44
+ ```bash
45
+ claude mcp add apple-notes -s user -- npx -y apple-notes-mcp
46
+ ```
47
+
42
48
  ### Using the Plugin Marketplace
43
49
 
44
50
  Install as a Claude Code plugin for automatic configuration and enhanced AI behavior:
@@ -50,6 +56,8 @@ Install as a Claude Code plugin for automatic configuration and enhanced AI beha
50
56
 
51
57
  This method also installs a **skill** that teaches Claude when and how to use Apple Notes effectively.
52
58
 
59
+ On the first tool call, macOS shows an Automation permission prompt ("Claude" wants access to control "Notes") — click **OK**. Optionally, grant **Full Disk Access** to the app that launches the server to enable the checklist-state and note-metadata features; see the [Full Disk Access Setup Guide](https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/FULL-DISK-ACCESS.md). Everything else works without it.
60
+
53
61
  ### Using the Codex Marketplace
54
62
 
55
63
  The same plugin is available for Codex. Add the marketplace and install the plugin:
@@ -65,14 +73,14 @@ The Codex plugin runs the published `apple-notes-mcp` server through `npx` and s
65
73
 
66
74
  Configuration for two more hosts is included — each registers the same `apple-notes` MCP server (`npx -y apple-notes-mcp`):
67
75
 
68
- - **[Hermes Agent](https://hermes-agent.nousresearch.com/)** (NousResearch) — Hermes has no plugin/marketplace drop-in. Add the server with `hermes mcp add apple-notes --command npx --args -y apple-notes-mcp`, or merge [`.hermes-plugin/config.yaml`](.hermes-plugin/config.yaml) into `~/.hermes/config.yaml`. Details: [`.hermes-plugin/README.md`](.hermes-plugin/README.md).
69
- - **[Antigravity](https://antigravity.google/)** (Google) — add the server entry from [`.antigravity-plugin/mcp_config.json`](.antigravity-plugin/mcp_config.json) to `~/.gemini/config/mcp_config.json` (or via Antigravity's MCP settings).
76
+ - **[Hermes Agent](https://hermes-agent.nousresearch.com/)** (NousResearch) — Hermes has no plugin/marketplace drop-in. Add the server with `hermes mcp add apple-notes --command npx --args -y apple-notes-mcp`, or merge [`.hermes-plugin/config.yaml`](https://github.com/sweetrb/apple-notes-mcp/blob/main/.hermes-plugin/config.yaml) into `~/.hermes/config.yaml`. Details: [`.hermes-plugin/README.md`](https://github.com/sweetrb/apple-notes-mcp/blob/main/.hermes-plugin/README.md).
77
+ - **[Antigravity](https://antigravity.google/)** (Google) — add the server entry from [`.antigravity-plugin/mcp_config.json`](https://github.com/sweetrb/apple-notes-mcp/blob/main/.antigravity-plugin/mcp_config.json) to `~/.gemini/config/mcp_config.json` (or via Antigravity's MCP settings).
70
78
 
71
- ### Manual Installation
79
+ ### Using Claude Desktop
72
80
 
73
81
  **1. Install the server:**
74
82
  ```bash
75
- npm install -g github:sweetrb/apple-notes-mcp
83
+ npm install -g apple-notes-mcp
76
84
  ```
77
85
 
78
86
  **2. Add to Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
@@ -81,7 +89,7 @@ npm install -g github:sweetrb/apple-notes-mcp
81
89
  "mcpServers": {
82
90
  "apple-notes": {
83
91
  "command": "npx",
84
- "args": ["apple-notes-mcp"]
92
+ "args": ["-y", "apple-notes-mcp"]
85
93
  }
86
94
  }
87
95
  }
@@ -133,7 +141,7 @@ common workflows: `find-note`, `weekly-review`, `new-meeting-note`.
133
141
  ### Known limitations
134
142
 
135
143
  A few Notes UI features are not exposed to AppleScript and therefore cannot be
136
- supported. See **[docs/APPLESCRIPT-LIMITATIONS.md](docs/APPLESCRIPT-LIMITATIONS.md)**
144
+ supported. See **[docs/APPLESCRIPT-LIMITATIONS.md](https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/APPLESCRIPT-LIMITATIONS.md)**
137
145
  for the investigation and verification behind each:
138
146
 
139
147
  - **Pinned notes** — Notes has no scriptable `pinned` property via AppleScript. Pin state can now be **read** with the BETA `get-note-metadata` tool (from the NoteStore database), but it still cannot be **set** programmatically.
@@ -155,17 +163,16 @@ Creates a new note in Apple Notes.
155
163
  |-----------|------|----------|-------------|
156
164
  | `title` | string | Yes | The title of the note. Automatically prepended as `<h1>` — do NOT include the title in `content` |
157
165
  | `content` | string | Yes | The body content of the note (do not repeat the title here) |
158
- | `tags` | string[] | No | Tags for organization (stored in metadata) |
166
+ | `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) |
159
167
  | `folder` | string | No | Folder to create the note in. Supports nested paths like `"Work/Clients"`. Defaults to account root |
160
168
  | `account` | string | No | Account name (defaults to iCloud) |
161
169
  | `format` | string | No | Content format: `"plaintext"` (default) or `"html"`. In both formats, the title is automatically prepended as `<h1>`. In plaintext mode, newlines become `<br>`, tabs become `<br>`, and backslashes are preserved as HTML entities |
162
170
 
163
- **Example:**
171
+ **Example (tagged with inline hashtags):**
164
172
  ```json
165
173
  {
166
174
  "title": "Meeting Notes",
167
- "content": "Discussed Q4 roadmap and budget allocation",
168
- "tags": ["work", "meetings"]
175
+ "content": "Discussed Q4 roadmap and budget allocation\n\n#work #meetings"
169
176
  }
170
177
  ```
171
178
 
@@ -264,7 +271,7 @@ Retrieves the full content of a specific note.
264
271
  **Returns:** The HTML content of the note, or error if not found. The
265
272
  `structuredContent` also includes `hashtags` — any inline `#hashtag` tags parsed
266
273
  from the body. Apple Notes tags are inline hashtags, not a scriptable property;
267
- see [docs/APPLESCRIPT-LIMITATIONS.md](../docs/APPLESCRIPT-LIMITATIONS.md#tags--hashtags-29). Smart Folders are not scriptable.
274
+ see [docs/APPLESCRIPT-LIMITATIONS.md](https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/APPLESCRIPT-LIMITATIONS.md#tags--hashtags-29). Smart Folders are not scriptable.
268
275
 
269
276
  ---
270
277
 
@@ -393,7 +400,7 @@ Updates an existing note's content and/or title.
393
400
 
394
401
  **Note:** `newContent` **replaces the entire note body** — it is not appended. To preserve existing content, read it first (e.g. with `get-note-content`) and include it in `newContent`.
395
402
 
396
- **Attachments:** A full-body replace can drop embedded files, images, scans, PDFs, or audio. When a note may hold attachments, run [`list-attachments`](#list-attachments) first, and either save them with `save-attachment` or build a new note rather than overwriting. See the skill's [Attachment-Safe Updates](skills/apple-notes/SKILL.md#attachment-safe-updates) guidance.
403
+ **Attachments:** A full-body replace can drop embedded files, images, scans, PDFs, or audio. When a note may hold attachments, run [`list-attachments`](#list-attachments) first, and either save them with `save-attachment` or build a new note rather than overwriting. See the skill's [Attachment-Safe Updates](https://github.com/sweetrb/apple-notes-mcp/blob/main/skills/apple-notes/SKILL.md#attachment-safe-updates) guidance.
397
404
 
398
405
  ---
399
406
 
@@ -460,8 +467,6 @@ Moves a note to a different folder. The note is relocated in place via Notes.app
460
467
 
461
468
  **Returns:** Confirmation message, or error if note or folder not found.
462
469
 
463
- **Note:** This operation copies the note to the new folder then deletes the original. If the delete fails, the note will exist in both locations.
464
-
465
470
  ---
466
471
 
467
472
  #### `list-notes`
@@ -796,7 +801,7 @@ Run a full setup diagnostic: Notes.app reachability, the Automation permission,
796
801
 
797
802
  **Parameters:** None
798
803
 
799
- **Returns:** A per-check report (`structuredContent` carries the raw `{healthy, checks[]}`). The Full Disk Access check tells you whether checklist-state features will work — see [Full Disk Access Setup](docs/FULL-DISK-ACCESS.md).
804
+ **Returns:** A per-check report (`structuredContent` carries the raw `{healthy, checks[]}`). The Full Disk Access check tells you whether checklist-state features will work — see [Full Disk Access Setup](https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/FULL-DISK-ACCESS.md).
800
805
 
801
806
  ---
802
807
 
@@ -891,7 +896,7 @@ AI: [calls create-note with title="Acme Corp", content="...", folder="Work/Clien
891
896
  ### npm (Recommended)
892
897
 
893
898
  ```bash
894
- npm install -g github:sweetrb/apple-notes-mcp
899
+ npm install -g apple-notes-mcp
895
900
  ```
896
901
 
897
902
  ### From Source
@@ -901,7 +906,9 @@ git clone https://github.com/sweetrb/apple-notes-mcp.git
901
906
  cd apple-notes-mcp
902
907
  ```
903
908
 
904
- The repo ships a prebuilt, dependency-free `build/index.js`, so a bare clone runs with nothing but Node installed. `npm install` and `npm run build` are only needed when you change the source.
909
+ The repo ships a prebuilt, dependency-free `build/index.js`, so a bare clone runs with nothing but Node installed. `pnpm install` and `pnpm run build` are only needed when you change the source (development uses [pnpm](https://pnpm.io/), not npm).
910
+
911
+ You can also install straight from the git repo with `npm install -g github:sweetrb/apple-notes-mcp` (building from source requires pnpm), but the published npm package above is the recommended path.
905
912
 
906
913
  If installed from source, use this configuration:
907
914
  ```json
@@ -976,7 +983,7 @@ MCP stores no secrets, but as a general rule keep only non-secret config here.
976
983
 
977
984
  The `get-checklist-state` tool and checklist annotations in `get-note-markdown` read directly from the Apple Notes SQLite database. This requires **Full Disk Access** for the process running the MCP server.
978
985
 
979
- > 📘 **For the full why-and-how walkthrough (which app to grant, verifying with `doctor`, graceful degradation), see the [Full Disk Access Setup Guide](docs/FULL-DISK-ACCESS.md).** The summary below is the quick version.
986
+ > 📘 **For the full why-and-how walkthrough (which app to grant, verifying with `doctor`, graceful degradation), see the [Full Disk Access Setup Guide](https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/FULL-DISK-ACCESS.md).** The summary below is the quick version.
980
987
 
981
988
  ### How to Grant Full Disk Access
982
989
 
@@ -1016,7 +1023,7 @@ All other tools work normally without Full Disk Access. Only checklist state fea
1016
1023
  | No pinned notes | Pin status is not exposed via AppleScript ([#28](https://github.com/sweetrb/apple-notes-mcp/issues/28)) |
1017
1024
  | Limited rich formatting | Use `format: "html"` on create/update for headings, lists, bold, code blocks; some complex formatting may not render |
1018
1025
  | Title matching | Most operations require exact title matches |
1019
- | Checklist state | Requires [Full Disk Access](docs/FULL-DISK-ACCESS.md) to read done/undone state from the database |
1026
+ | Checklist state | Requires [Full Disk Access](https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/FULL-DISK-ACCESS.md) to read done/undone state from the database |
1020
1027
  | Checklist **creation** | Not supported. AppleScript's `body of note` setter strips `<input type="checkbox">` and ignores any checklist-styling CSS class. Apple Notes stores checklist items as a protobuf paragraph style (`style_type=103`) that AppleScript doesn't expose, and the SQLite database is read-only. See [Creating Checklists](#creating-checklists) below for the workaround. |
1021
1028
 
1022
1029
  ### Roadmap
@@ -1084,7 +1091,7 @@ The `\\\\` in JSON becomes `\\` in the actual string, which represents a single
1084
1091
 
1085
1092
  ### "Permission denied"
1086
1093
  - macOS needs automation permission
1087
- - Go to System Preferences > Privacy & Security > Automation
1094
+ - Go to System Settings > Privacy & Security > Automation
1088
1095
  - Ensure your terminal/Claude has permission to control Notes
1089
1096
 
1090
1097
  ### "Note not found"
@@ -1097,9 +1104,14 @@ The `\\\\` in JSON becomes `\\` in the actual string, which represents a single
1097
1104
  - Use `\\` to represent each literal backslash
1098
1105
  - See "Backslash Escaping" section under Known Limitations
1099
1106
 
1107
+ ### Notes accumulate blank lines after repeated updates
1108
+ - Repeatedly updating a note (especially with HTML content) can accumulate whitespace artifacts — `<div><br></div>` tags that persist between sections even after you remove them from your content
1109
+ - Apple Notes' internal HTML processing preserves empty divs from previous edits, so the gaps are baked into the note's internal representation and cannot be fixed through further updates
1110
+ - Fix: delete the note with `delete-note` and create a fresh one with `create-note`
1111
+
1100
1112
  ### `apple-notes` server fails to connect when run from a clone
1101
1113
  - Launch `claude` from **inside the repo directory** so `CLAUDE_PROJECT_DIR` resolves to the repo root (the bare `.` fallback is unreliable — it points at the launching process's working directory)
1102
- - If you've been editing the source, rerun `npm run build` — the entrypoint is `${CLAUDE_PROJECT_DIR:-.}/build/index.js`, and the committed bundle only reflects your changes after a rebuild
1114
+ - If you've been editing the source, rerun `pnpm run build` — the entrypoint is `${CLAUDE_PROJECT_DIR:-.}/build/index.js`, and the committed bundle only reflects your changes after a rebuild
1103
1115
  - Run `claude mcp list` to check for a conflicting `apple-notes` entry at another scope (project-scope outranks user-scope, but local-scope outranks project-scope)
1104
1116
  - Approve the pending project-scope server when Claude Code prompts you
1105
1117
 
@@ -1107,14 +1119,16 @@ The `\\\\` in JSON becomes `\\` in the actual string, which represents a single
1107
1119
 
1108
1120
  ## Development
1109
1121
 
1122
+ Development uses [pnpm](https://pnpm.io/) (see `packageManager` in `package.json`):
1123
+
1110
1124
  ```bash
1111
- npm install # Install dependencies
1112
- npm run build # Typecheck, then bundle src/index.ts into build/index.js (esbuild)
1113
- npm test # Run unit test suite (mocked AppleScript)
1114
- npm run test:integration # Run integration tests against real Notes.app
1115
- npm run test:all # Unit + integration
1116
- npm run lint # Check code style
1117
- npm run format # Format code
1125
+ pnpm install # Install dependencies
1126
+ pnpm run build # Typecheck, then bundle src/index.ts into build/index.js (esbuild)
1127
+ pnpm test # Run unit test suite (mocked AppleScript)
1128
+ pnpm run test:integration # Run integration tests against real Notes.app
1129
+ pnpm run test:all # Unit + integration
1130
+ pnpm run lint # Check code style
1131
+ pnpm run format # Format code
1118
1132
  ```
1119
1133
 
1120
1134
  The integration suite (`test/integration.test.ts`) drives the real
@@ -1136,11 +1150,11 @@ A software consulting, contracting, and development company.
1136
1150
 
1137
1151
  ## License
1138
1152
 
1139
- MIT License - see [LICENSE](LICENSE) for details.
1153
+ MIT License - see [LICENSE](https://github.com/sweetrb/apple-notes-mcp/blob/main/LICENSE) for details.
1140
1154
 
1141
1155
  ## Contributing
1142
1156
 
1143
- Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
1157
+ Contributions are welcome! Please see [CONTRIBUTING.md](https://github.com/sweetrb/apple-notes-mcp/blob/main/CONTRIBUTING.md) for guidelines.
1144
1158
 
1145
1159
  ## Related Projects
1146
1160
 
@@ -1152,4 +1166,4 @@ Part of a family of macOS MCP servers:
1152
1166
 
1153
1167
  ## Recurring macOS permission prompts
1154
1168
 
1155
- If macOS keeps re-prompting for Full Disk Access or Automation for `node` (often after a `brew upgrade`), see [docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md](docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md) the fix is to run this server under the official, Developer-ID-signed Node so the grant survives Node updates.
1169
+ If macOS keeps re-prompting for Full Disk Access or Automation for `node` (often after a `brew upgrade`), the cause is almost always an **ad-hoc-signed Node** (typically Homebrew's): its code signature (cdhash) changes on every update, so macOS TCC treats each new build as a brand-new binary and silently drops the grants you already made. The fix is to run this server under an official, **Developer-ID-signed Node at a stable path** — its signing identity stays the same across updates, so you grant the permission once and it persists. The `doctor` tool detects the ad-hoc-signature case and the full walkthrough is in [docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md](https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md).
package/build/index.js CHANGED
@@ -38656,7 +38656,7 @@ var StdioServerTransport = class {
38656
38656
  };
38657
38657
 
38658
38658
  // src/utils/applescript.ts
38659
- import { execSync, spawnSync } from "child_process";
38659
+ import { execFileSync } from "child_process";
38660
38660
  var DEFAULT_TIMEOUT_MS = 3e4;
38661
38661
  var DEFAULT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
38662
38662
  function envPositiveNumber(name) {
@@ -38693,13 +38693,10 @@ function debugLog(message, data) {
38693
38693
  console.error(`[DEBUG ${timestamp}] ${message}`);
38694
38694
  }
38695
38695
  }
38696
- function escapeForShell(script) {
38697
- return script.replace(/'/g, "'\\''");
38698
- }
38699
38696
  function isTimeoutError(error2) {
38700
38697
  if (error2 instanceof Error) {
38701
38698
  const execError = error2;
38702
- return execError.killed === true || execError.signal === "SIGTERM";
38699
+ return execError.code === "ETIMEDOUT" || execError.killed === true || execError.signal === "SIGKILL" || execError.signal === "SIGTERM";
38703
38700
  }
38704
38701
  return false;
38705
38702
  }
@@ -38714,19 +38711,13 @@ function isRetryableError(errorMessage) {
38714
38711
  return RETRYABLE_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage));
38715
38712
  }
38716
38713
  function sleep(ms) {
38717
- const seconds = ms / 1e3;
38718
- const result = spawnSync("sleep", [seconds.toString()], { stdio: "ignore" });
38719
- if (result.error) {
38720
- const end = Date.now() + ms;
38721
- while (Date.now() < end) {
38722
- }
38723
- }
38714
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
38724
38715
  }
38725
38716
  var ERROR_MAPPINGS = [
38726
38717
  // Permission errors
38727
38718
  {
38728
38719
  pattern: /not authorized|not permitted|access.*denied/i,
38729
- message: "Permission denied. Grant automation access in System Preferences > Privacy & Security > Automation."
38720
+ message: "Permission denied. Grant automation access in System Settings > Privacy & Security > Automation."
38730
38721
  },
38731
38722
  // Application not running
38732
38723
  {
@@ -38812,8 +38803,7 @@ function executeAppleScript(script, options = {}) {
38812
38803
  error: "Cannot execute empty AppleScript"
38813
38804
  };
38814
38805
  }
38815
- const preparedScript = escapeForShell(wrapWithTimeout(script.trim(), timeoutMs));
38816
- const command = `osascript -e '${preparedScript}'`;
38806
+ const preparedScript = wrapWithTimeout(script.trim(), timeoutMs);
38817
38807
  debugLog("Executing AppleScript", {
38818
38808
  scriptPreview: script.trim().substring(0, 200) + (script.length > 200 ? "..." : ""),
38819
38809
  timeout: timeoutMs,
@@ -38824,7 +38814,8 @@ function executeAppleScript(script, options = {}) {
38824
38814
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
38825
38815
  const attemptStart = Date.now();
38826
38816
  try {
38827
- const output = execSync(command, {
38817
+ const output = execFileSync("osascript", ["-"], {
38818
+ input: preparedScript,
38828
38819
  encoding: "utf8",
38829
38820
  timeout: timeoutMs,
38830
38821
  // SIGKILL (not the default SIGTERM): a wedged osascript blocked on an
@@ -38833,9 +38824,7 @@ function executeAppleScript(script, options = {}) {
38833
38824
  killSignal: "SIGKILL",
38834
38825
  // Raise the output cap above Node's 1 MB default so large exports /
38835
38826
  // long notes aren't truncated into an ENOBUFS failure. (#16)
38836
- maxBuffer: getMaxBuffer(),
38837
- // Capture stderr separately to get error details
38838
- stdio: ["pipe", "pipe", "pipe"]
38827
+ maxBuffer: getMaxBuffer()
38839
38828
  });
38840
38829
  const duration3 = Date.now() - attemptStart;
38841
38830
  debugLog("AppleScript succeeded", {
@@ -38901,7 +38890,7 @@ function executeAppleScript(script, options = {}) {
38901
38890
  }
38902
38891
 
38903
38892
  // src/utils/checklistParser.ts
38904
- import { execFileSync } from "child_process";
38893
+ import { execFileSync as execFileSync2 } from "child_process";
38905
38894
  import * as zlib from "zlib";
38906
38895
  import * as fs from "fs";
38907
38896
  import * as path from "path";
@@ -38986,6 +38975,10 @@ function embeddedMessage(field) {
38986
38975
  return decodeMessage(bytes);
38987
38976
  }
38988
38977
 
38978
+ // src/utils/docsUrls.ts
38979
+ var FULL_DISK_ACCESS_GUIDE_URL = "https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/FULL-DISK-ACCESS.md";
38980
+ var NODE_RUNTIME_TCC_GUIDE_URL = "https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md";
38981
+
38989
38982
  // src/utils/checklistParser.ts
38990
38983
  var CHECKLIST_STYLE_TYPE = 103;
38991
38984
  var NOTES_DB_PATH = path.join(
@@ -38995,7 +38988,7 @@ var NOTES_DB_PATH = path.join(
38995
38988
  function hasFullDiskAccess() {
38996
38989
  try {
38997
38990
  if (!fs.existsSync(NOTES_DB_PATH)) return false;
38998
- execFileSync("sqlite3", ["-readonly", NOTES_DB_PATH, "SELECT 1;"], {
38991
+ execFileSync2("sqlite3", ["-readonly", NOTES_DB_PATH, "SELECT 1;"], {
38999
38992
  encoding: "utf8",
39000
38993
  timeout: 3e3,
39001
38994
  stdio: ["pipe", "pipe", "pipe"]
@@ -39014,7 +39007,7 @@ function queryNoteData(noteId) {
39014
39007
  const pk = pkMatch[1];
39015
39008
  const query = `SELECT hex(nd.ZDATA) FROM ZICNOTEDATA nd JOIN ZICCLOUDSYNCINGOBJECT n ON nd.ZNOTE = n.Z_PK WHERE n.Z_PK = ${pk};`;
39016
39009
  try {
39017
- const result = execFileSync("sqlite3", ["-readonly", NOTES_DB_PATH, query], {
39010
+ const result = execFileSync2("sqlite3", ["-readonly", NOTES_DB_PATH, query], {
39018
39011
  encoding: "utf8",
39019
39012
  timeout: 5e3,
39020
39013
  stdio: ["pipe", "pipe", "pipe"]
@@ -39106,7 +39099,7 @@ function getChecklistItems(noteId) {
39106
39099
  return {
39107
39100
  items: null,
39108
39101
  error: "no_fda",
39109
- message: "Full Disk Access is required to read checklist state. Grant access in System Settings > Privacy & Security > Full Disk Access, then add and restart this application."
39102
+ message: `Full Disk Access is required to read checklist state. In System Settings > Privacy & Security > Full Disk Access, grant access to the app that launches this server (Claude Desktop / Terminal / iTerm2), then fully quit and relaunch it. Setup guide: ${FULL_DISK_ACCESS_GUIDE_URL} \u2014 run the doctor tool to verify.`
39110
39103
  };
39111
39104
  }
39112
39105
  if (!hexData) {
@@ -40540,7 +40533,7 @@ var AppleNotesManager = class {
40540
40533
  message: "Notes.app is accessible"
40541
40534
  });
40542
40535
  } else {
40543
- const errorHint = appCheck.error?.includes("not authorized") ? " (check Automation permissions in System Preferences)" : "";
40536
+ const errorHint = appCheck.error?.includes("not authorized") ? " (check Automation permissions in System Settings > Privacy & Security > Automation)" : "";
40544
40537
  checks.push({
40545
40538
  name: "notes_app",
40546
40539
  passed: false,
@@ -40560,7 +40553,7 @@ var AppleNotesManager = class {
40560
40553
  checks.push({
40561
40554
  name: "permissions",
40562
40555
  passed: !isPermError,
40563
- message: isPermError ? "AppleScript permissions denied. Grant access in System Preferences > Privacy & Security > Automation" : `Permission check returned: ${permCheck.error}`
40556
+ message: isPermError ? "AppleScript permissions denied. Grant access in System Settings > Privacy & Security > Automation" : `Permission check returned: ${permCheck.error}`
40564
40557
  });
40565
40558
  if (isPermError) {
40566
40559
  return { healthy: false, checks };
@@ -41366,7 +41359,7 @@ var AppleNotesManager = class {
41366
41359
  };
41367
41360
 
41368
41361
  // src/utils/syncDetection.ts
41369
- import { execFileSync as execFileSync2 } from "child_process";
41362
+ import { execFileSync as execFileSync3 } from "child_process";
41370
41363
  import * as fs2 from "fs";
41371
41364
  import * as path2 from "path";
41372
41365
  import * as os2 from "os";
@@ -41407,7 +41400,7 @@ function getSyncStatus(useCache = true) {
41407
41400
  WHERE ZCURRENTLOCALVERSION > ZLATESTVERSIONSYNCEDTOCLOUD
41408
41401
  AND ZLATESTVERSIONSYNCEDTOCLOUD IS NOT NULL;
41409
41402
  `;
41410
- const result = execFileSync2(
41403
+ const result = execFileSync3(
41411
41404
  "sqlite3",
41412
41405
  ["-readonly", NOTES_DB_PATH2, query.replace(/\n/g, " ")],
41413
41406
  {
@@ -41466,7 +41459,7 @@ function withSyncAwarenessSync(operation, fn) {
41466
41459
  }
41467
41460
 
41468
41461
  // src/utils/noteMetadata.ts
41469
- import { execFileSync as execFileSync3 } from "child_process";
41462
+ import { execFileSync as execFileSync4 } from "child_process";
41470
41463
  import * as fs3 from "fs";
41471
41464
  import * as path3 from "path";
41472
41465
  import * as os3 from "os";
@@ -41474,7 +41467,7 @@ var NOTES_DB_PATH3 = path3.join(
41474
41467
  os3.homedir(),
41475
41468
  "Library/Group Containers/group.com.apple.notes/NoteStore.sqlite"
41476
41469
  );
41477
- var FDA_MESSAGE = "Full Disk Access is required to read note metadata. Grant access in System Settings > Privacy & Security > Full Disk Access, then add and restart this application.";
41470
+ var FDA_MESSAGE = `Full Disk Access is required to read note metadata. In System Settings > Privacy & Security > Full Disk Access, grant access to the app that launches this server (Claude Desktop / Terminal / iTerm2), then fully quit and relaunch it. Setup guide: ${FULL_DISK_ACCESS_GUIDE_URL} \u2014 run the doctor tool to verify.`;
41478
41471
  var COLUMN_MAP = [
41479
41472
  { key: "pinned", column: "ZISPINNED", type: "bool" },
41480
41473
  { key: "hasChecklist", column: "ZHASCHECKLIST", type: "bool" },
@@ -41487,7 +41480,7 @@ var COLUMN_MAP = [
41487
41480
  { key: "smartFolderQuery", column: "ZSMARTFOLDERQUERYJSON", type: "text" }
41488
41481
  ];
41489
41482
  function runSqlite(query) {
41490
- return execFileSync3("sqlite3", ["-readonly", NOTES_DB_PATH3, query], {
41483
+ return execFileSync4("sqlite3", ["-readonly", NOTES_DB_PATH3, query], {
41491
41484
  encoding: "utf8",
41492
41485
  timeout: 5e3,
41493
41486
  stdio: ["pipe", "pipe", "pipe"]
@@ -41626,7 +41619,7 @@ function strippedImagesWarning(stripped) {
41626
41619
  }
41627
41620
 
41628
41621
  // src/tools/doctor.ts
41629
- import { spawnSync as spawnSync2 } from "child_process";
41622
+ import { spawnSync } from "child_process";
41630
41623
  function runDoctor(manager) {
41631
41624
  const checks = [];
41632
41625
  const hc = manager.healthCheck();
@@ -41655,7 +41648,7 @@ function runDoctor(manager) {
41655
41648
  checks.push({
41656
41649
  name: "Full Disk Access",
41657
41650
  status: fda ? "ok" : "warn",
41658
- detail: fda ? "granted \u2014 checklist features available" : "not granted \u2014 get-checklist-state and checklist annotations in get-note-markdown won't work. Grant in System Settings > Privacy & Security > Full Disk Access."
41651
+ detail: fda ? "granted \u2014 checklist features available" : `not granted \u2014 get-checklist-state and checklist annotations in get-note-markdown won't work. In System Settings > Privacy & Security > Full Disk Access, grant access to the app that launches this server (Claude Desktop / Terminal / iTerm2), then fully quit and relaunch it and re-run doctor. Setup guide: ${FULL_DISK_ACCESS_GUIDE_URL}`
41659
41652
  });
41660
41653
  checks.push(checkNodeRuntimeSignature());
41661
41654
  const healthy = !checks.some((c) => c.status === "fail");
@@ -41664,7 +41657,7 @@ function runDoctor(manager) {
41664
41657
  function checkNodeRuntimeSignature() {
41665
41658
  const name = "Node runtime signature";
41666
41659
  try {
41667
- const r = spawnSync2("codesign", ["-dvvv", process.execPath], { encoding: "utf8" });
41660
+ const r = spawnSync("codesign", ["-dvvv", process.execPath], { encoding: "utf8" });
41668
41661
  const out = `${r.stdout ?? ""}${r.stderr ?? ""}`;
41669
41662
  if (r.error || !out.trim()) {
41670
41663
  return {
@@ -41678,7 +41671,7 @@ function checkNodeRuntimeSignature() {
41678
41671
  return {
41679
41672
  name,
41680
41673
  status: "warn",
41681
- detail: `${process.execPath} is ad-hoc signed (no Team ID). macOS revokes its Automation and Full Disk Access grants every time the binary changes (e.g. every brew upgrade), which looks like random permission loss. Fix: run the server with a Developer-ID-signed Node at a stable path \u2014 see docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md.`
41674
+ detail: `${process.execPath} is ad-hoc signed (no Team ID). macOS revokes its Automation and Full Disk Access grants every time the binary changes (e.g. every brew upgrade), which looks like random permission loss. Fix: run the server with a Developer-ID-signed Node at a stable path \u2014 see ${NODE_RUNTIME_TCC_GUIDE_URL}`
41682
41675
  };
41683
41676
  }
41684
41677
  const team = /^TeamIdentifier=(.+)$/m.exec(out)?.[1];
@@ -42682,7 +42675,7 @@ server.registerTool(
42682
42675
  return ` ${icon} ${c.name}: ${c.message}`;
42683
42676
  }).join("\n");
42684
42677
  const fdaAvailable = hasFullDiskAccess();
42685
- const fdaLine = fdaAvailable ? " \u2713 full_disk_access: Granted (checklist features available)" : " \u24D8 full_disk_access: Not granted (optional \u2014 needed for get-checklist-state and checklist annotations in get-note-markdown). Grant in System Settings > Privacy & Security > Full Disk Access.";
42678
+ const fdaLine = fdaAvailable ? " \u2713 full_disk_access: Granted (checklist features available)" : ` \u24D8 full_disk_access: Not granted (optional \u2014 needed for get-checklist-state and checklist annotations in get-note-markdown). In System Settings > Privacy & Security > Full Disk Access, grant access to the app that launches this server (Claude Desktop / Terminal / iTerm2), then fully quit and relaunch it. Setup guide: ${FULL_DISK_ACCESS_GUIDE_URL} \u2014 run the doctor tool to verify.`;
42686
42679
  return successResponse(`${statusIcon} ${statusText}
42687
42680
 
42688
42681
  ${checkLines}
@@ -0,0 +1,92 @@
1
+ # AppleScript Limitations
2
+
3
+ Apple Notes is automated through its AppleScript dictionary. A few features that
4
+ exist in the Notes UI are simply **not exposed to AppleScript**, so this MCP
5
+ server cannot read or write them no matter how the script is written. This page
6
+ documents what was investigated, how it was verified, and the conclusion, so the
7
+ limitation isn't re-investigated every release.
8
+
9
+ The full set of properties Notes exposes on a `note` is:
10
+
11
+ ```
12
+ container, class, password protected, modification date, creation date,
13
+ shared, body, id, name, plaintext
14
+ ```
15
+
16
+ (obtained with `properties of note 1 of account "iCloud"`).
17
+
18
+ ## Pinned notes (#28)
19
+
20
+ **Status: not feasible via AppleScript.** The Notes UI lets you pin a note to
21
+ the top of a folder, but the `note` class has no `pinned` property. Asking for
22
+ it raises error `-1700`:
23
+
24
+ ```applescript
25
+ tell application "Notes"
26
+ set p to pinned of note 1 of account "iCloud"
27
+ -- error -1700: Can't make pinned of note id "x-coredata://…" into type specifier.
28
+ end tell
29
+ ```
30
+
31
+ There is no alternative property, element, or command (`pin`, `pinned`,
32
+ `favorite`, …) in the dictionary. Pinned state lives only in Notes' private
33
+ Core Data store (`NoteStore.sqlite`), which is not part of the scriptable
34
+ surface. Reading it would require parsing the SQLite store directly — brittle
35
+ across macOS releases and outside what an AppleScript-based server should do —
36
+ and there is no supported way to *set* it at all.
37
+
38
+ **Conclusion:** pinned read/write is not supported and will not be added while
39
+ Notes lacks a scriptable property. If a future macOS exposes one, revisit by
40
+ re-running the probe above.
41
+
42
+ ## Note-to-note links (#30)
43
+
44
+ **Status: not supported as data; navigation-only.** Apple Notes lets you insert
45
+ a link from one note to another in the UI, but AppleScript exposes no property
46
+ or element for it:
47
+
48
+ - A `note` has no `URL`, `url`, or `link` property — each raises error `-2753`
49
+ (undefined). There is no element that enumerates outgoing/incoming links.
50
+ - There is no readable or constructable `applenotes://` / `notes://` deep link.
51
+ The note's `id` (`x-coredata://…/ICNote/p123`) is the only stable handle, and
52
+ it is a Core Data URI, not a shareable or clickable link.
53
+
54
+ The one related capability that *does* work is the `show` command, which reveals
55
+ a note in the Notes UI by id:
56
+
57
+ ```applescript
58
+ tell application "Notes" to show note id "x-coredata://…/ICNote/p123"
59
+ ```
60
+
61
+ This is deliberately **not** wrapped as a tool: it pops the GUI (unhelpful for a
62
+ headless server), and an agent already has the note's content via
63
+ `get-note-content` / `get-note-markdown`. Link relationships between notes
64
+ cannot be read at all, so a "list links in this note" feature is not possible.
65
+
66
+ **Conclusion:** note-to-note link data is not exposed and cannot be surfaced.
67
+ The `id` field already returned by every read tool is the canonical reference;
68
+ use that to address a specific note.
69
+
70
+ ## Tags / hashtags (#29)
71
+
72
+ **Status: parsed from the body, not first-class.** Apple Notes "tags" are inline
73
+ `#hashtag` tokens you type into a note's text. They are **not** a scriptable
74
+ property — the `note` class exposes no `tags` element, and the tag relationship
75
+ lives only in Notes' private Core Data store. So the only way to surface a
76
+ note's tags via AppleScript is to read them back out of the body text.
77
+
78
+ This server does that: `get-note-content` parses the body and returns the tags
79
+ as `hashtags` in its `structuredContent` (see `src/utils/hashtags.ts`). The
80
+ rules match Notes' own behaviour — a token is `#` followed by letters/digits/
81
+ underscores containing **at least one letter**, so `#123` is not a tag; tokens
82
+ are de-duplicated case-insensitively.
83
+
84
+ Two related caveats:
85
+
86
+ - The `tags` parameter on `create-note` is an application-level pass-through. It
87
+ is stored on the returned object but Notes does **not** persist it, and it does
88
+ **not** create real `#hashtags` in the body. To make a real tag, put `#tag`
89
+ in the note content.
90
+ - **Smart folders are not scriptable.** Notes' tag-driven Smart Folders cannot be
91
+ created, read, or enumerated via AppleScript; there is no `smart folder` class
92
+ in the dictionary. Only regular folders are scriptable.
@@ -0,0 +1,72 @@
1
+ # Full Disk Access for Checklist Features
2
+
3
+ Apple Notes MCP works almost entirely without any special disk permission. **One
4
+ feature area** needs **Full Disk Access (FDA)** for the process that runs the MCP
5
+ server:
6
+
7
+ - **`get-checklist-state`** — reads a note's checklist done/undone state.
8
+ - **Checklist annotations in `get-note-markdown`** — the `[x]` / `[ ]` prefixes on
9
+ checklist items.
10
+
11
+ Everything else (creating, reading, searching, updating, moving, deleting notes;
12
+ folders, accounts, attachments, stats, export, etc.) works **without** Full Disk
13
+ Access.
14
+
15
+ ## Why it's needed
16
+
17
+ Apple Notes stores checklist items as a paragraph style inside a gzipped protobuf
18
+ blob in its SQLite store, `NoteStore.sqlite`. AppleScript's `body of note`
19
+ interface strips that state — it can't tell you whether a checklist item is
20
+ checked. To recover it, the MCP reads the SQLite store directly.
21
+
22
+ That database lives in a macOS-protected directory:
23
+
24
+ ```
25
+ ~/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite
26
+ ```
27
+
28
+ Reading anything under `~/Library/Group Containers/` requires **Full Disk
29
+ Access** for the host process — without it, macOS denies the read and the MCP
30
+ cannot parse checklist state. (The MCP only ever **reads** this database; it never
31
+ writes to it.)
32
+
33
+ ## How to grant Full Disk Access
34
+
35
+ 1. Open **System Settings** (or **System Preferences** on older macOS).
36
+ 2. Go to **Privacy & Security → Full Disk Access**.
37
+ 3. Click the **+** button (you may need to unlock with Touch ID / your password
38
+ first), and add the application that **hosts** the MCP server — i.e. the app
39
+ that actually launches `node`:
40
+ - **Claude Desktop** → `/Applications/Claude.app`
41
+ - **Terminal** (if you run Claude Code from a shell) → `/Applications/Utilities/Terminal.app`
42
+ - **iTerm** → `/Applications/iTerm.app`
43
+ - **VS Code** → `/Applications/Visual Studio Code.app`
44
+ 4. Make sure the toggle next to the app is **on**.
45
+ 5. **Fully quit and reopen the host app.** macOS only applies the new permission
46
+ to processes started *after* the change — a reload or restart-server is not
47
+ enough; the host application itself must be quit (⌘Q) and relaunched.
48
+
49
+ > **Grant FDA to the right app.** FDA applies to the process that spawns the
50
+ > server, not to `node` or to Notes.app. If you launch Claude Code from iTerm,
51
+ > grant it to iTerm; if you use Claude Desktop, grant it to Claude. Granting it to
52
+ > the wrong app has no effect.
53
+
54
+ ## Verifying it worked
55
+
56
+ Run the **`doctor`** tool. It reports a dedicated **Full Disk Access** check as
57
+ `ok` / `warn` / `fail` with the reason, so you can confirm the grant took effect
58
+ without guessing. You can also just call `get-checklist-state` on a note that has
59
+ a checklist — if it returns items with `[x]`/`[ ]` state, FDA is working.
60
+
61
+ ## Without Full Disk Access
62
+
63
+ The server degrades gracefully — nothing crashes:
64
+
65
+ - `get-checklist-state` returns a clear error explaining that database access is
66
+ needed (and points here).
67
+ - `get-note-markdown` still returns the note as Markdown, but checklist items
68
+ appear as plain list items without the `[x]`/`[ ]` annotations.
69
+ - **All other tools work normally.**
70
+
71
+ See also: [Known Limitations](../README.md#known-limitations) and
72
+ [Creating Checklists](../README.md#creating-checklists) in the README.
@@ -0,0 +1,129 @@
1
+ # JXA (JavaScript for Automation) Research
2
+
3
+ This document summarizes research into using JXA as an alternative to AppleScript for interacting with Apple Notes.
4
+
5
+ ## Background
6
+
7
+ JXA was introduced in OS X Yosemite (10.10) as an alternative scripting language for the OSA (Open Scripting Architecture) framework. It allows JavaScript to be used instead of AppleScript for automation tasks.
8
+
9
+ **Hypothesis**: JXA might provide advantages over AppleScript:
10
+ - Simpler string escaping (standard JavaScript)
11
+ - Better Unicode handling
12
+ - Familiar syntax for developers
13
+ - Native JSON support
14
+
15
+ ## Implementation
16
+
17
+ Created `src/utils/jxa.ts` with:
18
+ - `executeJXA(script, options)` - Execute JXA via `osascript -l JavaScript`
19
+ - `escapeForJXA(str)` - Standard JavaScript string escaping
20
+ - `buildNotesJXA(code)` - Wrapper for Notes.app context
21
+
22
+ ## Test Results
23
+
24
+ ### Performance Comparison
25
+
26
+ | Operation | AppleScript | JXA | Ratio |
27
+ |-----------|-------------|-----|-------|
28
+ | List Accounts | 124ms | 152ms | 0.8x |
29
+ | List Folders | 141ms | 153ms | 0.9x |
30
+ | Count Notes | 148ms | 138ms | 1.1x |
31
+ | Get First Note Title | 154ms | 155ms | 1.0x |
32
+ | Get Creation Date | 152ms | 150ms | 1.0x |
33
+ | **Unicode Search** | **155ms** | **4129ms** | **0.04x** |
34
+ | **Text Search** | **311ms** | **4115ms** | **0.08x** |
35
+
36
+ **Overall**: JXA is **0.13x the speed of AppleScript** (7.6x slower)
37
+
38
+ ### Why Search is So Slow in JXA
39
+
40
+ AppleScript's `whose` clause is optimized at the OSA/Notes.app level:
41
+ ```applescript
42
+ -- Fast: filtered server-side
43
+ notes whose name contains "test"
44
+ ```
45
+
46
+ JXA must iterate in JavaScript:
47
+ ```javascript
48
+ // Slow: fetches all notes, filters client-side
49
+ Notes.notes().filter(n => n.name().includes("test"))
50
+ ```
51
+
52
+ The 347-note test database showed search operations taking 4+ seconds in JXA vs ~300ms in AppleScript.
53
+
54
+ ### Output Format Comparison
55
+
56
+ | Aspect | AppleScript | JXA |
57
+ |--------|-------------|-----|
58
+ | **Dates** | Locale-dependent ("Wednesday, December 31, 2025 at 5:33:09 PM") | ISO format with `.toISOString()` |
59
+ | **Arrays** | Comma-separated string | Native JavaScript arrays |
60
+ | **Null handling** | "missing value" | JavaScript null/undefined |
61
+
62
+ ### String Escaping Comparison
63
+
64
+ **AppleScript** (complex, multi-layer):
65
+ ```typescript
66
+ // Must escape for: shell → AppleScript → HTML
67
+ str.replace(/\\/g, "&#92;") // HTML entity for backslash
68
+ .replace(/"/g, "\\\"") // AppleScript quote escape
69
+ .replace(/'/g, "'\\''") // Shell quote escape
70
+ ```
71
+
72
+ **JXA** (simpler, standard JavaScript):
73
+ ```typescript
74
+ str.replace(/\\/g, "\\\\") // Standard JS escaping
75
+ .replace(/"/g, '\\"')
76
+ .replace(/\n/g, "\\n")
77
+ ```
78
+
79
+ ### Unicode Handling
80
+
81
+ Both handle Unicode identically - they share the same underlying OSA framework. No advantage either way.
82
+
83
+ ### Error Messages
84
+
85
+ Both produce similar error messages from the OSA framework:
86
+ - AppleScript: `execution error: Notes got an error: Can't get note "X". (-1728)`
87
+ - JXA: `execution error: Error: Error: Can't get object. (-1728)`
88
+
89
+ ## Recommendation
90
+
91
+ **Do NOT migrate to JXA for Apple Notes operations.**
92
+
93
+ ### Reasons
94
+
95
+ 1. **Critical Performance Penalty**: Search operations are 10-26x slower due to client-side filtering
96
+ 2. **No Significant Escaping Advantage**: While JXA escaping is simpler, our AppleScript escaping is already robust and well-tested
97
+ 3. **Same Underlying Limitations**: Both use OSA, so neither can access locked notes, sync state, etc.
98
+ 4. **Maintenance Burden**: Would require maintaining two scripting approaches
99
+
100
+ ### Limited Use Cases for JXA
101
+
102
+ JXA could be useful for:
103
+ 1. **Date formatting**: Returns ISO dates instead of locale-dependent strings (but we can parse AppleScript dates)
104
+ 2. **JSON output**: Native `JSON.stringify()` for complex data (but our current parsing works fine)
105
+ 3. **One-off scripts**: Developer tools/scripts where performance isn't critical
106
+
107
+ ### Conclusion
108
+
109
+ The current AppleScript implementation should remain the primary execution method. The simpler escaping in JXA doesn't justify the 7.6x performance penalty, especially for operations involving filtering or searching.
110
+
111
+ The JXA utilities (`src/utils/jxa.ts`) have been created and tested, and can be used for specific scenarios where its advantages (ISO dates, simpler escaping) outweigh the performance cost.
112
+
113
+ ## Files Created
114
+
115
+ - `src/utils/jxa.ts` - JXA execution utilities
116
+ - `src/utils/jxa.test.ts` - Unit tests (16 tests)
117
+ - `scripts/jxa-comparison.ts` - Real-world comparison script
118
+ - `docs/JXA_RESEARCH.md` - This document
119
+
120
+ ## Test Environment
121
+
122
+ - macOS Sequoia 26.2.0
123
+ - Notes.app with 347 notes across 4 accounts
124
+ - Node.js with tsx for TypeScript execution
125
+
126
+ ---
127
+
128
+ *Research completed: January 2025*
129
+ *Branch: feature/jxa-alternative*
@@ -0,0 +1,109 @@
1
+ # Node runtime & TCC permission stability
2
+
3
+ macOS gates this MCP server's access to your data behind **TCC** permissions —
4
+ **Full Disk Access** (to read app data such as Mail, Notes, or Photos) and
5
+ **Automation / Apple Events** (to drive an app like Mail.app or Notes.app via
6
+ AppleScript). See this repo's Full Disk Access / Automation notes for *which*
7
+ operations need which permission.
8
+
9
+ This page is about a **separate, recurring annoyance**: being asked to approve
10
+ those permissions **over and over**, often right after a routine `brew upgrade`.
11
+
12
+ ## Symptom
13
+
14
+ - You granted Full Disk Access (and/or Automation) to "node", but days later
15
+ macOS prompts again — `"node" wants access to ...` or `"node" wants to control
16
+ "Mail"`.
17
+ - System Settings → Privacy & Security → Full Disk Access shows **several
18
+ identical "node" rows**, usually only one enabled.
19
+ - It tends to happen immediately after you update Node.
20
+
21
+ ## Cause
22
+
23
+ TCC binds a permission grant to the **code identity of the binary that performs
24
+ the access** — here, the `node` executable that launches the MCP server. For a
25
+ binary that is only **ad-hoc signed** (no Developer ID / Team ID), TCC keys the
26
+ grant to the binary's **cdhash**, a hash of its contents.
27
+
28
+ Homebrew's `node` formula is ad-hoc signed:
29
+
30
+ ```bash
31
+ $ codesign -dvvv "$(which node)" 2>&1 | grep -E 'Signature|TeamIdentifier'
32
+ Signature=adhoc
33
+ TeamIdentifier=not set
34
+ ```
35
+
36
+ Every Node update **replaces the binary**, which **changes the cdhash**, so TCC
37
+ no longer recognizes it as the thing you approved — and re-prompts. The extra
38
+ "node" rows are stale cdhashes from previous versions.
39
+
40
+ By contrast, properly signed apps (Chrome, Slack, …) keep their permissions
41
+ across auto-updates because TCC matches them on a stable **Designated
42
+ Requirement** derived from their Developer ID, not on the cdhash.
43
+
44
+ ## Fix: run the MCP under the official, Developer-ID-signed Node
45
+
46
+ Node binaries distributed from **nodejs.org** are signed with a real Developer
47
+ ID (`Node.js Foundation`, Team `HX7739G8FX`), notarized, and self-contained.
48
+ Pointing the MCP server at one gives TCC a **stable** identity to match, so a
49
+ permission you grant **persists across future Node updates**. It also decouples
50
+ the MCP runtime from your Homebrew/dev Node, which can keep updating freely.
51
+
52
+ ### Steps (Apple Silicon shown; use `darwin-x64` on Intel)
53
+
54
+ 1. Install a current LTS to a stable path (kept off `PATH` so it won't shadow
55
+ your dev Node):
56
+
57
+ ```bash
58
+ VER=v24.17.0 ARCH=darwin-arm64
59
+ mkdir -p ~/mcp-runtime && cd ~/mcp-runtime
60
+ curl -O https://nodejs.org/dist/$VER/node-$VER-$ARCH.tar.gz
61
+ curl -O https://nodejs.org/dist/$VER/SHASUMS256.txt
62
+ grep " node-$VER-$ARCH.tar.gz$" SHASUMS256.txt | shasum -a 256 -c - # must print OK
63
+ tar -xzf node-$VER-$ARCH.tar.gz
64
+ ln -sfn node-$VER-$ARCH node-current
65
+ ```
66
+
67
+ 2. Confirm it's Developer-ID signed:
68
+
69
+ ```bash
70
+ codesign -dvvv ~/mcp-runtime/node-current/bin/node 2>&1 | grep -E 'Authority=Developer ID|TeamIdentifier'
71
+ # Authority=Developer ID Application: Node.js Foundation (HX7739G8FX)
72
+ # TeamIdentifier=HX7739G8FX
73
+ ```
74
+
75
+ 3. Point this MCP server's launcher at it. For Claude Desktop, edit
76
+ `~/Library/Application Support/Claude/claude_desktop_config.json` and set this
77
+ server's `command` to the absolute path:
78
+
79
+ ```json
80
+ {
81
+ "mcpServers": {
82
+ "apple-notes": {
83
+ "command": "/Users/<you>/mcp-runtime/node-current/bin/node",
84
+ "args": ["/path/to/apple-notes-mcp/build/index.js"]
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
90
+ Servers launched via `npx` that don't need Full Disk Access can stay on
91
+ Homebrew Node.
92
+
93
+ 4. **Restart your MCP client** so the server relaunches under the new Node.
94
+
95
+ 5. **Grant the permissions once** to the new binary:
96
+ - *Full Disk Access*: System Settings → Privacy & Security → Full Disk Access
97
+ → **+** → ⌘⇧G → paste `~/mcp-runtime/node-current/bin/node`.
98
+ - *Automation*: the first time the server drives an app you'll get a one-time
99
+ `"node" wants to control "<App>"` prompt — click **Allow**.
100
+
101
+ Both grants are keyed to the official Node's stable signature, so you should
102
+ not be asked again — including after future Node updates. You can delete the
103
+ stale "node" rows from the Full Disk Access list.
104
+
105
+ ### Updating the dedicated Node later
106
+
107
+ Drop a newer official LTS tarball into `~/mcp-runtime/`, repoint the
108
+ `node-current` symlink, and restart your client. The signing identity is
109
+ unchanged, so existing grants carry over — no re-approval.
@@ -0,0 +1,151 @@
1
+ # apple-notes-mcp — Stability & Performance Audit
2
+
3
+ **Date:** 2026-06-19 · **Version audited:** 1.4.4 (`main` @ `bb677d6`)
4
+ **Scope:** full codebase — `services/appleNotesManager.ts` (~2375 LOC), `index.ts` (23 tools), `utils/{applescript,jxa,checklistParser,protobuf,syncDetection}.ts`, CI/publish workflows.
5
+
6
+ This audit measures apple-notes-mcp against the maturity bar set by its sibling
7
+ **apple-mail-mcp (v2.1.1)** — robustness, performance, and the maturity surface
8
+ (structured output, diagnostics, resources/prompts, config). Findings are ranked
9
+ by **impact × likelihood** with rough effort (S/M/L). Many fixes are direct ports
10
+ of code already hardened in apple-mail. Line numbers are against `main` @ `bb677d6`.
11
+
12
+ ## Resolution status
13
+
14
+ | # | Finding | Tier | Issue | Status |
15
+ |---|---------|------|-------|--------|
16
+ | H1 | `execSync` has no `maxBuffer` cap | High | [#16](https://github.com/sweetrb/apple-notes-mcp/issues/16) | Open |
17
+ | H2 | No `with timeout` + SIGTERM wedges Notes.app | High | [#17](https://github.com/sweetrb/apple-notes-mcp/issues/17) | Open |
18
+ | H3 | Printable `\|\|\|` / comma delimiters collide with user content | High | [#18](https://github.com/sweetrb/apple-notes-mcp/issues/18) | Open |
19
+ | H4 | Swallowed failures return `[]`/`null`/`0` | High | [#19](https://github.com/sweetrb/apple-notes-mcp/issues/19) | Open |
20
+ | H5 | Unbounded full-library scans, no partial-result signal | High | [#20](https://github.com/sweetrb/apple-notes-mcp/issues/20) | Open |
21
+ | M1 | No `structuredContent` on any tool | Medium | [#21](https://github.com/sweetrb/apple-notes-mcp/issues/21) | Open |
22
+ | M2 | No `doctor` tool (incl. Full Disk Access check) | Medium | [#22](https://github.com/sweetrb/apple-notes-mcp/issues/22) | Open |
23
+ | M3 | No MCP resources or prompts | Medium | [#23](https://github.com/sweetrb/apple-notes-mcp/issues/23) | Open |
24
+ | M4 | No file-based config loader | Medium | [#24](https://github.com/sweetrb/apple-notes-mcp/issues/24) | Open |
25
+ | M5 | Locale-fragile date parsing | Medium | [#25](https://github.com/sweetrb/apple-notes-mcp/issues/25) | Open |
26
+ | M6 | Batch ops are N+1 osascript fan-out | Medium | [#26](https://github.com/sweetrb/apple-notes-mcp/issues/26) | Open |
27
+ | M7 | No `save-attachment` / `fetch-attachment` | Medium | [#27](https://github.com/sweetrb/apple-notes-mcp/issues/27) | Open |
28
+ | L1 | Pinned notes not exposed | Low | [#28](https://github.com/sweetrb/apple-notes-mcp/issues/28) | Open |
29
+ | L2 | Tags/hashtags not surfaced | Low | [#29](https://github.com/sweetrb/apple-notes-mcp/issues/29) | Open |
30
+ | L3 | Note-to-note links not supported | Low | [#30](https://github.com/sweetrb/apple-notes-mcp/issues/30) | Open |
31
+ | L5 | No integration test suite | Low | [#31](https://github.com/sweetrb/apple-notes-mcp/issues/31) | Open |
32
+ | L6 | Full Disk Access guide + commit this audit | Low | [#32](https://github.com/sweetrb/apple-notes-mcp/issues/32) | Open |
33
+
34
+ Target release for the fixes: **2.0.0** (full parity with apple-mail), built on a
35
+ long-lived `v2` branch, one item at a time with tests, then full regression +
36
+ docs + merge.
37
+
38
+ ---
39
+
40
+ ## High
41
+
42
+ ### H1 — `execSync` has no `maxBuffer` cap → ENOBUFS on large output (S)
43
+ `src/utils/applescript.ts:324` and `src/utils/jxa.ts:103` call `execSync` with no
44
+ `maxBuffer`, inheriting Node's 1 MB default. `export-notes-json` (full-library
45
+ JSON), `getRecentlyModifiedCounts` (every note's mod-date across all accounts,
46
+ `appleNotesManager.ts:1838-1851`), `get name of notes`, and `get-note-content` on
47
+ long notes can exceed it. Over the limit `execSync` throws and the catch path
48
+ returns `[]`/`null` — a silent, data-dependent failure. **Fix:** add a 64 MB
49
+ default `maxBuffer` (env-overridable) to both executors (port apple-mail #27).
50
+
51
+ ### H2 — No `with timeout` wrap; SIGTERM kill wedges Notes.app (M)
52
+ `applescript.ts` wraps nothing in `with timeout` and relies on `execSync`'s
53
+ default SIGTERM (timeout detection keys on `signal === "SIGTERM"`, line 88).
54
+ Killing osascript with SIGTERM leaves work already dispatched inside the
55
+ single-threaded app running, wedging it for subsequent calls (apple-mail #11).
56
+ **Fix:** port `wrapWithTimeout()` (a script-level `with timeout` set below the
57
+ process timeout) and `killSignal: "SIGKILL"`.
58
+
59
+ ### H3 — Printable `|||` / comma delimiters collide with user content (M)
60
+ Output is split on printable tokens a user can type into a note title or folder
61
+ name: `|||` and `|||ITEM|||` (~20 sites in `appleNotesManager.ts`), bare commas in
62
+ `parseCommaSeparatedList` (`:495`, used at `:1242` and `:1656`), and comma
63
+ field-splitting in `parseNotePropertiesOutput` (`:326-345`). A note titled
64
+ "Groceries, etc." splits into two phantom notes. **This is a real
65
+ data-corruption-on-ordinary-input bug.** **Fix:** switch field/record delimiters
66
+ to control chars (`\x1f`/`\x1e`/`\x1d`) and stop comma-splitting list output
67
+ (port apple-mail #30).
68
+
69
+ ### H4 — Swallowed failures: error → `return []`/`null`/`0` (M)
70
+ On `result.success === false`, many manager methods log to stderr and return an
71
+ empty/neutral value, so the caller can't distinguish "operation failed" from "no
72
+ notes": search `:808-810`, list `:1212-1216`/`:1239`, folders `:1367-1371`,
73
+ accounts `:1652`, `getRecentlyModifiedCounts` `:1852-1853`, and `getNotesStats`
74
+ (wrong totals if any folder scan fails). **Fix:** surface failures (throw or a
75
+ discriminated result) and add partial-coverage diagnostics (apple-mail #28/#29).
76
+
77
+ ### H5 — Unbounded full-library scans, no partial-result signal (M)
78
+ `getRecentlyModifiedCounts` (`:1838-1851`) iterates *every note of every account*
79
+ into one giant string; `getNotesStats` (`:1785-1815`) loops account×folder calling
80
+ `listNotes` (one osascript spawn) per folder. No bound, no coverage diagnostic —
81
+ this both trips H1's buffer and H2's timeout on large libraries. **Fix:** bound
82
+ the scan, batch stats into fewer Apple Events, report coverage (apple-mail #24).
83
+
84
+ ## Medium
85
+
86
+ ### M1 — No `structuredContent` on any tool (M)
87
+ No tool returns `structuredContent` (grep: none); `export-notes-json` even
88
+ stringifies JSON into a text block (`index.ts:955-961`). **Fix:** add
89
+ `successResponse`/`errorResponse` helpers carrying typed JSON and emit from all
90
+ read/list/get tools (apple-mail A1).
91
+
92
+ ### M2 — No `doctor` tool (M)
93
+ Only a thin `health-check` (`index.ts:763`, `appleNotesManager.ts:1687`). **Fix:**
94
+ add a `doctor` checking Notes.app reachability, Automation permission, **Full Disk
95
+ Access** (required for checklist parsing — a common silent-failure source),
96
+ account list, and default-account resolution, with actionable messages +
97
+ `structuredContent` (apple-mail C3).
98
+
99
+ ### M3 — No MCP resources or prompts (M)
100
+ No `registerResource`/`registerPrompt`. **Fix:** expose resources
101
+ (`notes://accounts`, `notes://folders`, `notes://note/{id}`) and prompts
102
+ (find-note, create-checklist, weekly-review) (apple-mail D2).
103
+
104
+ ### M4 — No file-based config loader (S)
105
+ **Fix:** port `fileConfig` reading
106
+ `~/Library/Application Support/apple-notes-mcp/config.json`, merged without
107
+ overriding existing env, so settings work when the host app strips the MCP `env`
108
+ block (apple-mail 2.1.1) — relevant for the default-account setting.
109
+
110
+ ### M5 — Locale-fragile date parsing (S)
111
+ `parseAppleScriptDate` (`:252-266`) feeds AppleScript `(date as text)` into
112
+ `new Date()` and falls back to `new Date()` (now) on failure — breaks under
113
+ non-en-US locales, corrupting created/modified timestamps and recency counts.
114
+ `listNotes`/search already use the locale-safe `whose modification date >=` clause
115
+ (`:1188`, `:774`); the read path doesn't. **Fix:** emit ISO-stable date components
116
+ from AppleScript (or parse explicitly) and never fall back to "now."
117
+
118
+ ### M6 — Batch ops are N+1 osascript fan-out (M)
119
+ `batchDeleteNotes` (`:2041`) and `batchMoveNotes` (`:2088`) loop per id issuing
120
+ several separate osascript spawns each. **Fix:** collapse existence/property reads
121
+ and mutations into single scripts where feasible, preserving per-item isolation
122
+ (apple-mail #31).
123
+
124
+ ### M7 — No `save-attachment` / `fetch-attachment` (M)
125
+ `list-attachments` (`:1887`) returns metadata only. **Fix:** add `save-attachment`
126
+ (AppleScript `save attachment … in (POSIX file …)`, validated path) and
127
+ `fetch-attachment` (base64), mirroring apple-mail.
128
+
129
+ ## Low
130
+
131
+ - **L1 — Pinned notes** ([#28](https://github.com/sweetrb/apple-notes-mcp/issues/28)): AppleScript `pinned` support is version-dependent; investigate, expose if feasible, else document.
132
+ - **L2 — Tags/hashtags** ([#29](https://github.com/sweetrb/apple-notes-mcp/issues/29)): `create-note`'s `tags` is cosmetic (`:608`); Notes tags are inline `#hashtags` not in the dictionary — parse from body and document; smart folders aren't scriptable.
133
+ - **L3 — Note-to-note links** ([#30](https://github.com/sweetrb/apple-notes-mcp/issues/30)): weak AppleScript support; investigate/document.
134
+ - **L5 — Integration tests** ([#31](https://github.com/sweetrb/apple-notes-mcp/issues/31)): 327 unit tests, no integration suite/CI job; add opt-in tests against a real library (gated).
135
+ - **L6 — FDA guide + audit doc** ([#32](https://github.com/sweetrb/apple-notes-mcp/issues/32)): add a Full-Disk-Access setup guide; commit this audit.
136
+
137
+ ## Recommended sequencing
138
+
139
+ 1. **Stability core** (H1–H4): port the hardened executor wholesale from apple-mail
140
+ (`maxBuffer` + `with timeout` + `SIGKILL`), control-char delimiters, stop
141
+ swallowing failures. Highest risk reduction per line; bring over apple-mail's
142
+ buffer/timeout/delimiter unit tests.
143
+ 2. **Scan safety** (H5, M6): bound and batch the stats/recent scans and the batch
144
+ mutations; add partial-coverage diagnostics.
145
+ 3. **Maturity surface** (M1, M2): `structuredContent` everywhere + `doctor` (the
146
+ Full Disk Access check is the high-value Notes-specific piece).
147
+ 4. **Config + dates** (M4, M5): small, isolated ports.
148
+ 5. **Resources/prompts** (M3) and **attachments** (M7).
149
+ 6. **Tests + docs** (L5, L6): integration suite + permissions guide.
150
+ 7. **Notes-specific** (L1, L2, L3): verify AppleScript feasibility first; document
151
+ limits where not feasible.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.5.9",
3
+ "version": "2.5.11",
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",
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "files": [
12
12
  "build",
13
+ "docs",
13
14
  "README.md",
14
15
  "LICENSE"
15
16
  ],