apple-tools-mcp 3.0.1 → 3.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.
Files changed (3) hide show
  1. package/README.md +209 -566
  2. package/lib/mailWrite.js +14 -4
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,26 +1,16 @@
1
1
  # apple-tools-mcp
2
2
 
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
-
5
- ## Features
6
-
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`
10
- - **Vector Indexing**: Uses LanceDB for fast similarity search with local embeddings
11
- - **Privacy-First**: All processing happens locally on your Mac - no data leaves your machine
12
- - **Smart Deduplication**: Handles IMAP duplicates, prioritizing INBOX over Junk/Trash
13
- - **Date Intelligence**: Understands queries like "last week", "yesterday", "March 2024"
3
+ An MCP (Model Context Protocol) server for Apple Mail, Messages, Calendar, and Contacts on macOS. Search them in plain language, send mail and messages, and create, edit, or remove calendar events and contacts. It talks to any MCP client over stdio. Search embeddings and the write bridge stay on your Mac.
14
4
 
15
5
  ## Requirements
16
6
 
17
- - **macOS** (Ventura 13.0 or later recommended)
18
- - **Node.js** 18.0 or later
19
- - **Full Disk Access** permission for the Node.js binary
7
+ - macOS
8
+ - Node.js 18 or later
9
+ - The permissions below, on the Mac that holds the Mail, Messages, Calendar, and Contacts data
20
10
 
21
- ## Installation
11
+ ## Setup
22
12
 
23
- ### 1. Install the package
13
+ ### 1. Install
24
14
 
25
15
  ```bash
26
16
  npm install -g apple-tools-mcp
@@ -34,205 +24,67 @@ cd Apple-Tools-MCP
34
24
  npm install
35
25
  ```
36
26
 
37
- If you installed from source, point your MCP client at the local `index.js` instead of `npx` in step 3:
38
-
39
- ```json
40
- "command": "node",
41
- "args": ["/absolute/path/to/Apple-Tools-MCP/index.js"]
42
- ```
43
-
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
-
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
-
48
- ### 2. Grant Full Disk Access
49
-
50
- The MCP server needs access to read your Mail, Messages, and Calendar databases.
51
-
52
- 1. First, find your Node.js path by running in Terminal:
53
-
54
- ```bash
55
- which node
56
- ```
57
-
58
- This will output something like `/opt/homebrew/bin/node` or `/usr/local/bin/node`
27
+ A global install uses `npx` in the client config below. A clone uses `node` and the full path to `index.js`.
59
28
 
60
- 2. Open **System Settings** → **Privacy & Security** → **Full Disk Access**
29
+ After you install or upgrade, run the permissions command in the next section. `npm install` only prints a reminder. It cannot click Allow for you.
61
30
 
62
- 3. Click the **+** button
31
+ ### 2. Permissions
63
32
 
64
- 4. Press **Cmd+Shift+G** to open the "Go to Folder" dialog
33
+ Two grants, and they do different jobs.
65
34
 
66
- 5. Paste the path from step 1 (e.g., `/opt/homebrew/bin/node`) and press Enter
35
+ **Full Disk Access** lets the server read the Mail, Messages, Calendar, and Contacts databases.
67
36
 
68
- 6. Select the `node` file and click **Open**
37
+ **Automation** lets it send mail, send messages, and change calendar events and contacts through those apps. Mail compose also needs **Accessibility**, because the message body is pasted into Mail's window through System Events.
69
38
 
70
- 7. Ensure the toggle for Node.js is enabled
39
+ #### Full Disk Access
71
40
 
72
- Full Disk Access covers the **read** tools. Write tools need the automation permissions below.
41
+ 1. In Terminal, run `which node` and copy the path it prints.
42
+ 2. Open **System Settings → Privacy & Security → Full Disk Access**.
43
+ 3. Click **+**.
44
+ 4. Press **Cmd+Shift+G**, paste the path, and press Enter.
45
+ 5. Select `node` and turn its toggle on.
73
46
 
74
- ### 2b. Grant Automation for write tools (first run / ship-gate)
47
+ #### Automation, Accessibility, and System Events
75
48
 
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.
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, Messages, and System Events 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.
49
+ Run this from **Terminal.app**, so the prompts attach to node and not to your editor or chat app:
83
50
 
84
51
  ```bash
85
- # Preferred: execPath matches the product node
86
52
  $(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
53
  ```
95
54
 
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, Messages, and System Events. 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, Messages, and System Events. 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). System Events is probed with **process / GUI scripting** (`tell process` + `unix id` of System Events; nothing is typed) so the keystroke compose path can pop **Allow** and verify a real Automation grant. A soft `tell application "System Events" to get name` is **not** enough — that can succeed while TCC still has node → System Events **denied**. If a prior **Don't Allow** left a sticky Deny, `permissions` reports System Events **missing** and exits non-zero: turn **System Events** on for that node in System Settings → Privacy & Security → **Automation**, then **re-run `apple-tools-mcp permissions` after Allow**.
108
-
109
- **One-pass first-run:** Allow **`node`** to control **Mail**, **Messages**, **Contacts**, **Calendar**, and **System Events** in the same Automation pass. Contacts or Calendar being allowed does **not** grant Mail, Messages, or System Events — 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
-
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.
55
+ From a clone, `npm run permissions` does the same thing. `npx apple-tools-mcp permissions` also works.
112
56
 
113
- #### Mini ship-gate host setup
114
-
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`:
116
-
117
- 1. Open **System Settings → Privacy & Security → Automation**.
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:
119
-
120
- ```bash
121
- npm run smoke:writes -- --apply
122
- ```
123
-
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`.
125
- 3. When prompts appear, click **Allow** for **`node`** to control **Mail**, **Messages**, **Contacts**, **Calendar**, and **System Events** (needed for `mail_send` / `mail_draft` keystrokes under the LaunchAgent). 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.
131
-
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.**
137
-
138
- #### Which process macOS is actually asking about
139
-
140
- 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.
141
-
142
- #### Reads and writes are different mechanisms
143
-
144
- Worth separating, because they fail for different reasons and have different fixes. This applies to **both** Contacts and Calendar:
145
-
146
- | | **Reads** (`contacts_search`, `contacts_lookup`, `calendar_date`, `calendar_free_time`, indexing) | **Writes** (`contacts_add/edit/remove`, `calendar_add/edit/remove/rsvp`) |
147
- |---|---|---|
148
- | How | sqlite query straight against `AddressBook-v22.abcddb` / `Calendar.sqlitedb` | Contacts.app (`CNContactStore`) / Calendar.app (EventKit) |
149
- | 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` |
150
- | Typical failure | `EPERM` / "unable to open database" | denial with **no prompt at all** |
151
- | Fix | grant FDA to the responsible process | run the write where node is the responsible process |
152
-
153
- 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.
154
-
155
- 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.**
156
-
157
- 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:
57
+ The command prints `process.execPath`. That path is the node binary macOS will ask about. Click **Allow** for that binary. If it warns that the command you typed started a different node, run it again with the printed path in front:
158
58
 
159
59
  ```bash
160
- codesign -d --entitlements - /Applications/Claude.app 2>/dev/null | grep personal-information
161
- # no com.apple.security.personal-information.addressbook
162
- # no com.apple.security.personal-information.calendars
60
+ /absolute/path/to/node $(which apple-tools-mcp) permissions
163
61
  ```
164
62
 
165
- 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.
166
-
167
- **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.
168
-
169
- #### The fix: let the indexer daemon own the writes
170
-
171
- 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.
172
-
173
- 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.
174
-
175
- 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:
176
-
177
- | Host | Reads (incl. Contacts + Calendar) | Mail / Messages writes | Calendar CRUD | Contacts CRUD |
178
- |------|-----------------------------------|------------------------|---------------|----------------|
179
- | Node host: indexer daemon or `node index.js` from a terminal | Yes (FDA on node) | Yes | **Yes — ship gate host** | **Yes — ship gate host** |
180
- | Any stdio client **with the daemon running** | Yes | Yes (via the bridge) | Yes (via the bridge) | Yes (via the bridge) |
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) |
182
-
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.
63
+ Allow **node** to control Mail, Messages, Contacts, Calendar, and System Events. Each app is its own switch. Allowing Contacts does not allow Mail.
184
64
 
