apple-tools-mcp 1.2.1 → 2.0.0

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
@@ -1,10 +1,12 @@
1
1
  # apple-tools-mcp
2
2
 
3
- An MCP (Model Context Protocol) server that provides semantic search across Apple Mail, Messages, Calendar, and Contacts on macOS. Use natural language to search your emails, iMessages, calendar events, and contacts from any compatible MCP client over stdio.
3
+ An MCP (Model Context Protocol) server for Apple Mail, Messages, Calendar, and Contacts on macOS. Search them with natural language, and — as of 2.0.0 — write to them: send mail and messages, manage calendar events, and manage contacts. Works with any compatible MCP client over stdio.
4
4
 
5
5
  ## Features
6
6
 
7
7
  - **Semantic Search**: Find emails, messages, and events using natural language queries
8
+ - **Write Tools (2.0.0)**: Send/reply/forward/draft mail, mark read, archive, trash; send iMessage/SMS; create, edit, remove, and RSVP to calendar events; create, edit, and remove contacts
9
+ - **Safe by Default**: Deletes and multi-recipient sends never run without `confirm`, and every write supports `dry_run`
8
10
  - **Vector Indexing**: Uses LanceDB for fast similarity search with local embeddings
9
11
  - **Privacy-First**: All processing happens locally on your Mac - no data leaves your machine
10
12
  - **Smart Deduplication**: Handles IMAP duplicates, prioritizing INBOX over Junk/Trash
@@ -65,9 +67,121 @@ The MCP server needs access to read your Mail, Messages, and Calendar databases.
65
67
 
66
68
  7. Ensure the toggle for Node.js is enabled
67
69
 
