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 +78 -20
- package/index.js +30 -7
- package/indexer.js +4 -1
- package/lib/appleScript.js +209 -21
- package/lib/calendarWrite.js +835 -23
- package/lib/contactsWrite.js +192 -5
- package/lib/eventKitSession.js +369 -0
- package/lib/mailWrite.js +332 -26
- package/lib/messagesWrite.js +39 -3
- package/lib/permissions.js +360 -0
- package/lib/processMode.js +47 -0
- package/lib/shell.js +50 -5
- package/lib/writeGuards.js +8 -0
- package/lib/writeRouting.js +76 -6
- package/lib/writeTools.js +26 -8
- package/package.json +4 -1
- package/scripts/postinstall.js +32 -0
- package/scripts/smoke-writes.js +188 -17
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 **
|
|
91
|
-
4. **
|
|
92
|
-
5.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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. **
|
|
530
|
-
2. **
|
|
531
|
-
3. **
|
|
532
|
-
4. **
|
|
533
|
-
5. **
|
|
534
|
-
6. **
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
}
|