185
- #### Verifying Mail Automation plus Contacts and Calendar CRUD on a Node host
65
+ Then open **System Settings → Privacy & Security → Accessibility** and turn on the same node binary. Without that, Mail compose cannot paste the body.
186
66
 
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.
67
+ The command does real work so the prompts can appear. It opens a Mail compose window and throws it away (nothing is sent), lists Messages accounts (nothing is sent), creates and deletes a throwaway contact, lists calendars without creating events, and checks System Events. It exits with an error until Mail, Messages, Contacts, Calendar, and System Events are all allowed.
188
68
 
189
- **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.
69
+ `dry_run` on `mail_send` or `messages_send` does not talk to those apps, so a dry run will not pop the prompts and will not tell you whether Automation is allowed. A real send that hangs after you denied Mail is an Automation denial, not "Mail could not be reached."
190
70
 
191
- 1. **Make sure the indexer LaunchAgent is running**, so the bridge is listening:
71
+ If you clicked **Don't Allow**, macOS will not ask again. Open **System Settings → Privacy & Security → Automation**, turn the switch on for that node and that app, then run `apple-tools-mcp permissions` again.
192
72
 
193
- ```bash
194
- pgrep -fl apple-tools-indexer
195
- ls -l ~/.apple-tools-mcp/writer.sock
196
- ```
73
+ Do not add node with the **+** button under **Privacy & Security → Contacts** or **Calendars**. Those lists are not how these writes are granted, and on current macOS they often have no Add button.
197
74
 
198
- 2. **Check out the tip / unpack the tarball** you are gating, in a short-lived directory. The global install stays untouched.
75
+ Leave **Mail, Messages, and Contacts** running. If they are quit, writes to them often fail even when Automation is allowed. Calendar does not need to stay open.
199
76
 
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.
77
+ #### If your chat app cannot hold the grants
201
78
 
202
- ```bash
203
- npm run smoke:writes
204
- ```
79
+ macOS treats these actions as coming from the app that launched the server. A chat app that starts a short-lived MCP process is that app. Some of them cannot be granted Contacts or Calendar access, no matter what you allow for node.
205
80
 
206
- 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:
81
+ Run the indexer in the next section. It is a normal node process, so the Automation grants you gave node apply. Your MCP client sends writes to it through a local socket at `~/.apple-tools-mcp/writer.sock`. When a prompt appears, allow **node**, not the chat app.
207
82
 
208
- ```bash
209
- node scripts/smoke-writes.js --apply # first writable calendar
210
- node scripts/smoke-writes.js --apply --calendar=Work # pick the calendar
211
- node scripts/smoke-writes.js --apply --keep # leave the test items behind
212
- ```
83
+ ### 3. Connect your MCP client
213
84
 
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.
85
+ Any stdio MCP client can run the server. The command is the same; only the client's settings screen changes.
215
86
 
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:
217
-
218
- ```bash
219
- node scripts/smoke-writes.js --apply --allow-local # only from Terminal.app / launchd
220
- ```
221
-
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.
223
-
224
- ### 3. Configure your MCP client
225
-
226
- 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.
227
-
228
- **Command**
229
-
230
- - `npx` with args `["-y", "apple-tools-mcp"]` (npm install)
231
- - or `node` with args `["/absolute/path/to/Apple-Tools-MCP/index.js"]` (from source)
232
-
233
- **Example: Claude Desktop**
234
-
235
- Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
87
+ Global install:
236
88
 
237
89
  ```json
238
90
  {
@@ -245,96 +97,27 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
245
97
  }
246
98
  ```
247
99
 
248
- Other clients use their own settings UI or config file. Use the same `command` and `args`; only the file path or UI differs.
249
-
250
- MCP clients are **short-lived stdio** processes: they exit when the client closes stdin. Always-on indexing belongs on the **indexer daemon**, not a sleep-pipe wrapper around this binary.
251
-
252
- ### 4. Restart your MCP client
100
+ From a clone, use `node` and the absolute path to `index.js` instead of `npx`.
253
101
 
254
- Quit and reopen the client so it loads the server. For Claude Desktop, fully quit (Cmd+Q) and reopen.
102
+ Claude Desktop keeps this in `~/Library/Application Support/Claude/claude_desktop_config.json`. Quit the client completely and reopen it after you save.
255
103
 
256
- ## Building the Index
104
+ The MCP process is short-lived: it exits when the client disconnects. Ongoing indexing belongs on the indexer, not on a wrapper that holds this process open.
257
105
 
258
- On first use, a vector index of your emails, messages, and calendar events is built automatically. Email history is unlimited by default. This may take a while depending on the volume of data.
106
+ ### 4. Indexer
259
107
 
260
- **Who indexes**
108
+ On first use the server builds a search index of your mail, messages, and calendar events. That can take a while if you have a lot of mail. The index is stored in `~/.apple-tools-mcp/vector-index/`.
261
109
 
262
- - **Indexer daemon running** (recommended on Mac Mini): the daemon owns `~/.apple-tools-mcp/indexer.lock` and refreshes `~/.apple-tools-mcp/vector-index/`. MCP stdio clients only search; they do not start background refresh.
263
- - **No daemon** (default MacBook / Claude Desktop / Cursor): the MCP stdio process indexes **locally on startup**, same as previous versions, then exits when the client disconnects.
264
-
265
- You can manually rebuild the index (stop the indexer daemon first if it is running, so it is not writing at the same time):
110
+ Run the indexer so the index stays current and so writes go through node:
266
111
 
267
112
  ```bash
268
- # Index all email history (default)
269
- npm run build-index
270
-
271
- # Optional: cap email lookback (e.g. for a faster test rebuild)
272
- APPLE_TOOLS_INDEX_DAYS_BACK=30 npm run build-index
273
- ```
274
-
275
- The index is stored in `~/.apple-tools-mcp/vector-index/`.
276
-
277
- ## Index refresh interval
278
-
279
- Resolved **once at process start**. Precedence (highest wins):
280
-
281
- 1. `INDEX_INTERVAL_MS` environment variable (milliseconds or human form: `30s`, `1m`, `5m`, `1h`)
282
- 2. `~/.apple-tools-mcp/config.json` keys `indexInterval` or `indexIntervalMs`
283
- 3. Product default: **5 minutes** (`300000` ms) — typical MacBook / MCP local-fallback
284
-
285
- Values are **clamped** to **15 seconds** minimum and **6 hours** maximum. Invalid JSON, unknown keys, and unparseable intervals are logged and ignored (the process does not crash). The effective interval is logged at start, for example:
286
-
287
- ```text
288
- Effective index refresh interval: 1m (60000 ms) [source=config]
113
+ apple-tools-indexer
289
114
  ```
290
115
 
291
- A warn line is also logged when clamping occurs.
116
+ That command is `node index.js --mode=indexer`. A global install provides `apple-tools-indexer` (`bin/apple-tools-indexer.js`). From a clone, use `npm run indexer`.
292
117
 
293
- ### Example `~/.apple-tools-mcp/config.json` (Mac Mini)
118
+ To keep it running across logins, use a LaunchAgent. LaunchAgents do not see your shell `PATH`, so put in the absolute paths from `which node` and `npm root -g`.
294
119
 