70
+ Full Disk Access covers the **read** tools. Write tools need the automation permissions below.
71
+
72
+ ### 2b. Grant Automation for write tools (first run / ship-gate)
73
+
74
+ 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
+
76
+ **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
+
78
+ #### Mini ship-gate host setup
79
+
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`:
81
+
82
+ 1. Open **System Settings → Privacy & Security → Automation**.
83
+ 2. Run write prove-out with the **indexer LaunchAgent** owning `node` (`~/.apple-tools-mcp/writer.sock` / launchd). Against the tip, with the LaunchAgent up:
84
+
85
+ ```bash
86
+ npm run smoke:writes -- --apply
87
+ ```
88
+
89
+ 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.
93
+
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.
95
+
96
+ #### Which process macOS is actually asking about
97
+
98
+ This is the part that decides whether writes work on your setup. macOS attributes an Apple event to the **responsible process**, not to whichever binary sent it. When an MCP client launches this server over stdio, the *client app* is responsible for everything node does — so the grant that matters belongs to the host app, not to node.
99
+
100
+ #### Reads and writes are different mechanisms
101
+
102
+ Worth separating, because they fail for different reasons and have different fixes. This applies to **both** Contacts and Calendar:
103
+
104
+ | | **Reads** (`contacts_search`, `contacts_lookup`, `calendar_date`, `calendar_free_time`, indexing) | **Writes** (`contacts_add/edit/remove`, `calendar_add/edit/remove/rsvp`) |
105
+ |---|---|---|
106
+ | How | sqlite query straight against `AddressBook-v22.abcddb` / `Calendar.sqlitedb` | Contacts.app (`CNContactStore`) / Calendar.app (EventKit) |
107
+ | Gated by | **Full Disk Access** on the responsible process | The **AddressBook** / **calendars** privacy classes, which require the host to hold `com.apple.security.personal-information.addressbook` / `…​.calendars` |
108
+ | Typical failure | `EPERM` / "unable to open database" | denial with **no prompt at all** |
109
+ | Fix | grant FDA to the responsible process | run the write where node is the responsible process |
110
+
111
+ So an `EPERM` reading `~/Library/Application Support/AddressBook/Sources/…` is almost always a Full Disk Access or attribution problem — **not** evidence of the entitlement gap. The server labels it that way in its logs so the two do not get conflated.
112
+
113
+ The entitlement gap bites on the **write** path: **a host app that cannot be granted Contacts or Calendars access blocks that CRUD no matter what node is allowed to do.**
114
+
115
+ Claude Desktop is the documented example. A `codesign` dump of the shipped app shows it carries **neither** personal-information entitlement — the only ones present are location and photos-library:
116
+
117
+ ```bash
118
+ codesign -d --entitlements - /Applications/Claude.app 2>/dev/null | grep personal-information
119
+ # no com.apple.security.personal-information.addressbook
120
+ # no com.apple.security.personal-information.calendars
121
+ ```
122
+
123
+ Under hardened runtime that means macOS denies AddressBook and Calendars access to anything Claude.app is responsible for, silently and without a prompt. It is a property of the host application: not a Full Disk Access problem, not a `tccutil` problem, and not something this package can patch, since one vendor cannot add entitlements to another vendor's signed app — no re-sign of Claude.app is proposed or required.
124
+
125
+ **So: Claude Desktop Contacts *and* Calendar CRUD are unsupported host limitations, not package defects.** The ship gate for both is the **Node host — Mini with Full Disk Access** (plus any Automation grants), which is exactly what the write bridge below makes available to every client.
126
+
127
+ #### The fix: let the indexer daemon own the writes
128
+
129
+ The **indexer daemon** is started by launchd, so node is the responsible process for its Apple events and macOS can grant AddressBook and Calendars access to node directly.
130
+
131
+ From 2.0.0, the daemon therefore doubles as a **write bridge**. When it is running, any MCP stdio process hands privacy-gated writes to it over a user-only unix socket at `~/.apple-tools-mcp/writer.sock` (mode 0600, inside your 0700 app directory — local only, nothing on the network). The daemon performs the write under its own TCC identity and returns what happened.
132
+
133
+ So the supported configuration for writes is: **run the indexer daemon** ([LaunchAgent setup below](#always-on-indexer-mac-mini-launchagent)) on the Mac that owns the data. Then:
134
+
135
+ | Host | Reads (incl. Contacts + Calendar) | Mail / Messages writes | Calendar CRUD | Contacts CRUD |
136
+ |------|-----------------------------------|------------------------|---------------|----------------|
137
+ | Node host: indexer daemon or `node index.js` from a terminal | Yes (FDA on node) | Yes | **Yes — ship gate host** | **Yes — ship gate host** |
138
+ | Any stdio client **with the daemon running** | Yes | Yes (via the bridge) | Yes (via the bridge) | Yes (via the bridge) |
139
+ | 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
+
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.
142
+
143
+ #### Verifying Contacts and Calendar CRUD on a Node host
144
+
145
+ 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
+
147
+ **Ship-gate procedure (Mini):** do this after the [Automation first-run](#2b-grant-automation-for-write-tools-first-run--ship-gate) above. The LaunchAgent must own `node`; an embedded agent shell, IDE terminal, or MCP host app subprocess is not the ship-gate host.
148
+
149
+ 1. **Make sure the indexer LaunchAgent is running**, so the bridge is listening:
150
+
151
+ ```bash
152
+ pgrep -fl apple-tools-indexer
153
+ ls -l ~/.apple-tools-mcp/writer.sock
154
+ ```
155
+
156
+ 2. **Check out the tip / unpack the tarball** you are gating, in a short-lived directory. The global install stays untouched.
157
+
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.
159
+
160
+ ```bash
161
+ npm run smoke:writes
162
+ ```
163
+
164
+ 4. **Prove real CRUD.** This creates a clearly-named test contact and a test event roughly a year out, edits each, and deletes both again:
165
+
166
+ ```bash
167
+ node scripts/smoke-writes.js --apply # first writable calendar
168
+ node scripts/smoke-writes.js --apply --calendar=Work # pick the calendar
169
+ node scripts/smoke-writes.js --apply --keep # leave the test items behind
170
+ ```
171
+
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.
173
+
174
+ **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
+
176
+ ```bash
177
+ node scripts/smoke-writes.js --apply --allow-local # only from Terminal.app / launchd
178
+ ```
179
+
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.
181
+
68
182
  ### 3. Configure your MCP client
69
183
 
70
- This server speaks MCP over **stdio**. Any compatible client can run it — Claude Desktop is one example, not the only one. Cursor, Grok Bot, and other stdio MCP clients work the same way: register the command below in that client's MCP settings.
184
+ This server speaks MCP over **stdio**. Any compatible client can run it — Claude Desktop is one example, not the only one. Other stdio MCP clients work the same way: register the command below in that client's MCP settings.
71
185
 
72
186
  **Command**
73
187
 
@@ -150,7 +264,11 @@ Missing `config.json` is fine — env then the 5-minute default apply.
150
264
 
151
265
  ## Always-on indexer (Mac Mini LaunchAgent)
152
266
 
153
- On Mini, run the **indexer daemon**, not a sleep-pipe wrapper around `apple-tools-mcp`. Grok Bot, Claude Desktop, and other clients still attach via short-lived stdio MCP (`npx -y apple-tools-mcp` or the global `apple-tools-mcp` bin).
267
+ 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
+
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.
270
+
271
+ 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.
154
272
 
155
273
  **Entrypoint:** `node index.js --mode=indexer`
156
274
  **Convenience bin:** `apple-tools-indexer` (same file; npm global install provides it)
@@ -161,8 +279,10 @@ LaunchAgent should invoke **node + `--mode=indexer`** on the **global** package
161
279
 
162
280
  ```bash
