apple-tools-mcp 1.2.0 → 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 +261 -7
- package/contacts.js +71 -19
- package/index.js +133 -87
- package/indexer.js +33 -23
- package/lib/appleScript.js +292 -0
- package/lib/audit.js +22 -14
- package/lib/calendarWrite.js +594 -0
- package/lib/contactsWrite.js +300 -0
- package/lib/indexGate.js +49 -0
- package/lib/lancedbTables.js +307 -0
- package/lib/mailWrite.js +464 -0
- package/lib/messagesWrite.js +281 -0
- package/lib/writeBridge.js +214 -0
- package/lib/writeGuards.js +250 -0
- package/lib/writeRouting.js +69 -0
- package/lib/writeTools.js +396 -0
- package/package.json +4 -2
- package/scripts/smoke-writes.js +313 -0
- package/search.js +7 -18
package/README.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# apple-tools-mcp
|
|
2
2
|
|
|
3
|
-
An MCP (Model Context Protocol) server
|
|
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.
|
|
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`.
|
|
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
|
-
#
|
|
165
|
-
#
|
|
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
|
-
- **
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|