295
- Recommended Mini always-on interval is **1 minute**. 30 seconds is allowed (at or above the 15s floor).
296
-
297
- ```json
298
- {
299
- "indexInterval": "1m"
300
- }
301
- ```
302
-
303
- Equivalent: `"indexIntervalMs": 60000`, or `INDEX_INTERVAL_MS=60000` (env overrides the file).
304
-
305
- Missing `config.json` is fine — env then the 5-minute default apply.
306
-
307
- ## Always-on indexer (Mac Mini LaunchAgent)
308
-
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).
310
-
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, Calendar.app, **and System Events** (mail_send / mail_draft fill the body via AX hit-test). 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.
316
-
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.
318
-
319
- **Entrypoint:** `node index.js --mode=indexer`
320
- **Convenience bin:** `apple-tools-indexer` (`bin/apple-tools-indexer.js`; npm global install provides it)
321
- **npm script (clone only):** `npm run indexer`
322
- **One-shot rebuild:** `npm run build-index` (stop the indexer daemon first)
323
-
324
- LaunchAgent should invoke **node + `--mode=indexer`** on the **global** package (Mini has no git clone). LaunchAgent does not inherit your shell `PATH`, so use absolute paths from `which node` and `npm root -g`.
325
-
326
- ```bash
327
- which node
328
- # Use this path (and the LaunchAgent plist). Examples only — not universal:
329
- # Mini ship-gate host: /Users/petercoates/.local/node/bin/node
330
- # Apple Silicon Homebrew: /opt/homebrew/bin/node
331
- # Intel Homebrew / usr/local: /usr/local/bin/node
332
-
333
- npm root -g
334
- # Example: /opt/homebrew/lib/node_modules
335
- ```
336
-
337
- Example `~/Library/LaunchAgents/com.apple-tools-mcp.indexer.plist`:
120
+ Save this as `~/Library/LaunchAgents/com.apple-tools-mcp.indexer.plist` and replace both paths:
338
121
 
339
122
  ```xml
340
123
  <?xml version="1.0" encoding="UTF-8"?>
@@ -345,19 +128,14 @@ Example `~/Library/LaunchAgents/com.apple-tools-mcp.indexer.plist`:
345
128
  <string>com.apple-tools-mcp.indexer</string>
346
129
  <key>ProgramArguments</key>
347
130
  <array>
348
- <string>/opt/homebrew/bin/node</string>
349
- <string>/opt/homebrew/lib/node_modules/apple-tools-mcp/index.js</string>
131
+ <string>/absolute/path/to/node</string>
132
+ <string>/absolute/path/to/node_modules/apple-tools-mcp/index.js</string>
350
133
  <string>--mode=indexer</string>
351
134
  </array>
352
135
  <key>RunAtLoad</key>
353
136
  <true/>
354
137
  <key>KeepAlive</key>
355
138
  <true/>
356
- <key>EnvironmentVariables</key>
357
- <dict>
358
- <key>PATH</key>
359
- <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
360
- </dict>
361
139
  <key>StandardOutPath</key>
362
140
  <string>/tmp/apple-tools-indexer.out.log</string>
363
141
  <key>StandardErrorPath</key>
@@ -366,54 +144,32 @@ Example `~/Library/LaunchAgents/com.apple-tools-mcp.indexer.plist`:
366
144
  </plist>
367
145
  ```
368
146
 
369
- Replace the node and `node_modules` paths with the values from `which node` and `npm root -g`. Load it with:
370
-
371
147
  ```bash
372
148
  launchctl load ~/Library/LaunchAgents/com.apple-tools-mcp.indexer.plist
373
149
  ```
374
150
 
375
- KeepAlive belongs on this indexer job only — not on the MCP stdio process.
376
-
377
- ## Remote HTTP transport (v3.0.0)
151
+ The refresh interval is chosen once, when the process starts. The default is 5 minutes. Set `INDEX_INTERVAL_MS` (`30s`, `1m`, `1h`, or a number of milliseconds) or add this to `~/.apple-tools-mcp/config.json`:
378
152
 
379
- Every tool the stdio server exposes — including the write tools — is also
380
- available over **Streamable HTTP**, so it's reachable from other machines,
381
- not just whatever process spawned it locally. This is additive: stdio still
382
- works exactly as before for local same-machine MCP clients that spawn it
383
- directly. The intended MacBook clients are Claude Desktop, Claude Code,
384
- Codex Desktop, and ChatGPT Desktop. Claude Desktop uses the included local
385
- HTTP-to-stdio proxy; Claude Code and Codex connect to the Mini's HTTP
386
- endpoint directly. ChatGPT Desktop does not currently support this private
387
- MCP connection; see the client guide for its provider-supported options.
153
+ ```json
154
+ {
155
+ "indexInterval": "1m"
156
+ }
157
+ ```
388
158
 
389
- Because HTTP reaches beyond "whoever spawned this process," which stdio's
390
- trust model relies on, **every HTTP request needs a bearer token.** stdio
391
- has no equivalent check — a locally spawned child process is already
392
- trusted by whoever spawned it.
159
+ Values are clamped to 15 seconds through 6 hours. A missing config file is fine; the 5-minute default is used.
393
160
 
394
- **Entrypoint:** `node index.js --transport=http`
395
- **Convenience bin:** `apple-tools-http` (`bin/apple-tools-http.js`; npm global install provides it)
396
- **npm script (clone only):** `npm run http`
161
+ If you do not run the indexer, the MCP process indexes when it starts, then exits when the client disconnects. Writes then run inside that process, and macOS attributes them to the app that launched it.
397
162
 
398
- ### Auth token
163
+ ### 5. Another computer (optional)
399
164
 
400
- The token is a random secret generated on first run and stored in the
401
- macOS Keychain (service `apple-tools-mcp-http`) — never in a config file,
402
- the server's config file, or logs after the one-time generation banner.
403
- There's no external account and no cost; it's a self-issued password,
404
- like an SSH key.
165
+ The same tools are available over HTTP, with a bearer token on every request. stdio on this Mac does not use the token.
405
166
 
406
167
  ```bash
407
- apple-tools-mcp http-token # prints the token, generating one first if needed
168
+ apple-tools-http
169
+ apple-tools-mcp http-token
408
170
  ```
409
171
 
410
- Clients send it as `Authorization: Bearer <token>`. Rotate it by deleting
411
- the Keychain item and restarting the server (it generates a fresh one on
412
- the next run), then update every client using the old value.
413
-
414
- ### Config
415
-
416
- `~/.apple-tools-mcp/config.json` (same file the index interval uses):
172
+ `~/.apple-tools-mcp/config.json` (the same file as the index interval):
417
173
 
418
174
  ```json
419
175
  {
@@ -422,241 +178,217 @@ the next run), then update every client using the old value.
422
178
  }
423
179
  ```
424
180
 
425
- Env overrides: `APPLE_TOOLS_HTTP_HOST`, `APPLE_TOOLS_HTTP_PORT`. The default
426
- host is `127.0.0.1`, so HTTP is local-only until you explicitly set a network
427
- address such as `0.0.0.0`. Default port is 8421 (the indexer's stdio process
428
- needs no port; this is only for `--transport=http`). For remote access, use a
429
- Tailscale address for clients that connect directly from your tailnet, or
430
- put the server behind a TLS-terminating reverse proxy. Do not send the
431
- bearer token over an untrusted LAN. Cloud-hosted connectors need a separate
432
- way to reach the server; a private Tailscale address alone is insufficient.
181
+ `127.0.0.1` accepts connections only on this Mac. Set `httpHost` to a LAN or Tailscale address when a client on another machine should connect. `APPLE_TOOLS_HTTP_HOST` and `APPLE_TOOLS_HTTP_PORT` override the file. Do not send the token over an open network.
433
182
 
434
- ### LaunchAgent
183
+ An example LaunchAgent is `examples/com.apple-tools-http.plist`. How to point Claude and other clients at it, including `apple-tools-http-proxy`, is in `examples/mcp-client.md`.
435
184
 
436
- See `examples/com.apple-tools-http.plist` (copy to
437
- `~/Library/LaunchAgents/`, replace the `REPLACE_ME*` placeholders the same
438
- way as the indexer plist above, then `launchctl load` it). Runs alongside
439
- the indexer LaunchAgent, not instead of it — the HTTP server is a second,
440
- independent process.
185
+ ## Index commands
441
186
 