163
281
  which node
164
- # Apple Silicon Homebrew example: /opt/homebrew/bin/node
165
- # Intel Homebrew / usr/local example: /usr/local/bin/node
282
+ # Use this path (and the LaunchAgent plist). Examples only — not universal:
283
+ # Mini ship-gate host: /Users/petercoates/.local/node/bin/node
284
+ # Apple Silicon Homebrew: /opt/homebrew/bin/node
285
+ # Intel Homebrew / usr/local: /usr/local/bin/node
166
286
 
167
287
  npm root -g
168
288
  # Example: /opt/homebrew/lib/node_modules
@@ -264,6 +384,118 @@ Once configured, your MCP client can use these tools:
264
384
  | `rebuild_index` | Rebuild search index for one or all sources |
265
385
  | `audit_index` | Audit index health and coverage |
266
386
 
387
+ ## Write Tools (2.0.0)
388
+
389
+ Write tools change your data. They do not use the vector index, so they keep working while the indexer daemon holds the lock.
390
+
391
+ Two arguments are available on **every** write tool:
392
+
393
+ | Argument | Type | Meaning |
394
+ |----------|------|---------|
395
+ | `dry_run` | boolean | Preview only. Reports what *would* happen and changes nothing. Always wins, even together with `confirm`. |
396
+ | `confirm` | boolean | Approves an action that is otherwise blocked (deletes, multi-recipient sends). |
397
+
398
+ ### Confirm / dry-run rules
399
+
400
+ - **Deletes always need `confirm: true`** — `mail_trash`, `calendar_remove`, `contacts_remove`, and `contacts_edit` when it clears every email or phone. Without it the call returns a `CONFIRMATION REQUIRED` preview and changes nothing. There is no bulk delete tool and no silent mass delete: one id per call.
401
+ - **Multi-recipient sends always need `confirm: true`** — `mail_send` / `mail_forward` when `to` + `cc` + `bcc` total more than one address, `mail_reply` with `reply_all: true`, and `messages_send` to multiple handles or to a group chat. A single-recipient send runs on the first call.
402
+ - **`mail_draft` is exempt from the recipient rule** because a draft is never delivered.
403
+ - Responses name what happened (tool, ids, recipients, titles) and never echo message bodies — including on the error path.
404
+ - Writes never invent data. A missing or malformed `message_id`, `event_id`, `contact_id`, `chat_id`, or recipient is refused with a message saying so. Calendar times must be explicit local datetimes (`YYYY-MM-DD HH:MM`); natural language such as "next Tuesday" is rejected for writes.
405
+
406
+ ### Mail write tools
407
+
408
+ | Tool | Arguments | Confirm rule |
409
+ |------|-----------|--------------|
410
+ | `mail_send` | `to[]` (required), `cc[]`, `bcc[]`, `subject` (required), `body` (required), `body_format` (`plain` \| `html`) | `confirm` when total recipients > 1 |
411
+ | `mail_draft` | `to[]` (required), `cc[]`, `bcc[]`, `subject`, `body`, `body_format` | none (saved to Drafts, not sent) |
412
+ | `mail_reply` | `message_id` or `file_path`, `body` (required), `reply_all`, `save_as_draft` | `confirm` when `reply_all: true` |
413
+ | `mail_forward` | `message_id` or `file_path`, `to[]` (required), `body`, `save_as_draft` | `confirm` when recipients > 1 |
414
+ | `mail_mark` | `message_id` or `file_path`, `status` (`read` \| `unread`, default `read`) | none |
415
+ | `mail_archive` | `message_id` or `file_path` | none |
416
+ | `mail_trash` | `message_id` or `file_path` | **`confirm` required** |
417
+
418
+ `body_format: "html"` sets Mail's `html content` and keeps a tag-stripped plain-text alternative in `content`; the default is plain text. HTML support is whatever Mail.app offers — if a Mail version rejects the property, the message still goes out as plain text.
419
+
420
+ 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
+
422
+ ### Messages write tool
423
+
424
+ | Tool | Arguments | Confirm rule |
425
+ |------|-----------|--------------|
426
+ | `messages_send` | `to[]` or `chat_id`, `text`, `attachment_path`, `service` (`auto` \| `imessage` \| `sms`) | `confirm` for multiple handles or a group chat |
427
+
428
+ Supported identifiers:
429
+
430
+ - **`to`** — phone numbers in E.164 form (`+15551234567`) or Apple ID email addresses. These map to a Messages `participant` on the iMessage (or SMS relay) service.
431
+ - **`chat_id`** — the chat GUID of an existing conversation, for example `iMessage;-;+15551234567` (1:1) or `iMessage;+;chat123456789` (group). The GUID is checked against `~/Library/Messages/chat.db` before anything is sent, so an unknown chat id is refused rather than delivered somewhere unexpected. The same lookup counts participants, which is how group chats are detected for the confirm rule.
432
+ - **`service`** — `auto` (default) tries iMessage and falls back to the SMS relay; `imessage` and `sms` pin the service.
433
+ - **`attachment_path`** — an absolute path to a file that already exists on this Mac. Text and attachment can be sent together.
434
+
435
+ ### Calendar write tools
436
+
437
+ | Tool | Arguments | Confirm rule |
438
+ |------|-----------|--------------|
439
+ | `calendar_list_calendars` | none | none (read-only helper) |
440
+ | `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** |
443
+ | `calendar_rsvp` | `event_id` (required), `response` (`accept` \| `decline` \| `tentative`), `attendee_email` | none |
444
+
445
+ 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
+
447
+ 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
+
449
+ **Supported recurrence patterns.** Either pass structured arguments or a raw `recurrence` RRULE:
450
+
451
+ | Argument | Values |
452
+ |----------|--------|
453
+ | `frequency` | `daily`, `weekly`, `monthly`, `yearly` |
454
+ | `interval` | 1-366 (e.g. `2` with `weekly` = every other week) |
455
+ | `count` | 1-1000 occurrences (cannot be combined with `until`) |
456
+ | `until` | explicit local datetime; emitted as a UTC `UNTIL` |
457
+ | `by_day` | `MO TU WE TH FR SA SU` (weekly patterns) |
458
+ | `recurrence` | raw RRULE instead of the above, e.g. `FREQ=WEEKLY;INTERVAL=1;COUNT=10` |
459
+
460
+ `{ frequency: "weekly", interval: 2, by_day: ["MO","WE"], count: 10 }` becomes `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;COUNT=10`. Anything outside this grammar is refused.
461
+
462
+ **Alerts.** `alerts_minutes_before` takes up to 5 whole minute values (0 to 40320, i.e. four weeks) and creates display alarms. On `calendar_edit`, supplying alerts replaces the event's existing alarms; `replace_alerts: true` removes them without adding new ones.
463
+
464
+ **RSVP.** `calendar_rsvp` sets your attendee participation status on the invitation. Some macOS versions refuse to write that property from AppleScript; when that happens the tool says so explicitly (and asks you to answer in Calendar) rather than reporting a silent success.
465
+
466
+ ### Contacts write tools
467
+
468
+ | Tool | Arguments | Confirm rule |
469
+ |------|-----------|--------------|
470
+ | `contacts_add` | `first_name`, `last_name`, `organization`, `job_title`, `emails[]`, `email_label`, `phones[]`, `phone_label` | none |
471
+ | `contacts_edit` | `contact_id` (required), any of the above, `replace_emails`, `replace_phones` | **`confirm` required** when replacing with an empty list |
472
+ | `contacts_remove` | `contact_id` (required) | **`confirm` required** |
473
+
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).
475
+
476
+ ### Example write calls
477
+
478
+ ```jsonc
479
+ // Preview first - nothing is sent
480
+ { "name": "mail_send", "arguments": { "to": ["a@example.com"], "subject": "Status", "body": "All good", "dry_run": true } }
481
+
482
+ // Single recipient: sends on the first call
483
+ { "name": "mail_send", "arguments": { "to": ["a@example.com"], "subject": "Status", "body": "All good" } }
484
+
485
+ // Two recipients: blocked until confirmed
486
+ { "name": "mail_send", "arguments": { "to": ["a@example.com", "b@example.com"], "subject": "Status", "body": "All good", "confirm": true } }
487
+
488
+ // Recurring event with an alert
489
+ { "name": "calendar_add", "arguments": { "calendar_name": "Work", "title": "Standup", "start": "2026-09-21 09:00", "end": "2026-09-21 09:15", "frequency": "weekly", "by_day": ["MO","TU","WE","TH","FR"], "alerts_minutes_before": [10] } }
490
+
491
+ // Delete: preview, then confirm
492
+ { "name": "calendar_remove", "arguments": { "event_id": "EVT-UID", "confirm": true } }
493
+ ```
494
+
495
+ ### Not included
496
+
497
+ No Apple **Reminders** tools ship in this package, by design. Notes, FaceTime, and Files automation are also out of scope.
498
+
267
499
  ## Example Queries
268
500
 
269
501
  Ask your MCP client things like:
@@ -279,8 +511,9 @@ Ask your MCP client things like:
279
511
  ## Privacy & Security
280
512
 
281
513
  - **Local Processing**: All embeddings are generated locally using Xenova/Transformers
282
- - **No Cloud Services**: No data is sent to external servers
283
- - **Read-Only**: The server only reads data, never modifies your Mail/Messages/Calendar
514
+ - **No Cloud Services**: No data is sent to external servers; the write bridge is a local unix socket, never a network port
515
+ - **Reads are read-only**: Search and lookup tools never modify your data. The [write tools](#write-tools-200) are the only ones that change anything, and they are opt-in per call, with `confirm` required for deletes and multi-recipient sends
516
+ - **No credentials**: The server holds no tokens or passwords. It uses the Mail, Messages, Calendar, and Contacts apps you are already signed into
284
517
  - **Your Data**: The vector index is stored locally in your home directory
285
518
 
286
519
  ## Troubleshooting
@@ -289,6 +522,21 @@ Ask your MCP client things like:
289
522
 
290
523
  Ensure Node.js has Full Disk Access (see Installation step 2).
291
524
 
525
+ ### A write tool reports that macOS denied the automation
526
+
527
+ The message names which process macOS was actually asking about. Work through it in this order:
528
+
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.
535
+
536
+ ### A write returned "CONFIRMATION REQUIRED"
537
+
538
+ That is the safety gate, not a failure. Deletes and multi-recipient sends need `confirm: true`; the message states exactly what would have happened. Use `dry_run: true` for a preview.
539
+
292
540
  ### Empty search results
293
541
 
294
542
  1. Check that the index was built: `ls ~/.apple-tools-mcp/vector-index/`
@@ -363,6 +611,12 @@ npx vitest run --coverage --reporter=verbose
363
611
 
364
612
  # Run audit to check index health
365
613
  npm run audit
614
+
615
+ # Prove the write path on this host (dry run)
616
+ npm run smoke:writes
617
+
618
+ # Real CRUD — the extra -- is required so npm forwards --apply
619
+ npm run smoke:writes -- --apply
366
620
  ```
