apple-tools-mcp 2.0.0 → 2.0.2

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
@@ -43,6 +43,8 @@ If you installed from source, point your MCP client at the local `index.js` inst
43
43
 
44
44
  **Mac Mini** stays on a **global npm** install (`npm install -g apple-tools-mcp`) — no git clone on Mini. **MacBook / development** uses the clone above.
45
45
 
46
+ After **first install** and after **upgrade** (when write tools are present or change), run the permissions command once on the host UI before using write tools — see [step 2b](#2b-grant-automation-for-write-tools-first-run--ship-gate).
47
+
46
48
  ### 2. Grant Full Disk Access
47
49
 
48
50
  The MCP server needs access to read your Mail, Messages, and Calendar databases.
@@ -73,11 +75,44 @@ Full Disk Access covers the **read** tools. Write tools need the automation perm
73
75
 
74
76
  Write tools drive Mail, Messages, Calendar, and Contacts through AppleScript. macOS gates those Apple events behind **Automation**, not by adding `node` to the Contacts or Calendars privacy lists.
75
77
 
78
+ #### First install and upgrade: `apple-tools-mcp permissions`
79
+
80
+ Run this **after first global install** and **after upgrade** when write surfaces are present or change. It probes **`process.execPath`** (the `node` running the command) so macOS can pop **Allow** dialogs for Contacts, Calendar, Mail, and Messages in one sitting. You click **Allow**; the command cannot grant silently. Already-granted surfaces report OK without another click. Missing grants print a report and the process **exits non-zero** (fail closed).
81
+
82
+ Use the **same `node` the product uses** — not the MCP host app. Always invoke so **`process.execPath`** is that node. A bare `apple-tools-mcp` (or a path like `~/.nvm/versions/node/v22.21.1/bin/apple-tools-mcp`) can have a shebang that starts a **different** node; Allow dialogs attach to `execPath`, not the CLI path you typed.
83
+
84
+ ```bash
85
+ # Preferred: execPath matches the product node
86
+ $(which node) $(which apple-tools-mcp) permissions
87
+
88
+ # After `npm install -g apple-tools-mcp` on that node (same idea):
89
+ node "$(dirname "$(which node)")/../lib/node_modules/apple-tools-mcp/index.js" permissions
90
+ npx apple-tools-mcp permissions
91
+
92
+ # From a clone:
93
+ npm run permissions
94
+ ```
95
+
96
+ If the invoked CLI path and `process.execPath` differ, the command prints a **WARN** with both paths and tells you to re-run as `execPath …/apple-tools-mcp permissions`. The command also prints the binary it is probing. Host examples (not universal paths):
97
+
98
+ - **Mini:** `/Users/petercoates/.local/node/bin/node` (global npm / indexer LaunchAgent)
99
+ - **MacBook:** `/Users/petercoates/.nvm/versions/node/v22.21.1/bin/node` (Claude’s nvm `node`, **not** Homebrew)
100
+
101
+ **MacBook (default for `permissions`):** run from **Terminal.app**. Click **Allow** for the **printed `process.execPath`**. Confirm **System Settings → Privacy & Security → Automation** for that node → Contacts, Calendar, Mail, and Messages. Do **not** start `apple-tools-indexer` — the always-on indexer / write bridge is a **Mini** install only.
102
+
103
+ **Mini:** use the [Mini ship-gate](#mini-ship-gate-host-setup) path (LaunchAgent-owned node / `writer.sock`). Missing-grant copy mentions the write bridge only when that socket is present.
104
+
105
+ On the host UI (Mini Screen Sharing or MacBook local), open **System Settings → Privacy & Security → Automation**, then run the command. Click **Allow** for **`node`** → Contacts, Calendar, Mail, and Messages. npm `postinstall` only **prints a reminder** — it does not run the probes unattended.
106
+
107
+ This is a real Apple Events pass: Mail uses `make new outgoing message` (compose then discard; nothing is sent). Messages enumerates accounts (nothing is sent). `dry_run` of `mail_send` / `messages_send` never talks to those apps and **does not count**. Contacts creates and deletes a throwaway person in-script. Calendar lists calendars only (no leftover events).
108
+
109
+ **One-pass first-run:** Allow **`node`** to control **Mail**, **Messages**, **Contacts**, and **Calendar** in the same Automation pass. Contacts or Calendar being allowed does **not** grant Mail or Messages — they are separate Apple Events targets. A denied Mail grant hangs `mail_send` / `mail_draft` / `mail_reply` / `mail_forward` until the client disconnects; `dry_run` never talks to Mail, so that deny is invisible until a real compose.
110
+
76
111
  **Do not add `node` via the + button in System Settings → Privacy & Security → Contacts or Calendars.** On current macOS those panes often have **no Add button**, and that instruction is not the ship-gate setup — it failed on the Mini.
77
112
 
78
113
  #### Mini ship-gate host setup
79
114
 
80
- This is the first-run flow for Contacts and Calendar **writes** on the Mac Mini. Do it on the Mini UI (or Screen Sharing to Mini), with the indexer LaunchAgent owning `node`:
115
+ This is the first-run flow for Mail, Messages, Contacts, and Calendar **writes** on the Mac Mini. Do it on the Mini UI (or Screen Sharing to Mini), with the indexer LaunchAgent owning `node`:
81
116
 
82
117
  1. Open **System Settings → Privacy & Security → Automation**.
83
118
  2. Run write prove-out with the **indexer LaunchAgent** owning `node` (`~/.apple-tools-mcp/writer.sock` / launchd). Against the tip, with the LaunchAgent up:
@@ -87,11 +122,18 @@ This is the first-run flow for Contacts and Calendar **writes** on the Mac Mini.
87
122
  ```
88
123
 
89
124
  That is the ship-gate context. An embedded agent shell, IDE terminal, or MCP host app subprocess is **not** the ship-gate host unless the write bridge is up and the work executes inside launchd-owned `node`.
90
- 3. When prompts appear, click **Allow** for **`node`** to control **Contacts** and **Calendar**. Use whatever path the LaunchAgent plist / `which node` reports — `/Users/petercoates/.local/node/bin/node` is a Mini *example* only, not a universal path. Do **not** approve the MCP client / host app that launched a short-lived stdio server.
91
- 4. **Full Disk Access** on `node` is a separate grant and covers **reads** (Mail / Messages / Calendar / AddressBook databases). Contacts and Calendar **writes** need the Automation / Apple Events grants to Contacts.app and Calendar.app.
92
- 5. npm Trusted Publisher / publish tokens are unrelated to TCC. Do not confuse them with this setup.
125
+ 3. When prompts appear, click **Allow** for **`node`** to control **Mail**, **Messages**, **Contacts**, and **Calendar**. Watch the host — Allow **`node`** (the LaunchAgent binary), not any other app. Use whatever path the LaunchAgent plist / `which node` reports — `/Users/petercoates/.local/node/bin/node` is a Mini *example* only, not a universal path. Do **not** approve the MCP client / host app that launched a short-lived stdio server.
126
+ 4. **Keep Contacts, Mail, and Messages running** on the write host (leave the apps open; do not quit them). Cold `tell application "Contacts"` under launchd often fails with `-600` / “application isn't running” instead of auto-launching — that is **not** an Automation deny. The write path launches Contacts.app before CRUD, but write reliability still requires those three apps to stay running. **Calendar does not need to stay open** (Calendar writes go through EventKit).
127
+ 5. **Full Disk Access** on `node` is a separate grant and covers **reads** (Mail / Messages / Calendar / AddressBook databases). Write tools need the Automation / Apple Events grants to Mail.app (`mail_send`, `mail_draft`, `mail_reply`, `mail_forward`, and the other mail writes), Messages.app (`messages_send`), Contacts.app, and Calendar.app.
128
+ 6. npm Trusted Publisher / publish tokens are unrelated to TCC. Do not confuse them with this setup.
129
+
130
+ If you dismissed a prompt, re-open **Automation** and turn the **node → Mail** / **node → Messages** / **node → Contacts** / **node → Calendar** toggles back on. `tccutil reset AppleEvents` re-arms the Automation prompt so you can Allow **`node`** again. Do not use `tccutil reset AddressBook` / `tccutil reset Calendar`, and do not add `node` via Settings **+** into the Contacts or Calendars privacy lists — those panes often have no Add button, and that is not how this ship gate is granted.
93
131
 
94
- If you dismissed a prompt, re-open **Automation** and turn the **node → Contacts** / **node → Calendar** toggles back on. `tccutil reset AppleEvents` re-arms the Automation prompt so you can Allow **`node`** again. Do not use `tccutil reset AddressBook` / `tccutil reset Calendar`, and do not add `node` via Settings **+** into the Contacts or Calendars privacy lists — those panes often have no Add button, and that is not how this ship gate is granted.
132
+ #### `dry_run` does not prove Mail Automation
133
+
134
+ `mail_send` / `mail_draft` / `mail_reply` / `mail_forward` with `dry_run=true` return immediately and never send Apple events to Mail — dry_run never talks to Mail. A TCC deny for **node → Mail** is therefore invisible until a real compose (`make new outgoing message`). The hang is **Automation denied**, not “Mail.app could not be reached” / “app not available”.
135
+
136
+ `tell application "Mail" to get name` can succeed while compose still hangs. The smoke test’s Mail step runs that real compose (then discards the outgoing message, or on an older daemon saves a clearly named Draft) so the deny fails **setup**, not a later production `mail_send`. The same Allow-via-prompt applies to **Messages** for `messages_send`; `messages_send` with `dry_run=true` likewise never talks to Messages.app. Smoke also live-enumerates Messages accounts (nothing is sent). **`--apply` fails closed if Mail, Messages, Contacts, or Calendar Automation is missing.**
95
137
 
96
138
  #### Which process macOS is actually asking about
97
139
 
@@ -138,9 +180,9 @@ So the supported configuration for writes is: **run the indexer daemon** ([Launc
138
180
  | Any stdio client **with the daemon running** | Yes | Yes (via the bridge) | Yes (via the bridge) | Yes (via the bridge) |
139
181
  | Claude Desktop, **no daemon running** | Yes — reads are sqlite + FDA, unaffected by the entitlements | Yes, if Claude is granted Automation for Mail/Messages | **No — host limitation** (no calendars entitlement) | **No — host limitation** (no addressbook entitlement) |
140
182
 
141
- If a write is denied and no daemon is listening, the tool says so and tells you to start `apple-tools-indexer`, rather than failing with a bare AppleScript error. Contacts and Calendar denials each name their own privacy class and note that reads are unaffected.
183
+ If a write is denied and no write-bridge socket is listening, the tool uses **Terminal.app + printed `process.execPath` Automation** copy — it does **not** tell MacBook users to start `apple-tools-indexer`. On Mini, when `writer.sock` is up, missing-grant copy may mention the LaunchAgent / write bridge. Contacts and Calendar denials each name their own privacy class and note that reads are unaffected.
142
184
 
143
- #### Verifying Contacts and Calendar CRUD on a Node host
185
+ #### Verifying Mail Automation plus Contacts and Calendar CRUD on a Node host
144
186
 
145
187
  The smoke test drives writes through **the same dispatcher the MCP tools use**, so when the write bridge is up the work executes inside the indexer daemon. That matters: the thing being proven is the shipping path, not the Automation rights of whatever shell you happened to type the command into.
146
188
 
@@ -155,7 +197,7 @@ The smoke test drives writes through **the same dispatcher the MCP tools use**,
155
197
 
156
198
  2. **Check out the tip / unpack the tarball** you are gating, in a short-lived directory. The global install stays untouched.
157
199
 
158
- 3. **Dry run first.** It creates, edits, and deletes nothing — but it is not a no-op: it reads your contacts from the AddressBook database and calls `calendar_list_calendars`, which is a **live Calendar.app query and therefore a real TCC touch** that can raise an Automation prompt or be denied. Because the run changes nothing, a refused listing is reported as `WARN` rather than failing the run.
200
+ 3. **Dry run first.** It creates, edits, and deletes nothing on Contacts/Calendar — but it is not a no-op: it reads your contacts from the AddressBook database, calls `calendar_list_calendars` (a **live Calendar.app query and therefore a real TCC touch**), runs a **live Mail compose** (`make new outgoing message`) because `mail_send` `dry_run` never touches Mail, and live-enumerates **Messages** accounts because `messages_send` `dry_run` never touches Messages. A refused Calendar listing, Mail compose, or Messages lookup is `WARN` on a dry run. `--apply` fails closed if **Mail, Messages, Contacts, or Calendar** Automation is still denied.
159
201
 
160
202
  ```bash
161
203
  npm run smoke:writes
@@ -169,7 +211,7 @@ The smoke test drives writes through **the same dispatcher the MCP tools use**,
169
211
  node scripts/smoke-writes.js --apply --keep # leave the test items behind
170
212
  ```
171
213
 
172
- The header prints the write path it chose (`indexer daemon via write bridge` or `in this process`), and the read path (sqlite + FDA) is reported separately from the two write paths (Contacts.app, Calendar.app), so a failure tells you which mechanism refused.
214
+ The header prints the write path it chose (`indexer daemon via write bridge` or `in this process`), and the read path (sqlite + FDA) is reported separately from the write paths (Mail.app compose, Contacts.app, Calendar.app), so a failure tells you which mechanism refused. A Mail **compose-probe** hang is **TCC / Automation denied**, not “app not available”. A `mail_send` / `mail_reply` / `mail_forward` hang after `send` is a **timeout** (`ETIMEDOUT` / `-1712`) unless Mail reports `-1743` / `-10004`; check Sent before retrying.
173
215
 
174
216
  **The parent process matters.** With `--apply` and **no bridge listening**, the smoke test **refuses to run** rather than executing in-process and calling the result a package failure. Running it from an embedded agent shell, an IDE terminal, or a host app's subprocess is *not* the ship-gate context on its own, because macOS attributes the Apple events to that parent. Either start the LaunchAgent (preferred, and what production clients use), or run it from **Terminal.app**, where node is the responsible process, and pass `--allow-local` to acknowledge that:
175
217
 
@@ -177,7 +219,7 @@ The header prints the write path it chose (`indexer daemon via write bridge` or
177
219
  node scripts/smoke-writes.js --apply --allow-local # only from Terminal.app / launchd
178
220
  ```
179
221
 
180
- Expected results: **PASS on the Node host with the bridge up — this is the ship gate for Contacts and Calendar writes.** On Claude Desktop with no daemon running, Contacts and Calendar CRUD are both expected to fail; that is the documented host limitation above, not a regression.
222
+ Expected results: **PASS on the Node host with the bridge up — this is the ship gate for Mail, Messages, Contacts, and Calendar writes.** `--apply` fails closed if any of those four Automation grants is missing. On Claude Desktop with no daemon running, Contacts and Calendar CRUD are both expected to fail; that is the documented host limitation above, not a regression. Mail and Messages still need their own **node → Mail** / **node → Messages** grants; a Contacts/Calendar grant does not cover them.
181
223
 
182
224
  ### 3. Configure your MCP client
183
225
 
@@ -266,7 +308,11 @@ Missing `config.json` is fine — env then the 5-minute default apply.
266
308
 
267
309
  On Mini, run the **indexer daemon**, not a sleep-pipe wrapper around `apple-tools-mcp`. Claude Desktop and other clients still attach via short-lived stdio MCP (`npx -y apple-tools-mcp` or the global `apple-tools-mcp` bin).
268
310
 
269
- The daemon does two jobs: it refreshes the vector index, and it serves the **write bridge** at `~/.apple-tools-mcp/writer.sock` so stdio clients can perform Contacts/Calendar writes that their host app cannot be granted (see [step 2b](#2b-grant-automation-for-write-tools-first-run--ship-gate)). When macOS prompts, Allow **`node`** (the LaunchAgent binary) to control Contacts.app and Calendar.app. Do not approve the MCP client / host app that launched a short-lived stdio server, and do not try to add `node` via **+** in the Contacts or Calendars privacy lists.
311
+ The daemon does two jobs: it refreshes the vector index, and it serves the **write bridge** at `~/.apple-tools-mcp/writer.sock` so stdio clients can perform Mail / Messages / Contacts / Calendar writes that their host app cannot be granted (see [step 2b](#2b-grant-automation-for-write-tools-first-run--ship-gate)). When macOS prompts, Allow **`node`** (the LaunchAgent binary) to control Mail.app, Messages.app, Contacts.app, and Calendar.app. Do not approve the MCP client / host app that launched a short-lived stdio server, and do not try to add `node` via **+** in the Contacts or Calendars privacy lists.
312
+
313
+ **Keep Contacts.app, Mail.app, and Messages.app running all the time** on the Mini (and on any MacBook that does local writes). Quitting them makes Apple Events to those apps unreliable (`-600` / “application isn't running”), which is a cold-launch miss — not a missing Automation grant. The Contacts write path launches Contacts.app before add/edit/remove, but do not rely on that instead of leaving the apps open. **Calendar.app does not need to stay open**; Calendar writes use EventKit.
314
+
315
+ The indexer does **not** post product Notification Center / `display notification` / UserNotifications toasts. `Apple Tools MCP indexer running` is a **stderr** line only (the plist redirects it to a file). That does **not** silence macOS Background Items or “running in the background” notices for `node` / the LaunchAgent — those are OS notices, and this package does not suppress them. It also does not suppress OS Allow dialogs. The [permissions command](#2b-grant-automation-for-write-tools-first-run--ship-gate) still needs those dialogs.
270
316
 
271
317
  The bridge is created before the daemon touches the vector index, so writes stay available even when the index is missing, locked, or mid-rebuild. Confirm it after an upgrade with `ls -l ~/.apple-tools-mcp/writer.sock` (it should be a `srw-------` socket); the daemon removes it on shutdown.
272
318
 
@@ -419,6 +465,8 @@ Two arguments are available on **every** write tool:
419
465
 
420
466
  Emails are addressed by their RFC822 **Message-ID**. Pass `message_id`, or pass the `file_path` from `mail_search` / `mail_recent` and the server reads the Message-ID out of the `.emlx` headers for you. `mail_archive` moves the message to its account's Archive (or All Mail) mailbox; `mail_trash` moves it to that account's Trash.
421
467
 
468
+ **Send timeout vs TCC — check Sent before retrying.** `mail_send` / `mail_reply` / `mail_forward` can hang after Mail has already put the message in Sent. That hang is a **timeout** (`ETIMEDOUT` / `-1712` / AppleEvent timed out), not a TCC deny. The same hang-vs-TCC split applies to **find / reply / open before send** — an Allowed `ETIMEDOUT` there is never `MAIL_TCC_GUIDANCE`. Real Mail Automation denials report **`-1743`**, **`-10004`**, or “not authorized to send Apple events”. After a send hang the tool looks in Sent (and Outbox) and returns **success** if the message is there — never label a delivered send as TCC fail or timeout. Replies match `In-Reply-To` or an exact recent `Re:` + original subject (the fallback compose does not set reply headers). Forwards match the intended recipient plus an exact `Fwd:` subject or the original Message-ID in a forwarded body — not `In-Reply-To`, and not an unrelated `Fwd:` that only shares a To. Neither scan treats the original itself as this send. If Sent-verify misses, the error tells you it was a hang and to check Sent. **Clients must Sent-check before retrying a timed-out send**; retrying a message that already landed sends a second copy. This is not a silent TCC grant.
469
+
422
470
  ### Messages write tool
423
471
 
424
472
  | Tool | Arguments | Confirm rule |
@@ -438,12 +486,20 @@ Supported identifiers:
438
486
  |------|-----------|--------------|
439
487
  | `calendar_list_calendars` | none | none (read-only helper) |
440
488
  | `calendar_add` | `calendar_name` (required), `title` (required), `start` (required), `end`, `all_day`, `location`, `notes`, recurrence args, `alerts_minutes_before[]` | none |
441
- | `calendar_edit` | `event_id` (required) plus any of `title`, `start`, `end`, `location`, `notes`, recurrence args, `alerts_minutes_before[]`, `replace_alerts` | none |
442
- | `calendar_remove` | `event_id` (required) | **`confirm` required** |
489
+ | `calendar_edit` | `event_id` (required), optional `eventkit_id` from add, plus any of `title`, `start`, `end`, `location`, `notes`, recurrence args, `alerts_minutes_before[]`, `replace_alerts` | none |
490
+ | `calendar_remove` | `event_id` (required), optional `eventkit_id` from add, optional `calendar_name` | **`confirm` required** |
443
491
  | `calendar_rsvp` | `event_id` (required), `response` (`accept` \| `decline` \| `tentative`), `attendee_email` | none |
444
492
 
445
493
  Events are addressed by their **iCalendar UID**, reported as `Event ID` by `calendar_date` and returned by `calendar_add`. Run `calendar_list_calendars` first so new events land on the intended calendar instead of the default one.
446
494
 
495
+ `calendar_add`, `calendar_edit`, and `calendar_remove` share the same write-bridge RPC (`{ tool, args }` into launchd-owned node). They do not use a different socket or calendar account. Calendar.app’s dictionary has **`delete` only** — there is no `remove` or `move to trash`.
496
+
497
+ Non-recurring `calendar_add` **must** create through **EventKit** and print `via: EventKit` plus `eventkit_id`. Mini `cd74071`: EventKit had `eventKitCalendars=1` but `default=[id NSTaggedPointerString]` — JXA `String(title)` prints the ObjC class, not the calendar name, so the title match missed the only writable calendar. Titles and `calendarIdentifier` are `ObjC.unwrap`d; a single writable calendar or `defaultCalendarForNewEvents` is used when the name does not match. There is **no AppleScript fallback** for non-recurring add.
498
+
499
+ `calendar_edit` and `calendar_remove` take `eventkit_id` from add (`calendarUUID:eventUUID` on Mini). writeOnly EventKit (`status=4`) can create and read ids off the saved `EKEvent`, but **cannot re-query** via `eventWithIdentifier` — Mini `2cdf44d` failed add on that post-save lookup. Add therefore returns ids from the in-memory event. The indexer write-bridge keeps a long-lived EventKit osascript session that caches the `EKEvent` so edit/remove call `saveEvent` / `removeEvent` without a fetch. When `eventkit_id` is present and the session misses, Calendar.app uid lookup is skipped so iCloud cannot hang. `ETIMEDOUT` / `-1712` is **`timeout`**, never TCC. Failures print `osascript kind=… error=… codes=…`. `--apply` fails closed unless add printed `via: EventKit` and `eventkit_id`.
500
+
501
+ `--apply` prefers an **On My Mac** calendar when EventKit lists one. `--calendar=` still wins. The Mini host’s first writable calendar named **Calendar** (no On My Mac / iCloud label) is fine if EventKit create actually runs.
502
+
447
503
  Like Contacts, calendar writes go through Calendar.app rather than writing `Calendar.sqlitedb` directly. Reads query that database for speed, but edits must go through the app so iCloud sync, invitations, and alarms behave correctly.
448
504
 
449
505
  **Supported recurrence patterns.** Either pass structured arguments or a raw `recurrence` RRULE:
@@ -471,7 +527,7 @@ Like Contacts, calendar writes go through Calendar.app rather than writing `Cale
471
527
  | `contacts_edit` | `contact_id` (required), any of the above, `replace_emails`, `replace_phones` | **`confirm` required** when replacing with an empty list |
472
528
  | `contacts_remove` | `contact_id` (required) | **`confirm` required** |
473
529
 
474
- Contacts are addressed by their Contacts.app person id (for example `ABCD1234-...:ABPerson`), reported as `Contact ID` by `contacts_search` and `contacts_lookup` and returned by `contacts_add`. At least one of `first_name`, `last_name`, or `organization` is required to create a contact. Writes go through Contacts.app, never by writing the AddressBook database directly (that breaks iCloud sync).
530
+ Contacts are addressed by their Contacts.app person id (for example `ABCD1234-...:ABPerson`), reported as `Contact ID` by `contacts_search` and `contacts_lookup` and returned by `contacts_add`. At least one of `first_name`, `last_name`, or `organization` is required to create a contact. Writes go through Contacts.app, never by writing the AddressBook database directly (that breaks iCloud sync). Keep **Contacts, Mail, and Messages** running for write reliability; the Contacts write path launches Contacts.app before CRUD if it was quit. Calendar does not need to stay open (EventKit).
475
531
 
476
532
  ### Example write calls
477
533
 
@@ -526,12 +582,14 @@ Ensure Node.js has Full Disk Access (see Installation step 2).
526
582
 
527
583
  The message names which process macOS was actually asking about. Work through it in this order:
528
584
 
529
- 1. **Is the indexer daemon running?** `pgrep -fl apple-tools-indexer`. If not, start it — the daemon is the supported host for Contacts and Calendar writes (see [step 2b](#2b-grant-automation-for-write-tools-first-run--ship-gate)).
530
- 2. **Did you approve the Automation prompts for `node`?** Open System Settings → Privacy & Security → **Automation** and confirm **node** (the LaunchAgent binary — whatever path the plist / `which node` reports; `/Users/petercoates/.local/node/bin/node` is a Mini *example* only) is allowed to control Contacts and Calendar. Do **not** approve the MCP client / host app that launched a short-lived stdio server, and do **not** try to add `node` via **+** in the Contacts or Calendars privacy lists — those panes often have no Add button. `tccutil reset AppleEvents` re-arms the Automation prompt so you can Allow **`node`** again. Full Disk Access is a separate read grant; npm Trusted Publisher / tokens are unrelated.
531
- 3. **Is the daemon's node binary the one with Full Disk Access?** LaunchAgents do not inherit your shell `PATH`; confirm the plist points at the same path `which node` reports.
532
- 4. **Is it actually the write path?** Contacts or Calendar *reads* failing with `EPERM` is a Full Disk Access / attribution problem, not the entitlement gap — fix FDA for the responsible process. Contacts or Calendar *CRUD* failing with no prompt under Claude Desktop is the [documented host limitation](#reads-and-writes-are-different-mechanisms): Claude.app carries neither the addressbook nor the calendars entitlement. Start the daemon and the write succeeds through the bridge.
533
- 5. **"Contacts.app / Calendar.app could not be reached"** with the app clearly installed is an **Automation / responsible-process** failure, not a missing app — macOS reports a refused Apple event as `-1728` / "can't get application". The tool says so and points at the bridge. Start the LaunchAgent, or run from Terminal.app and approve the Automation prompt.
534
- 6. **Prove the host itself works** with `node scripts/smoke-writes.js --apply` on the Node host with the LaunchAgent running; it routes through the bridge and separates the read and write mechanisms for you.
585
+ 1. **MacBook / Terminal (`permissions` CLI):** there is **no** always-on indexer. Re-run `apple-tools-mcp permissions` from **Terminal.app**, Allow the **printed `process.execPath`**, and check System Settings → Privacy & Security → **Automation** for that node → Contacts, Calendar, Mail, and Messages. Do **not** start `apple-tools-indexer`.
586
+ 2. **Mini only:** `pgrep -fl apple-tools-indexer` / `~/.apple-tools-mcp/writer.sock`. If the LaunchAgent is the product host, start it so writes run under launchd-owned node (see [Mini ship-gate](#mini-ship-gate-host-setup)).
587
+ 3. **Did you approve the Automation prompts for `node`?** Re-run `apple-tools-mcp permissions` with the product `node` (it prints `process.execPath`). Open System Settings → Privacy & Security → **Automation** and confirm **that printed node** is allowed to control **Mail**, **Messages**, Contacts, and Calendar. On Mini the LaunchAgent binary is whatever the plist / `which node` reports (`/Users/petercoates/.local/node/bin/node` is a Mini *example* only). MacBook Claude nvm is `/Users/petercoates/.nvm/versions/node/v22.21.1/bin/node`, not Homebrew. Watch the host — Allow **`node`**, not any other app. Contacts or Calendar being allowed does **not** grant Mail. A `mail_send` hang is **Automation denied**, not “Mail.app could not be reached”; `dry_run` never talks to Mail so it cannot detect this. Do **not** approve the MCP client / host app that launched a short-lived stdio server, and do **not** try to add `node` via **+** in the Contacts or Calendars privacy lists — those panes often have no Add button. `tccutil reset AppleEvents` re-arms the Automation prompt so you can Allow **`node`** again. Full Disk Access is a separate read grant; npm Trusted Publisher / tokens are unrelated.
588
+ 4. **Mini: is the daemon's node binary the one with Full Disk Access?** LaunchAgents do not inherit your shell `PATH`; confirm the plist points at the same path `which node` reports.
589
+ 5. **Is it actually the write path?** Contacts or Calendar *reads* failing with `EPERM` is a Full Disk Access / attribution problem, not the entitlement gap — fix FDA for the responsible process. Contacts or Calendar *CRUD* failing with no prompt under Claude Desktop is the [documented host limitation](#reads-and-writes-are-different-mechanisms): Claude.app carries neither the addressbook nor the calendars entitlement. On Mini, start the daemon so the write goes through the bridge. On MacBook, use Terminal.app + the printed node.
590
+ 6. **"Contacts.app / Calendar.app could not be reached"** with the app clearly installed is an **Automation / responsible-process** failure, not a missing app — macOS reports a refused Apple event as `-1728` / "can't get application". On MacBook / Terminal, Allow the printed `process.execPath`. On Mini, the write bridge / LaunchAgent may apply. A Contacts `-600` / “application isn't running” is a **cold launch**, not Automation — keep Contacts, Mail, and Messages running; Calendar does not need to stay open.
591
+ 7. **`calendar_remove` FAIL after add/edit PASS** is a delete-path failure, not missing Calendar Automation. Same write-bridge RPC. Read the `osascript kind=/error=` line: `kind=timeout` + `ETIMEDOUT` / `-1712` is an iCloud/CalDAV hang or Calendar confirmation dialog — **not** TCC. `EVENTKIT_NOT_FOUND status=4` is writeOnly EventKit failing to see an AppleScript-created event (add must go through EventKit). `-1743` / `-10004` is a real Automation deny. Paste that line if it still FAILs.
592
+ 8. **Prove the Mini host itself works** with `node scripts/smoke-writes.js --apply` on the Node host with the LaunchAgent running; it routes through the bridge and separates the read and write mechanisms for you.
535
593
 
536
594
  ### A write returned "CONFIRMATION REQUIRED"
537
595
 
package/index.js CHANGED
@@ -10,7 +10,8 @@ import fs from "fs";
10
10
  import path from "path";
11
11
  import { validateEmailPath, stripHtmlTags, unfoldRfc822Headers, validateLimit, validateDaysBack, validateWeekOffset, toUnixMillis } from "./lib/validators.js";
12
12
  import { cycleEndFlags, indexUnavailableMessage, indexQueryGate } from "./lib/indexGate.js";
13
- import { isIndexerMode } from "./lib/processMode.js";
13
+ import { isIndexerMode, isPermissionsMode } from "./lib/processMode.js";
14
+ import { runPermissionsCommand } from "./lib/permissions.js";
14
15
  import { loadResolvedIndexInterval, logResolvedInterval } from "./lib/config.js";
15
16
  import { createIndexerLock, DEFAULT_LOCK_HEARTBEAT_MS } from "./lib/indexerLock.js";
16
17
  import {
@@ -29,12 +30,16 @@ import {
29
30
  executeWriteToolLocally
30
31
  } from "./lib/writeTools.js";
31
32
  import { startWriteBridgeServer, defaultSocketPath } from "./lib/writeBridge.js";
33
+ import { closeEventKitSession, ensureEventKitSession } from "./lib/eventKitSession.js";
34
+ import { EVENTKIT_JXA_HELPERS } from "./lib/calendarWrite.js";
32
35
 
33
36
  const PACKAGE_VERSION = JSON.parse(
34
37
  fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")
35
38
  ).version;
36
39
 
37
40
  // Canonical indexer entrypoint: `node index.js --mode=indexer` or `apple-tools-indexer`.
41
+ // Permissions CLI: `apple-tools-mcp permissions` — short-lived, no MCP / indexer.
42
+ const PERMISSIONS_MODE = isPermissionsMode();
38
43
  const INDEXER_MODE = isIndexerMode();
39
44
  const resolvedIndexInterval = loadResolvedIndexInterval();
40
45
  const INDEX_INTERVAL = resolvedIndexInterval.ms;
@@ -83,6 +88,7 @@ const WRITE_SOCKET_PATH = defaultSocketPath();
83
88
  let writeBridge = null;
84
89
 
85
90
  function stopWriteBridge() {
91
+ closeEventKitSession();
86
92
  if (!writeBridge) return;
87
93
  try {
88
94
  writeBridge.close();
@@ -99,6 +105,7 @@ async function startWriteBridge() {
99
105
  handler: (tool, args) => executeWriteToolLocally(tool, args),
100
106
  log: (msg) => console.error(msg)
101
107
  });
108
+ ensureEventKitSession({ helpers: EVENTKIT_JXA_HELPERS });
102
109
  } catch (e) {
103
110
  console.error(`Write bridge unavailable: ${e.message}. Writes will run in each MCP process.`);
104
111
  }
@@ -147,7 +154,7 @@ process.on("unhandledRejection", (reason, promise) => {
147
154
 
148
155
  // MCP stdio clients exit when the host closes stdin. The indexer daemon must
149
156
  // not — LaunchAgent / KeepAlive often attaches stdin to /dev/null.
150
- bindStdinCloseExit(process.stdin, INDEXER_MODE, () => {
157
+ bindStdinCloseExit(process.stdin, INDEXER_MODE || PERMISSIONS_MODE, () => {
151
158
  console.error("Client disconnected. Exiting.");
152
159
  shutdownIndexing(0);
153
160
  });
@@ -314,6 +321,7 @@ function startBackgroundIndexing() {
314
321
 
315
322
  // Stop background indexing and clean up timers
316
323
  function stopBackgroundIndexing() {
324
+ const wasRunning = Boolean(indexTimer || progressCheckTimer);
317
325
  if (indexTimer) {
318
326
  clearInterval(indexTimer);
319
327
  indexTimer = null;
@@ -322,7 +330,9 @@ function stopBackgroundIndexing() {
322
330
  clearInterval(progressCheckTimer);
323
331
  progressCheckTimer = null;
324
332
  }
325
- console.error("Background indexing stopped");
333
+ if (wasRunning) {
334
+ console.error("Background indexing stopped");
335
+ }
326
336
  }
327
337
 
328
338
  // Unblock searches after a cycle ends. Must run on failure as well as success
@@ -436,9 +446,22 @@ async function initializeIndexing() {
436
446
  // Start indexing immediately on server startup. A startup failure is logged
437
447
  // rather than rejected: an unhandled rejection would tear down the daemon,
438
448
  // taking the write bridge with it.
439
- initializeIndexing().catch((e) => {
440
- console.error(`Indexing startup failed: ${e.message}`);
441
- });
449
+ if (PERMISSIONS_MODE) {
450
+ runPermissionsCommand({
451
+ execPath: process.execPath,
452
+ argv: process.argv,
453
+ version: PACKAGE_VERSION
454
+ }).then((code) => {
455
+ process.exit(code);
456
+ }).catch((e) => {
457
+ console.error(`Permissions command error: ${e.message}`);
458
+ process.exit(1);
459
+ });
460
+ } else {
461
+ initializeIndexing().catch((e) => {
462
+ console.error(`Indexing startup failed: ${e.message}`);
463
+ });
464
+ }
442
465
 
443
466
  // ============ SEMANTIC SEARCH FUNCTIONS ============
444
467
 
@@ -1580,6 +1603,6 @@ async function main() {
1580
1603
  // fallback on this stdio process only if indexer.lock is free.
1581
1604
  }
1582
1605
 
1583
- if (shouldConnectMcpStdio(INDEXER_MODE)) {
1606
+ if (!PERMISSIONS_MODE && shouldConnectMcpStdio(INDEXER_MODE)) {
1584
1607
  main().catch(console.error);
1585
1608
  }
package/indexer.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import * as lancedb from "@lancedb/lancedb";
4
- import { pipeline } from "@xenova/transformers";
5
4
  import {
6
5
  validateEmailPath,
7
6
  validateMailboxName,
@@ -86,6 +85,10 @@ let db = null;
86
85
  async function getEmbedder() {
87
86
  if (!embeddingPipeline) {
88
87
  console.error("Loading embedding model (first time may take a minute)...");
88
+ // Lazy import: @xenova/transformers loads sharp at import time. A static
89
+ // import crashes Linux (and any host without the platform sharp native)
90
+ // before search.js formatters / pronoun helpers can load.
91
+ const { pipeline } = await import("@xenova/transformers");
89
92
  embeddingPipeline = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2");
90
93
  console.error("Embedding model loaded.");
91
94
  }