442
- ### Wiring up clients
187
+ Stop the indexer before a manual rebuild so two processes are not writing the index at once.
443
188
 
444
- See `examples/mcp-client.md` for the MacBook setup. Short version:
189
+ ```bash
190
+ # Rebuild (all email history)
191
+ npm run build-index
445
192
 
446
- - **On the Mini:** set `httpHost` to its LAN or Tailscale IP and restart the HTTP server. Retrieve the bearer token with `apple-tools-mcp http-token`.
447
- - **Claude Desktop on the MacBook:** configure the included `apple-tools-http-proxy` as a local MCP server. It forwards tool calls to the Mini over HTTP.
448
- - **Claude Code and Codex Desktop on the MacBook:** add `http://<mini-address>:8421/mcp` and the token. The client guide has commands for both.
449
- - **ChatGPT Desktop on the MacBook:** current custom MCP support does not provide a direct private HTTP connection in the desktop app. The client guide describes the documented ChatGPT web route.
450
- - **Claude / ChatGPT cloud connectors:** a connector does not connect from your device's tailnet interface. [Claude requires a publicly reachable server](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp); [ChatGPT directs private-network servers to Secure MCP Tunnel and currently supports MCP apps on web only](https://help.openai.com/en/articles/12584461-developer-mode-and-mcp-apps-in-chatgpt). Use the provider's supported network setup before adding this server as a cloud connector.
193
+ # Optional shorter email lookback
194
+ APPLE_TOOLS_INDEX_DAYS_BACK=30 npm run build-index
195
+ ```
451
196
 
452
- ## Available Tools
197
+ Your client can also call `rebuild_index`.
453
198
 
454
- Once configured, your MCP client can use these tools:
199
+ Force a clean rebuild when the index is corrupt or out of date:
455
200
 
456
- ### Universal Search
201
+ ```bash
202
+ launchctl unload ~/Library/LaunchAgents/com.apple-tools-mcp.indexer.plist
457
203
 