367
621
 
368
622
  ## Contributing
package/contacts.js CHANGED
@@ -29,27 +29,61 @@ const MAX_CONTACTS = 50000; // Maximum contacts to load
29
29
  const MAX_EMAILS_PER_CONTACT = 10; // Maximum email addresses per contact
30
30
  const MAX_PHONES_PER_CONTACT = 10; // Maximum phone numbers per contact
31
31
 
32
+ /**
33
+ * True when a failure looks like macOS refusing the file itself.
34
+ *
35
+ * Contacts *reads* go straight at the AddressBook sqlite file, so they depend
36
+ * on Full Disk Access for the process macOS holds responsible - not on the
37
+ * AddressBook entitlement that gates Contacts.app / CNContactStore *writes*.
38
+ * Reporting the difference keeps a plain FDA problem from being mistaken for
39
+ * the host entitlement gap.
40
+ */
41
+ export function isAddressBookPermissionError(message) {
42
+ const text = String(message || "").toLowerCase();
43
+ return text.includes("eperm") ||
44
+ text.includes("operation not permitted") ||
45
+ text.includes("unable to open database") ||
46
+ text.includes("authorization denied");
47
+ }
48
+
49
+ export const ADDRESSBOOK_FDA_HINT =
50
+ "Contacts reads need Full Disk Access for the process macOS holds responsible for this server " +
51
+ "(the app that launched it over stdio, or node itself when launchd starts the indexer daemon). " +
52
+ "This is a Full Disk Access / attribution issue, not the AddressBook entitlement that gates Contacts writes.";
53
+
32
54
  /**
33
55
  * Find the contacts database file (handles iCloud sync location)
34
56
  */