458
- | Tool | Description |
459
- |------|-------------|
460
- | `smart_search` | Intelligent search across all sources - automatically determines which to search |
461
- | `person_search` | Find ALL communication with a person across Mail, Messages, and Calendar |
204
+ rm -rf ~/.apple-tools-mcp/vector-index
205
+ rm -f ~/.apple-tools-mcp/index-meta.json
206
+ rm -f ~/.apple-tools-mcp/indexer.lock
207
+ ```
462
208
 
463
- ### Email Tools
209
+ Start the indexer or your MCP client again so it builds a new index.
464
210
 
465
- | Tool | Description |
466
- |------|-------------|
467
- | `mail_search` | Semantic search for emails with filters (sender, recipient, attachments, mailbox) |
468
- | `mail_recent` | Get most recent emails (supports unread filter) |
469
- | `mail_date` | Get emails from a specific date ("today", "yesterday", "Nov 13") |
470
- | `mail_read` | Read full email content by file path |
471
- | `mail_senders` | List most frequent email senders |
472
- | `mail_thread` | Get all emails in a conversation thread |
211
+ Watch progress. With the LaunchAgent above:
473
212
 
474
- ### Messages Tools
213
+ ```bash
214
+ tail -f /tmp/apple-tools-indexer.err.log
215
+ ```
475
216
 
476
- | Tool | Description |
477
- |------|-------------|
478
- | `messages_search` | Semantic search for iMessages/SMS with filters |
479
- | `messages_recent` | Get most recent messages |
480
- | `messages_conversation` | Get full conversation history with a contact |
481
- | `messages_contacts` | List all contacts you've messaged |
217
+ Client logs depend on the app. Claude Desktop:
482
218
 
483
- ### Calendar Tools
219
+ ```bash
220
+ tail -f ~/Library/Logs/Claude/mcp-server-apple-tools.log
221
+ ```
484
222
 
485
- | Tool | Description |
486
- |------|-------------|
487
- | `calendar_search` | Semantic search for events with filters |
488
- | `calendar_date` | Get events on a specific date (live Calendar.app, not the search index) |
489
- | `calendar_upcoming` | Get next N upcoming events |
490
- | `calendar_week` | Get all events for current or future week |
491
- | `calendar_free_time` | Find available time slots on a date (live Calendar.app, not the search index) |
492
- | `calendar_recurring` | List recurring events |
223
+ Audit coverage against the source databases:
224
+
225
+ ```bash
226
+ npm run audit
227
+ npm run audit -- --reporter=verbose > audit-report.txt
228
+ ```
493
229
 
494
- ### Contacts Tools
230
+ Your client can also call `audit_index`.
495
231
 
496
- | Tool | Description |
497
- |------|-------------|
498
- | `contacts_search` | Search contacts by name, email, phone, or organization |
499
- | `contacts_lookup` | Look up a specific contact's full details |
232
+ ## Available tools
500
233
 
501
- ### Admin Tools
234
+ ### Universal search
502
235
 
503
236
  | Tool | Description |
504
237
  |------|-------------|
505
- | `rebuild_index` | Rebuild search index for one or all sources |
506
- | `audit_index` | Audit index health and coverage |
507
-
508
- ## Write Tools (2.0.0)
509
-
510
- Write tools change your data. They do not use the vector index, so they keep working while the indexer daemon holds the lock.
238
+ | `smart_search` | Search across mail, messages, and calendar. Picks the sources from the question. |
239
+ | `person_search` | Find communication with one person across Mail, Messages, and Calendar. |
511
240
 
512
- Two arguments are available on **every** write tool:
241
+ ### Email
513
242
 
514
- | Argument | Type | Meaning |
515
- |----------|------|---------|
516
- | `dry_run` | boolean | Preview only. Reports what *would* happen and changes nothing. Always wins, even together with `confirm`. |
517
- | `confirm` | boolean | Approves an action that is otherwise blocked (deletes, multi-recipient sends). |
518
-
519
- ### Confirm / dry-run rules
520
-
521
- - **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.
522
- - **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.
523
- - **`mail_draft` is exempt from the recipient rule** because a draft is never delivered.
524
- - **`dry_run` and confirm-blocked calls are not deliveries.** Planned writes return `ok: false`, `planned: true`, `delivered: false`, and MCP sets `isError: true`, so a client that only checks `isError` cannot treat a preview as sent. The text is `DRY RUN` / `CONFIRMATION REQUIRED` and never `verified in Sent`. Re-run with `dry_run=false` (and `confirm=true` when required) to actually send.
525
- - Responses name what happened (tool, ids, recipients, titles) and never echo message bodies — including on the error path.
526
- - 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.
527
-
528
- ### Mail write tools
243
+ | Tool | Description |
244
+ |------|-------------|
245
+ | `mail_search` | Semantic search, with filters for sender, recipient, attachments, and mailbox. |
246
+ | `mail_recent` | Most recent emails. Can limit to unread. |
247
+ | `mail_date` | Emails from a date such as "today", "yesterday", or "Nov 13". |
248
+ | `mail_read` | Full email content by file path. |
249
+ | `mail_senders` | Most frequent senders. |
250
+ | `mail_thread` | Every email in a conversation. |
529
251
 
530
- | Tool | Arguments | Confirm rule |
531
- |------|-----------|--------------|
532
- | `mail_send` | `to[]` (required), `cc[]`, `bcc[]`, `subject` (required), `body` (required), `body_format` (`plain` \| `html`) | `confirm` when total recipients > 1 |
533
- | `mail_draft` | `to[]` (required), `cc[]`, `bcc[]`, `subject`, `body`, `body_format` | none (saved to Drafts, not sent) |
534
- | `mail_reply` | `message_id` or `file_path`, `body` (required), `reply_all`, `save_as_draft` | `confirm` when `reply_all: true` |
535
- | `mail_forward` | `message_id` or `file_path`, `to[]` (required), `body`, `save_as_draft` | `confirm` when recipients > 1 |
536
- | `mail_mark` | `message_id` or `file_path`, `status` (`read` \| `unread`, default `read`) | none |
537
- | `mail_archive` | `message_id` or `file_path` | none |
538
- | `mail_trash` | `message_id` or `file_path` | **`confirm` required** |
252
+ ### Messages
539
253
 
540
- `body_format: "html"` uses the same compose path with a tag-stripped body pasted into Mail's native editor. It does **not** set Mail's `content` or `html content` — those setters cite-wrap the whole message (`>` prefixes and `<blockquote type="cite">`, same iOS purple bar). Mail may still generate its own HTML alternative from the native compose. The default is plain text.
254
+ | Tool | Description |
255
+ |------|-------------|
256
+ | `messages_search` | Semantic search for iMessage and SMS, with filters. |
257
+ | `messages_recent` | Most recent messages. |
258
+ | `messages_conversation` | Full conversation with a contact. |
259
+ | `messages_contacts` | Contacts you have messaged. |
541
260
 
542
- **Compose is not quoted.** `mail_send` / `mail_draft` (plain and html) call `make new outgoing message` **without** AppleScript `content:` so `newMessage` is a real Mail object, then paste the body into the compose window (exact subject title, role `AXWebArea`, proven focus, Cmd-A + clipboard). Mail's `mailto` command was shipped in 2.0.4 and **fails on Mini/MacBook Mail**: it does not return an outgoing message (`newMessage` undefined, AppleScript **-2753**). On current Mail (Ventura+, FB11734014) `content` / `html content` store the body as a citation: every plain-text line prefixed with `>`, plus a `multipart/alternative` HTML part wrapped in `<blockquote type="cite">`. Desktop Mail often hides the bar with inline styles; iOS Mail paints the whole body purple with a left quote bar — even when the subject is not `Re:`/`Fwd:` and there is no `In-Reply-To`. Reply and forward still quote the original, which is expected (those paths still prepend via `content`; fixing reply/forward cite-wrap is out of scope for 2.1.0).
261
+ ### Calendar
543
262
 
544
- Keystrokes use System Events, so **node needs Accessibility** (Privacy & Security → Accessibility) **and** Automation → Mail **and** Automation → System Events. Run `apple-tools-mcp permissions` after first install or upgrade so **node → System Events** pops **Allow** in the same sitting as Mail / Messages / Contacts / Calendar. An Accessibility / System Events deny is not a Mail Automation deny (`-1743` / `-10004`).
263
+ | Tool | Description |
264
+ |------|-------------|
265
+ | `calendar_search` | Semantic search for events, with filters. |
266
+ | `calendar_date` | Events on a date, read live from Calendar rather than the search index. |
267
+ | `calendar_upcoming` | Next upcoming events. |
268
+ | `calendar_week` | Events for this week or a future week. |
269
+ | `calendar_free_time` | Open time on a date, read live from Calendar rather than the search index. |
270
+ | `calendar_recurring` | Recurring events. |
545
271
 
546
- **Compose body focus (2.0.8).** After `make new outgoing message`, To and Subject are AppleScript properties (never paste). Cmd-V used to run only after an AX window walker left the header. Quote-prefix Sent prove and Sent/Outbox verify stay gated on fail-closed body/header checks — a MacBook 2.0.7 live `mail_send` with Accessibility allowed still hit `BODY_PASTE_MISDIRECTED` when Cmd-V landed in To/Subject (`ATP-207-SENT-PROVE-20260921-084048`).
272
+ ### Contacts
547
273
 
548
- **macOS 26 focus compile (2.0.9).** Dual-host Sent prove on `@2.0.8` never reached paste: `atmSafeToPaste` used bare `focused UI element`, which System Events 26.x rejects at compile.
274
+ | Tool | Description |
275
+ |------|-------------|
276
+ | `contacts_search` | Search by name, email, phone, or organization. |
277
+ | `contacts_lookup` | Full details for one contact. |
549
278
 
550
- **AppleScript reserved identifiers (2.0.10).** Dual-host Sent prove on `@2.0.9` compiled past the focused-UI fix, then failed in `atmFocusMailBody`: `set {tw, th} to size of atmElem` → **-2741** Expected expression but found `th` (AppleScript reserved ordinal).
279
+ ### Index
551
280
 
552
- **Keystroke compose (2.1.0).** macOS 26 System Events reports `windows of process Mail = 0`, so the AX window walker cannot focus a tall `AXWebArea`. Fresh compose **Tabs into the body** (max Tab×6 as proven on Mini) and **types the body with Return between paragraphs**. Clipboard Cmd-V is only a fallback if a *readable* AX value is still missing the body needle. Fail-closed: missing AX body focus or changed To/Cc/Bcc/subject is `BODY_FOCUS_FAILED` / `BODY_PASTE_MISDIRECTED`; the outgoing message is deleted and nothing is sent. Apple events are wrapped in `with timeout`; hung `osascript` is **SIGKILL**'d so a wedge fails fast instead of sitting ~60s. If the Mini write-bridge / indexer daemon cannot drive System Events, `mail_send` returns `unsupported: true` so the stdio client falls back **in-process** — it does **not** hang with `MAIL_SEND_TIMEOUT_GUIDANCE` for a message that never sent.
281
+ | Tool | Description |
282
+ |------|-------------|
283
+ | `rebuild_index` | Rebuild the search index for one source or all of them. |
284
+ | `audit_index` | Check index health and coverage. |
553
285
 
554
- **AX body-focus oracle (2.1.1).** Dual-host Mail 16 / macOS 26 showed that Mail AppleScript `content of` the outgoing message is **not** a live oracle for UI-typed/pasted text: `set content` reads back, but after Tab + keystroke/paste into the UI body `content of` stays empty (`typedSeen=false`, bodyLen 0) even when typing still produces a plain Sent `.emlx`. `@2.1.0` calibrated Tab against that property and fail-closed with `BODY_FOCUS_FAILED`. 2.1.1 Tabs until the focused AX role is a body field (`AXWebArea` / non-header `AXTextArea` via `AXFocusedUIElement` / `first UI element whose focused is true` — never `focused UI element`, never `windows of process Mail`). Cmd-V runs only when that AX **value** is readable and still missing the needle. An empty AX value is not a miss (Mail 16 WebArea); header checks still fail-closed. Ship prove that the body landed remains the Sent `.emlx` after send (2.0.7 To+subject verify). Do not “fix Tab count.”
286
+ ## Write tools
555
287
 
556
- **Compose body hit-test (2.1.3).** Mini host: `AXFocusedUIElement` role coerce fails (**-1700**); `windows of process Mail` is empty; a deep `UI elements` tree walk (the `app_ax_find` class of walk) **truncates after ~23 header/toolbar nodes** and never reaches compose `AXWebArea`. Subject-anchored clipboard read-back is refuted on Mini. The proven path: identify the compose window by the **subject title** among **top-level** System Events UI elements, then **coordinate AX hit-test** (`AXUIElementCopyElementAtPosition`) into the body region. The hit must be `AXWebArea` **"message body"**; a short `AXTextField` is fail-closed (`BODY_FOCUS_FAILED`). Focus + `AXValue` produced a **plain** Sent `.emlx` (cite=0) — no AppleScript `content` setter, no physical mouse click. Typed Returns / Cmd-V run only if a readable AX value is still missing the needle. System Events permissions probe is unchanged from 2.1.2.
288
+ Write tools change your data. They do not use the search index, so they work while the indexer is rebuilding.
557
289
 
558
- **AXTitle compile (2.1.4).** Mini host: 2.1.3 `mail_send` never reached hit-test because `osacompile` failed **-2741** on bare `value of attribute "AXTitle" of atmEl` in `atmTopElemBits` / `atmTopElemExactTitle` (those handlers sit outside the System Events tell that walks top-level UI elements). Mini solution probe (`results-214/SOLUTION-PROBE-REPORT.md`): wrapping those AXTitle reads inside `tell application "System Events"` compiles (`osacompile` exit 0). Hit-test + fail-closed `AXTextField` + plain Sent behavior is unchanged from 2.1.3. Live Sent prove is after npm.
290
+ Every write tool accepts:
559
291
 
560
- **mail_send success is Sent/Outbox verify (2.0.7).** AppleScript `send` returning without throw is not enough. After a real send the tool looks in **Sent** and **Outbox** and reports success only if the message is there. The success text names the delivery state (`mailbox: sent` + `delivery: sent`, or `mailbox: outbox` + `delivery: outbox` while still sending). If verify misses, the tool returns a failure (`isError`) — not success. Hang/timeout recover matches **To + subject** (and Message-ID when compose captured one). It never matches subject alone (short subjects like `test` are unsafe). `from` / account selection is out of scope; Mail's default From is used. `dry_run` / confirm-blocked writes stay `ok: false`, `planned: true`, `delivered: false`.
292
+ | Argument | Meaning |
293
+ |----------|---------|
294
+ | `dry_run` | Preview only. Nothing is sent or changed. This wins even if `confirm` is also set. |
295
+ | `confirm` | Approves an action that is otherwise blocked. |
561
296
 
562
- **Compile prove (2.1.4 on the Mini host):** Unpatched `buildComposeScript` / `buildMailBodyPasteHandler` `osacompile` **-2741** on those bare AXTitle reads; patched AXTitle-inside-System-Events tell `osacompile` exit 0. Report: `results-214/SOLUTION-PROBE-REPORT.md`. Global install untouched. Live Sent is the 2.1.3 ship prove after npm.
297
+ Deletes need `confirm: true`: `mail_trash`, `calendar_remove`, `contacts_remove`, and `contacts_edit` when it clears every email or phone. One id per call. There is no bulk delete.
563
298
 
564
- **Probe 3 body delivery (2.1.5).** Mini Probe 3 (`atm-p3-deliver.applescript`, classic AppleScript / System Events, not JXA) proved a click-free path: exact subject window title, first `AXWebArea` from `entire contents` of that window, `set focused of` that element to true, then an independent `AXFocusedUIElement` re-read that must match role `AXWebArea` and position/size before any keystroke. Delivery is Cmd-A + clipboard paste. No Mail `content` / `html content`. No `AXValue` write (`AXUIElementSetAttributeValue` on content is a silent no-op; 2.1.4 `atmAxHitFill` returned `OK` when that value was empty, so the keystroke recovery never ran). No coordinate `click at` (Probe 3: a click can land on another Space or app). Focus mismatch is `BODY_FOCUS_UNPROVEN`: the draft is deleted and nothing is sent. The post-paste body needle runs only when the focused element is still that proven body, so a To: recipient chip (`U+FFFC`) is not `BODY_PASTE_MISDIRECTED`. Sent/Outbox verify, `dry_run`, and confirm are unchanged.
299
+ Sends to more than one person need `confirm: true`: `mail_send` and `mail_forward` when To, Cc, and Bcc add up to more than one address, `mail_reply` with `reply_all: true`, and `messages_send` to several people or to a group chat. A single recipient sends on the first call. `mail_draft` never sends, so it does not need that confirm.
565
300
 
566
- **Manual prove (2.1.3 on the Mini host):** After `apple-tools-mcp permissions` reports System Events granted, `mail_send` a short **multiline** plain message and a short `body_format: "html"` message whose body looks like ordinary paragraphs (for example `<p>Quick note</p>`), neither with a `Re:`/`Fwd:` subject. The body must land via title-marker + hit-test (not Tab / focused-role): no `BODY_FOCUS_FAILED` / `BODY_PASTE_MISDIRECTED`. Inspect each Sent `.emlx`: the text/plain part must contain the intended text (newlines preserved) and must not prefix every body line with `>`, and any HTML alternative must not wrap the whole body in `<blockquote type="cite">`. Compose must succeed (no `-2753` / undefined `newMessage`). Prove the production path (write-bridge with node → System Events allowed, **or** the documented in-process fallback). That quote-prefix Mini Sent prove is a separate bar from the 2.0.7 verify contract.
301
+ A dry run or a call that still needs confirm does not deliver anything. The result is `ok: false`, `planned: true`, `delivered: false`, and the client may mark it as an error. The text says `DRY RUN` or `CONFIRMATION REQUIRED`. Run it again with `dry_run` false, and with `confirm: true` when the tool asked for it.
567
302
 
568
- 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.
303
+ Responses name the action, ids, and recipients. They do not echo message bodies.
569
304
 
570
- **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. Compose (`mail_send`) matches the intended **To plus exact subject** (and Message-ID when available), never subject alone. Neither scan treats the original itself as this send. A real send that returns without throw is also verified the same way before success. If Sent-verify misses, the error tells you it was a hang (or a verify-miss after send returned) 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.
305
+ A missing or malformed id or recipient is refused. Calendar times are local datetimes, `YYYY-MM-DD HH:MM`. Phrases like "next Tuesday" are rejected on writes.
571
306
 
572
- ### Messages write tool
307
+ ### Mail
573
308
 
574
- | Tool | Arguments | Confirm rule |
575
- |------|-----------|--------------|
576
- | `messages_send` | `to[]` or `chat_id`, `text`, `attachment_path`, `service` (`auto` \| `imessage` \| `sms`) | `confirm` for multiple handles or a group chat |
309
+ | Tool | Arguments | Confirm |
310
+ |------|-----------|---------|
311
+ | `mail_send` | `to[]` (required), `cc[]`, `bcc[]`, `subject` (required), `body` (required), `body_format` (`plain` or `html`) | when recipients > 1 |
312
+ | `mail_draft` | `to[]` (required), `cc[]`, `bcc[]`, `subject`, `body`, `body_format` | no (saved to Drafts) |
313
+ | `mail_reply` | `message_id` or `file_path`, `body` (required), `reply_all`, `save_as_draft` | when `reply_all` is true |
314
+ | `mail_forward` | `message_id` or `file_path`, `to[]` (required), `body`, `save_as_draft` | when recipients > 1 |
315
+ | `mail_mark` | `message_id` or `file_path`, `status` (`read` or `unread`, default `read`) | no |
316
+ | `mail_archive` | `message_id` or `file_path` | no |
317
+ | `mail_trash` | `message_id` or `file_path` | yes |
577
318
 
578
- Supported identifiers:
319
+ Pass the RFC822 Message-ID, or the `file_path` from `mail_search` or `mail_recent` and the server reads the Message-ID from the file. `mail_archive` moves the message to that account's Archive (or All Mail). `mail_trash` moves it to Trash.
579
320
 
580
- - **`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.
581
- - **`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.
582
- - **`service`** — `auto` (default) tries iMessage and falls back to the SMS relay; `imessage` and `sms` pin the service.
583
- - **`attachment_path`** — an absolute path to a file that already exists on this Mac. Text and attachment can be sent together.
321
+ `body_format: "html"` pastes the text into Mail's compose window. Mail may still create its own HTML version. New mail is a new message, not a quoted reply. Reply and forward still include the original.
584
322
 
585
- ### Calendar write tools
323
+ A send is successful only after the message is in **Sent** or still in **Outbox**. If the call times out, check Sent before you try again. Retrying a message that already went out sends a second copy. A timeout is not the same thing as a denied Automation prompt.
586
324
 
587
- | Tool | Arguments | Confirm rule |
588
- |------|-----------|--------------|
589
- | `calendar_list_calendars` | none | none (read-only helper) |
590
- | `calendar_add` | `calendar_name` (required), `title` (required), `start` (required), `end`, `all_day`, `location`, `notes`, recurrence args, `alerts_minutes_before[]` | none |
591
- | `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 |
592
- | `calendar_remove` | `event_id` (required), optional `eventkit_id` from add, optional `calendar_name` | **`confirm` required** |
593
- | `calendar_rsvp` | `event_id` (required), `response` (`accept` \| `decline` \| `tentative`), `attendee_email` | none |
325
+ Compose needs the Accessibility and System Events grants from the permissions section.
594
326
 
595
- 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.
327
+ ### Messages
596
328
 
597
- `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`.
329
+ | Tool | Arguments | Confirm |
330
+ |------|-----------|---------|
331
+ | `messages_send` | `to[]` or `chat_id`, `text`, `attachment_path`, `service` (`auto`, `imessage`, or `sms`) | several handles, or a group chat |
598
332
 
599
- 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.
333
+ - `to` is a phone number in E.164 form (`+15551234567`) or an Apple ID email.
334
+ - `chat_id` is an existing conversation id, such as `iMessage;-;+15551234567` or `iMessage;+;chat123456789`. It is checked against Messages before anything is sent. That lookup is also how group chats are detected.
335
+ - `service`: `auto` (default) tries iMessage and can fall back to SMS; `imessage` and `sms` pin the service.
336
+ - `attachment_path` is an absolute path to a file that already exists on this Mac. Text and a file can go together.
600
337
 
601
- `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`.
338
+ ### Calendar
602
339
 
603
- `--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.
340
+ | Tool | Arguments | Confirm |
341
+ |------|-----------|---------|
342
+ | `calendar_list_calendars` | none | no. Lists calendars so you can pick one. |
343
+ | `calendar_add` | `calendar_name` (required), `title` (required), `start` (required), `end`, `all_day`, `location`, `notes`, recurrence, `alerts_minutes_before[]` | no |
344
+ | `calendar_edit` | `event_id` (required), optional `eventkit_id` from add, plus any of `title`, `start`, `end`, `location`, `notes`, recurrence, `alerts_minutes_before[]`, `replace_alerts` | no |
345
+ | `calendar_remove` | `event_id` (required), optional `eventkit_id` from add, optional `calendar_name` | yes |
346
+ | `calendar_rsvp` | `event_id` (required), `response` (`accept`, `decline`, or `tentative`), `attendee_email` | no |
604
347
 
605
- 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.
348
+ `calendar_date` and `calendar_add` report the event id. Pass that id to edit, remove, and RSVP. Run `calendar_list_calendars` first so a new event lands on the calendar you mean. `calendar_add` also returns `eventkit_id`; pass it back on edit and remove.
606
349
 
607
- **Supported recurrence patterns.** Either pass structured arguments or a raw `recurrence` RRULE:
350
+ Recurrence is either structured fields or one raw rule:
608
351
 
609
352
  | Argument | Values |
610
353
  |----------|--------|
611
354
  | `frequency` | `daily`, `weekly`, `monthly`, `yearly` |
612
- | `interval` | 1-366 (e.g. `2` with `weekly` = every other week) |
613
- | `count` | 1-1000 occurrences (cannot be combined with `until`) |
614
- | `until` | explicit local datetime; emitted as a UTC `UNTIL` |
615
- | `by_day` | `MO TU WE TH FR SA SU` (weekly patterns) |
616
- | `recurrence` | raw RRULE instead of the above, e.g. `FREQ=WEEKLY;INTERVAL=1;COUNT=10` |
617
-
618
- `{ 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.
355
+ | `interval` | 1–366 |
356
+ | `count` | 1–1000 occurrences. Do not combine with `until`. |
357
+ | `until` | local datetime |
358
+ | `by_day` | `MO` `TU` `WE` `TH` `FR` `SA` `SU` |
359
+ | `recurrence` | a raw rule such as `FREQ=WEEKLY;INTERVAL=1;COUNT=10` |
619
360
 
620
- **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.
361
+ `alerts_minutes_before` takes up to five values, from 0 to 40320 minutes (four weeks). On edit, supplying alerts replaces the existing ones. `replace_alerts: true` removes them without adding new ones.
621
362
 
622
- **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.
363
+ `calendar_rsvp` sets your response on an invitation. If that macOS version will not write it, the tool says so instead of pretending it worked.
623
364
 
624
- ### Contacts write tools
365
+ ### Contacts
625
366
 
626
- | Tool | Arguments | Confirm rule |
627
- |------|-----------|--------------|
628
- | `contacts_add` | `first_name`, `last_name`, `organization`, `job_title`, `emails[]`, `email_label`, `phones[]`, `phone_label` | none |
629
- | `contacts_edit` | `contact_id` (required), any of the above, `replace_emails`, `replace_phones` | **`confirm` required** when replacing with an empty list |
630
- | `contacts_remove` | `contact_id` (required) | **`confirm` required** |
367
+ | Tool | Arguments | Confirm |
368
+ |------|-----------|---------|
369
+ | `contacts_add` | `first_name`, `last_name`, `organization`, `job_title`, `emails[]`, `email_label`, `phones[]`, `phone_label` | no |
370
+ | `contacts_edit` | `contact_id` (required), any of the add fields, `replace_emails`, `replace_phones` | yes, when replacing with an empty list |
371
+ | `contacts_remove` | `contact_id` (required) | yes |
631
372
 
632
- 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).
373
+ Use the contact id from `contacts_search`, `contacts_lookup`, or `contacts_add`. Creating a contact needs at least one of `first_name`, `last_name`, or `organization`. Writes go through Contacts, which keeps iCloud in sync.
633
374
 