35
57
  function findContactsDatabase() {
58
+ let permissionDenied = false;
59
+
36
60
  // First check Sources directory for iCloud-synced contacts
37
- if (fs.existsSync(SOURCES_DIR)) {
38
- const sources = fs.readdirSync(SOURCES_DIR);
39
- for (const source of sources) {
40
- const dbPath = path.join(SOURCES_DIR, source, "AddressBook-v22.abcddb");
41
- if (fs.existsSync(dbPath)) {
42
- // Check if it has actual data (not empty)
43
- try {
44
- const result = safeSqlite3(dbPath, "SELECT COUNT(*) FROM ZABCDRECORD", { json: false, timeout: 5000 });
45
- const count = parseInt(result.trim());
46
- if (count > 0) {
47
- return dbPath;
48
- }
49
- } catch (e) {
50
- // Continue to next source
51
- }
61
+ let sources = [];
62
+ try {
63
+ if (fs.existsSync(SOURCES_DIR)) {
64
+ sources = fs.readdirSync(SOURCES_DIR);
65
+ }
66
+ } catch (e) {
67
+ if (isAddressBookPermissionError(e.message) || e.code === "EPERM" || e.code === "EACCES") {
68
+ permissionDenied = true;
69
+ }
70
+ }
71
+
72
+ for (const source of sources) {
73
+ const dbPath = path.join(SOURCES_DIR, source, "AddressBook-v22.abcddb");
74
+ if (!fs.existsSync(dbPath)) continue;
75
+ // Check if it has actual data (not empty)
76
+ try {
77
+ const result = safeSqlite3(dbPath, "SELECT COUNT(*) FROM ZABCDRECORD", { json: false, timeout: 5000 });
78
+ const count = parseInt(result.trim());
79
+ if (count > 0) {
80
+ return dbPath;
81
+ }
82
+ } catch (e) {
83
+ if (isAddressBookPermissionError(e.message)) {
84
+ permissionDenied = true;
52
85
  }
86
+ // Continue to next source
53
87
  }
54
88
  }
55
89
 
@@ -59,6 +93,10 @@ function findContactsDatabase() {
59
93
  return mainDb;
60
94
  }
61
95
 
96
+ if (permissionDenied) {
97
+ console.error(`Contacts: AddressBook database exists but could not be read. ${ADDRESSBOOK_FDA_HINT}`);
98
+ }
99
+
62
100
  return null;
63
101
  }
64
102
 
@@ -98,10 +136,13 @@ export function loadContacts() {
98
136
  }
99
137
 
100
138
  try {
101
- // Query all contacts with their basic info (with limit for memory safety)
102
- const contactQuery = `
139
+ // Query all contacts with their basic info (with limit for memory safety).
140
+ // ZUNIQUEID is the Contacts.app person id the write tools address contacts
141
+ // by; a schema without it must still load contacts for search.
142
+ const buildContactQuery = (includeUniqueId) => `
103
143
  SELECT
104
144
  Z_PK as id,
145
+ ${includeUniqueId ? "ZUNIQUEID as uniqueId," : "'' as uniqueId,"}
105
146
  ZFIRSTNAME as firstName,
106
147
  ZLASTNAME as lastName,
107
148
  ZNICKNAME as nickname,
@@ -113,7 +154,13 @@ export function loadContacts() {
113
154
  LIMIT ${MAX_CONTACTS}
114
155
  `;
115
156
 
116
- const rawContacts = safeSqlite3Json(dbPath, contactQuery, { timeout: 10000 });
157
+ let rawContacts;
158
+ try {
159
+ rawContacts = safeSqlite3Json(dbPath, buildContactQuery(true), { timeout: 10000 });
160
+ } catch (e) {
161
+ console.error(`Contacts: unique id column unavailable (${e.message}); loading without it`);
162
+ rawContacts = safeSqlite3Json(dbPath, buildContactQuery(false), { timeout: 10000 });
163
+ }
117
164
 
118
165
  if (rawContacts.length >= MAX_CONTACTS) {
119
166
  console.error(`Warning: Contact limit reached (${MAX_CONTACTS}). Some contacts may not be searchable.`);
@@ -184,6 +231,7 @@ export function loadContacts() {
184
231
  for (const c of rawContacts) {
185
232
  const contact = {
186
233
  id: c.id,
234
+ uniqueId: c.uniqueId || "",
187
235
  firstName: c.firstName || "",
188
236
  lastName: c.lastName || "",
189
237
  nickname: c.nickname || "",
@@ -244,7 +292,11 @@ export function loadContacts() {
244
292
 
245
293
  return contacts;
246
294
  } catch (e) {
247
- console.error("Error loading contacts:", e.message);
295
+ if (isAddressBookPermissionError(e.message)) {
296
+ console.error(`Error loading contacts: ${e.message}. ${ADDRESSBOOK_FDA_HINT}`);
297
+ } else {
298
+ console.error("Error loading contacts:", e.message);
299
+ }
248
300
  return [];
249
301
  }
250
302
  }
package/index.js CHANGED
@@ -22,6 +22,13 @@ import {
22
22
  waitForIndexerLock,
23
23
  beginOwnedIndexing
24
24
  } from "./lib/indexerRuntime.js";
25
+ import {
26
+ WRITE_TOOL_DEFINITIONS,
27
+ isWriteTool,
28
+ dispatchWriteTool,
29
+ executeWriteToolLocally
30
+ } from "./lib/writeTools.js";
31
+ import { startWriteBridgeServer, defaultSocketPath } from "./lib/writeBridge.js";
25
32
 
26
33
  const PACKAGE_VERSION = JSON.parse(
27
34
  fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")
@@ -70,9 +77,37 @@ function stopLockHeartbeat() {
70
77
  indexerLock.stopHeartbeat();
71
78
  }
72
79
 
80
+ // Daemon-only: serves writes for stdio clients whose host app cannot be
81
+ // granted Contacts/Calendar automation (see lib/writeBridge.js).
82
+ const WRITE_SOCKET_PATH = defaultSocketPath();
83
+ let writeBridge = null;
84
+
85
+ function stopWriteBridge() {
86
+ if (!writeBridge) return;
87
+ try {
88
+ writeBridge.close();
89
+ } catch (e) {
90
+ console.error("Error closing write bridge:", e.message);
91
+ }
92
+ writeBridge = null;
93
+ }
94
+
95
+ async function startWriteBridge() {
96
+ try {
97
+ writeBridge = await startWriteBridgeServer({
98
+ socketPath: WRITE_SOCKET_PATH,
99
+ handler: (tool, args) => executeWriteToolLocally(tool, args),
100
+ log: (msg) => console.error(msg)
101
+ });
102
+ } catch (e) {
103
+ console.error(`Write bridge unavailable: ${e.message}. Writes will run in each MCP process.`);
104
+ }
105
+ }
106
+
73
107
  function shutdownIndexing(exitCode) {
74
108
  stopBackgroundIndexing();
75
109
  stopLockHeartbeat();
110
+ stopWriteBridge();
76
111
  releaseLock();
77
112
  if (exitCode !== undefined) {
78
113
  process.exit(exitCode);
@@ -83,6 +118,7 @@ function shutdownIndexing(exitCode) {
83
118
  process.on("exit", () => {
84
119
  stopBackgroundIndexing();
85
120
  stopLockHeartbeat();
121
+ stopWriteBridge();
86
122
  releaseLock();
87
123
  });
88
124
  process.on("SIGINT", () => {
@@ -350,12 +386,27 @@ function waitForLockAndStartDaemon() {
350
386
 
351
387
  // Initialize and start indexing
352
388
  async function initializeIndexing() {
353
- isFirstEverRun = await checkIfFirstRun();
354
-
355
389
  if (INDEXER_MODE) {
356
390
  console.error(`Apple Tools MCP indexer running (v${PACKAGE_VERSION})`);
357
391
  logResolvedInterval(resolvedIndexInterval);
358
392
  loggedIndexInterval = true;
393
+ // launchd started this process, so node owns its TCC prompts. Offer the
394
+ // write bridge to stdio clients whose host app cannot get those grants.
395
+ // This happens before any index work: writes must stay available even if
396
+ // the vector index is missing, locked, or unreadable.
397
+ await startWriteBridge();
398
+ }
399
+
400
+ try {
401
+ isFirstEverRun = await checkIfFirstRun();
402
+ } catch (e) {
403
+ // A failed readiness probe must not take the daemon (or its write
404
+ // bridge) down; assume a first run and let the cycle report the details.
405
+ console.error(`Could not determine index state: ${e.message}`);
406
+ isFirstEverRun = true;
407
+ }
408
+
409
+ if (INDEXER_MODE) {
359
410
  waitForLockAndStartDaemon();
360
411
  return;
361
412
  }
@@ -382,8 +433,12 @@ async function initializeIndexing() {
382
433
  });
383
434
  }
384
435
 
385
- // Start indexing immediately on server startup
386
- initializeIndexing();
436
+ // Start indexing immediately on server startup. A startup failure is logged
437
+ // rather than rejected: an unhandled rejection would tear down the daemon,
438
+ // taking the write bridge with it.
439
+ initializeIndexing().catch((e) => {
440
+ console.error(`Indexing startup failed: ${e.message}`);
441
+ });
387
442
 
388
443
  // ============ SEMANTIC SEARCH FUNCTIONS ============
389
444
 
@@ -750,6 +805,7 @@ function formatContactsSearchResults(contacts) {
750
805
  output += `• ${c.displayName}`;
751
806
  if (c.organization) output += ` (${c.organization})`;
752
807
  output += "\n";
808
+ if (c.uniqueId) output += ` Contact ID: ${c.uniqueId}\n`;
753
809
  if (c.emails.length > 0) {
754
810
  output += ` Emails: ${c.emails.map(e => e.email).join(", ")}\n`;
755
811
  }
@@ -769,6 +825,7 @@ function formatContactLookupResult(contact) {
769
825
  let output = `Contact: ${contact.displayName}\n`;
770
826
  output += "─".repeat(40) + "\n";
771
827
 
828
+ if (contact.uniqueId) output += `Contact ID: ${contact.uniqueId}\n`;
772
829
  if (contact.organization) output += `Organization: ${contact.organization}\n`;
773
830
  if (contact.department) output += `Department: ${contact.department}\n`;
774
831
  if (contact.jobTitle) output += `Job Title: ${contact.jobTitle}\n`;
@@ -1270,6 +1327,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1270
1327
  required: ["name"],
1271
1328
  },
1272
1329
  },
1330
+
1331
+ // ============ WRITE TOOLS (2.0.0) ============
1332
+ // Mail / Messages / Calendar / Contacts writes with dry_run + confirm.
1333
+ ...WRITE_TOOL_DEFINITIONS,
1273
1334
  ],
1274
1335
  }));
1275
1336
 
@@ -1280,6 +1341,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1280
1341
  try {
1281
1342
  let result;
1282
1343
 
1344
+ // Writes never wait on the vector index: they talk to Mail / Messages /
1345
+ // Calendar / Contacts directly and must keep working while the indexer
1346
+ // daemon holds the lock.
1347
+ if (isWriteTool(name)) {
1348
+ const writeResult = await dispatchWriteTool(name, args || {}, {
1349
+ indexerMode: INDEXER_MODE,
1350
+ socketPath: WRITE_SOCKET_PATH,
1351
+ log: (msg) => console.error(msg)
1352
+ });
1353
+ return {
1354
+ content: [{ type: "text", text: writeResult.message }],
1355
+ ...(writeResult.ok === false ? { isError: true } : {})
1356
+ };
1357
+ }
1358
+
1283
1359
  switch (name) {
1284
1360
  // Smart search (agentic)
1285
1361
  case "smart_search":
package/indexer.js CHANGED
@@ -1741,7 +1741,9 @@ export function localDayToMacBounds(startMs, endMs) {
1741
1741
  }
1742
1742
 
1743
1743
  // Date-bounded OccurrenceCache query: one row per occurrence, no GROUP BY ci.ROWID
1744
- export function buildEventsOnDateQuery(startMac, endMac) {
1744
+ // includeUid adds the iCal UID that the calendar write tools address events by.
1745
+ // Older Calendar schemas may lack the column, so callers can retry without it.
1746
+ export function buildEventsOnDateQuery(startMac, endMac, { includeUid = true } = {}) {
1745
1747
  const startBound = Math.floor(Number(startMac));
1746
1748
  const endBound = Math.floor(Number(endMac));
1747
1749
  if (!Number.isFinite(startBound) || !Number.isFinite(endBound)) {
@@ -1755,6 +1757,7 @@ export function buildEventsOnDateQuery(startMac, endMac) {
1755
1757
  return `
1756
1758
  SELECT DISTINCT
1757
1759
  ci.ROWID as itemId,
1760
+ ${includeUid ? "ci.unique_identifier as uid," : "'' as uid,"}
1758
1761
  ci.summary as title,
1759
1762
  datetime(CASE WHEN ci.all_day THEN ${allDayStartMac} ELSE ${timedStart} END + 978307200, 'unixepoch', 'localtime') as start,
1760
1763
  datetime(${occEnd} + 978307200, 'unixepoch', 'localtime') as end,
@@ -1821,9 +1824,11 @@ export function getEventsOnDate(startMs, endMs) {
1821
1824
  return { events: [], error: "Calendar database not found" };
1822
1825
  }
1823
1826
  const { startMac, endMac } = localDayToMacBounds(startMs, endMs);
1824
- const query = buildEventsOnDateQuery(startMac, endMac);
1825
1827
  let lastError;
1826
1828
  for (let attempt = 0; attempt < 3; attempt++) {
1829
+ // Drop the UID column after a failed first attempt: a schema without
1830
+ // unique_identifier must still return events for calendar_date.
1831
+ const query = buildEventsOnDateQuery(startMac, endMac, { includeUid: attempt === 0 });
1827
1832
  try {
1828
1833
  const events = withCalendarCopy((dbPath) => {
1829
1834
  return safeSqlite3Json(dbPath, query, { timeout: 15000 });