634
- ### Example write calls
375
+ ### Examples
635
376
 
636
377
  ```jsonc
637
- // Preview first - nothing is sent
638
378
  { "name": "mail_send", "arguments": { "to": ["a@example.com"], "subject": "Status", "body": "All good", "dry_run": true } }
639
379
 
640
- // Single recipient: sends on the first call
641
380
  { "name": "mail_send", "arguments": { "to": ["a@example.com"], "subject": "Status", "body": "All good" } }
642
381
 
643
- // Two recipients: blocked until confirmed
644
382
  { "name": "mail_send", "arguments": { "to": ["a@example.com", "b@example.com"], "subject": "Status", "body": "All good", "confirm": true } }
645
383
 
646
- // Recurring event with an alert
647
384
  { "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] } }
648
385
 
649
- // Delete: preview, then confirm
650
386
  { "name": "calendar_remove", "arguments": { "event_id": "EVT-UID", "confirm": true } }
651
387
  ```
652
388
 
653
- ### Not included
389
+ This package does not include Reminders, Notes, FaceTime, or Files.
654
390
 
655
- No Apple **Reminders** tools ship in this package, by design. Notes, FaceTime, and Files automation are also out of scope.
656
-
657
- ## Example Queries
658
-
659
- Ask your MCP client things like:
391
+ ## Example questions
660
392
 
661
393
  - "Find emails from John about the quarterly report"
662
394
  - "What messages did I get from Mom last week?"
@@ -666,135 +398,46 @@ Ask your MCP client things like:
666
398
  - "What's Sarah's phone number?"
667
399
  - "Show me all communication with David from last month"
668
400
 
669
- ## Privacy & Security
670
-
671
- - **Local Processing**: All embeddings are generated locally using Hugging Face Transformers.js
672
- - **No Cloud Services**: No data is sent to external servers; the write bridge is a local unix socket, never a network port
673
- - **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
674
- - **No credentials**: The server holds no tokens or passwords. It uses the Mail, Messages, Calendar, and Contacts apps you are already signed into
675
- - **Your Data**: The vector index is stored locally in your home directory
676
-
677
- ## Troubleshooting
678
-
679
- ### "Authorization denied" errors
680
-
681
- Ensure Node.js has Full Disk Access (see Installation step 2).
682
-
683
- ### A write tool reports that macOS denied the automation
684
-
685
- The message names which process macOS was actually asking about. Work through it in this order:
401
+ ## Privacy
686
402
 
687
- 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, Messages, and System Events. Do **not** start `apple-tools-indexer`.
688
- 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)).
689
- 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, Calendar, and **System Events**. A prior **Don't Allow** for System Events is a sticky Deny — the Allow dialog will not appear again; turn **System Events** on for that node, then re-run `permissions` (it must not report granted from a soft name check). 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.
690
- 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.
691
- 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.
692
- 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.
693
- 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.
694
- 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.
403
+ Search and the index read your mail, messages, calendar, and contacts on this Mac. Write tools send messages and change events and contacts. Deletes and multi-recipient sends wait for `confirm`.
695
404
 
696
- ### A write returned "CONFIRMATION REQUIRED"
405
+ Embeddings are computed locally. Nothing is uploaded. The write bridge is a socket in your home directory, not a network port. The server stores no passwords; it uses the Apple apps you are already signed into. The index stays in `~/.apple-tools-mcp/`.
697
406
 
698
- 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.
699
-
700
- ### Empty search results
701
-
702
- 1. Check that the index was built: `ls ~/.apple-tools-mcp/vector-index/`
703
- 2. Rebuild the index if needed: `npm run build-index`
704
-
705
- ### Server not appearing in the MCP client
706
-
707
- 1. Verify your client config is valid (JSON files must be valid JSON)
708
- 2. Restart the MCP client completely (for Claude Desktop: Cmd+Q, then reopen)
709
- 3. Check the client's MCP logs for errors
710
-
711
- ### Force rebuild the index
712
-
713
- If the index becomes corrupted or out of sync:
714
-
715
- ```bash
716
- # If the Mini indexer LaunchAgent is running, unload it first
717
- # launchctl unload ~/Library/LaunchAgents/com.apple-tools-mcp.indexer.plist
718
-
719
- # Remove existing index files
720
- rm -rf ~/.apple-tools-mcp/vector-index
721
- rm -f ~/.apple-tools-mcp/index-meta.json
722
- rm -f ~/.apple-tools-mcp/indexer.lock
723
-
724
- # Restart the indexer daemon or your MCP client to trigger a fresh rebuild
725
- ```
726
-
727
- ### Monitor indexing progress
728
-
729
- Watch the MCP server logs in your client. Log locations vary by client; Claude Desktop example:
407
+ ## Troubleshooting
730
408
 
731
- ```bash
732
- tail -f ~/Library/Logs/Claude/mcp-server-apple-tools.log
733
- ```
409
+ **"Authorization denied" or the databases will not open.** Full Disk Access is missing or it was granted to a different `node` than the one that is running. Repeat the Full Disk Access steps with `which node`.
734
410
 
735
- ### Audit the index
411
+ **A write is denied, or sending mail hangs.** Automation was not granted for the node that is actually running. Run `apple-tools-mcp permissions` from Terminal.app and allow the printed `process.execPath` for Mail, Messages, Contacts, Calendar, and System Events. Allow node, not the chat app. If you previously clicked Don't Allow, turn the switch on in Automation and run the command again. `dry_run` does not talk to Mail, so it will not catch this.
736
412
 
737
- Check index health and coverage:
413
+ **"CONFIRMATION REQUIRED".** The safety check worked. Pass `confirm: true`, or use `dry_run: true` if you only wanted a preview.
738
414
 
739
- ```bash
740
- # Quick audit
741
- npm run audit
415
+ **Search returns nothing.** Check `ls ~/.apple-tools-mcp/vector-index/`. If it is missing or stale, rebuild with the commands above.
742
416
 
743
- # Detailed audit saved to file
744
- npm run audit -- --reporter=verbose > audit-report.txt
745
- ```
417
+ **The server does not show up in the client.** Confirm the config is valid JSON, then quit the client completely and reopen it.
746
418
 
747
419
  ## Development
748
420
 
749
421
  ```bash
750
- # Clone the repo
751
422
  git clone https://github.com/sfls1397/Apple-Tools-MCP.git
752
423
  cd Apple-Tools-MCP
753
-
754
- # Install dependencies
755
424
  npm install
756
-
757
- # Install test dependencies
758
- npm install -D vitest @vitest/coverage-v8 fast-check
759
-
760
- # Run tests
761
425
  npm test
762
-
763
- # Run the indexer daemon (owns indexer.lock + vector-index refresh)
764
426
  npm run indexer
765
-
766
- # One-shot rebuild (stop the indexer daemon first)
767
427
  npm run build-index
768
-
769
- # Run tests with verbose coverage report
770
- npx vitest run --coverage --reporter=verbose
771
-
772
- # Run audit to check index health
773
428
  npm run audit
774
-
775
- # Prove the write path on this host (dry run)
776
429
  npm run smoke:writes
777
-
778
- # Real CRUD — the extra -- is required so npm forwards --apply
779
430
  npm run smoke:writes -- --apply
780
431
  ```
781
432
 
782
- ## Contributing
783
-
784
- Contributions are welcome! Please:
785
-
786
- 1. Fork the repository
787
- 2. Create a feature branch
788
- 3. Make your changes
789
- 4. Run the tests: `npm test`
790
- 5. Submit a pull request
433
+ `npm run smoke:writes` checks the write path without creating lasting contacts or events. `npm run smoke:writes -- --apply` performs real creates, edits, and deletes, then removes the test items. The extra `--` is required so npm forwards `--apply`. `--apply` fails if Mail, Messages, Contacts, or Calendar Automation is missing.
791
434
 
792
435
  ## License
793
436
 
794
- MIT License - see [LICENSE](LICENSE) for details.
437
+ MIT License. See [LICENSE](LICENSE).
795
438
 
796
439
  ## Acknowledgments
797
440
 
798
- - Built with the [Model Context Protocol SDK](https://github.com/modelcontextprotocol/sdk)
799
- - Vector search powered by [LanceDB](https://lancedb.com/)
800
- - Local embeddings via [Hugging Face Transformers.js](https://github.com/huggingface/transformers.js)
441
+ - [Model Context Protocol SDK](https://github.com/modelcontextprotocol/sdk)
442
+ - [LanceDB](https://lancedb.com/)
443
+ - [Hugging Face Transformers.js](https://github.com/huggingface/transformers.js)
package/lib/mailWrite.js CHANGED
@@ -525,17 +525,27 @@ on atmTopElemBits(atmEl)
525
525
  return atmBits
526
526
  end atmTopElemBits
527
527
 
528
+ -- Mail titles a compose window with the full subject, while the marker is
529
+ -- cut to ${MAIL_COMPOSE_TITLE_MARKER_MAX} characters for long subjects. A marker that length is a
530
+ -- prefix; a shorter one is the whole subject and must match exactly.
531
+ on atmTitleMatches(atmTitle, atmMarker)
532
+ if atmTitle is atmMarker then return true
533
+ if (length of atmMarker) >= ${MAIL_COMPOSE_TITLE_MARKER_MAX} and atmTitle starts with atmMarker then return true
534
+ return false
535
+ end atmTitleMatches
536
+
528
537
  on atmTopElemExactTitle(atmEl, atmMarker)
529
538
  try
530
- if (name of atmEl as string) is atmMarker then return true
539
+ if my atmTitleMatches(name of atmEl as string, atmMarker) then return true
531
540
  end try
532
541
  try
533
- if (title of atmEl as string) is atmMarker then return true
542
+ if my atmTitleMatches(title of atmEl as string, atmMarker) then return true
534
543
  end try
535
544
  try
536
545
  tell application "System Events"
537
- if (value of attribute "AXTitle" of atmEl as string) is atmMarker then return true
546
+ set atmAxTitle to (value of attribute "AXTitle" of atmEl as string)
538
547
  end tell
548
+ if my atmTitleMatches(atmAxTitle, atmMarker) then return true
539
549
  end try
540
550
  return false
541
551
  end atmTopElemExactTitle
@@ -624,7 +634,7 @@ on atmFillMailBody(bodyText, needle, titleMarker, msg)
624
634
  set atmWin to missing value
625
635
  repeat with atmCand in windows
626
636
  try
627
- if (name of atmCand as string) is titleMarker then
637
+ if my atmTitleMatches(name of atmCand as string, titleMarker) then
628
638
  set atmWin to atmCand
629
639
  exit repeat
630
640
  end if
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
4
4
  "description": "MCP server for semantic search and write actions across Apple Mail, Messages, Calendar, and Contacts. Speaks stdio (local) or bearer-token-authenticated Streamable HTTP (LAN / Tailscale).",
5
5
  "type": "module",
6
6
  "main": "index.js",