apple-tools-mcp 2.1.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,7 +77,7 @@ Write tools drive Mail, Messages, Calendar, and Contacts through AppleScript. ma
77
77
 
78
78
  #### First install and upgrade: `apple-tools-mcp permissions`
79
79
 
80
- Run this **after first global install** and **after upgrade** when write surfaces are present or change. It probes **`process.execPath`** (the `node` running the command) so macOS can pop **Allow** dialogs for Contacts, Calendar, Mail, and Messages in one sitting. You click **Allow**; the command cannot grant silently. Already-granted surfaces report OK without another click. Missing grants print a report and the process **exits non-zero** (fail closed).
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
81
 
82
82
  Use the **same `node` the product uses** — not the MCP host app. Always invoke so **`process.execPath`** is that node. A bare `apple-tools-mcp` (or a path like `~/.nvm/versions/node/v22.21.1/bin/apple-tools-mcp`) can have a shebang that starts a **different** node; Allow dialogs attach to `execPath`, not the CLI path you typed.
83
83
 
@@ -98,15 +98,15 @@ If the invoked CLI path and `process.execPath` differ, the command prints a **WA
98
98
  - **Mini:** `/Users/petercoates/.local/node/bin/node` (global npm / indexer LaunchAgent)
99
99
  - **MacBook:** `/Users/petercoates/.nvm/versions/node/v22.21.1/bin/node` (Claude’s nvm `node`, **not** Homebrew)
100
100
 
101
- **MacBook (default for `permissions`):** run from **Terminal.app**. Click **Allow** for the **printed `process.execPath`**. Confirm **System Settings → Privacy & Security → Automation** for that node → Contacts, Calendar, Mail, and Messages. Do **not** start `apple-tools-indexer` — the always-on indexer / write bridge is a **Mini** install only.
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
102
 
103
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
104
 
105
- On the host UI (Mini Screen Sharing or MacBook local), open **System Settings → Privacy & Security → Automation**, then run the command. Click **Allow** for **`node`** → Contacts, Calendar, Mail, and Messages. npm `postinstall` only **prints a reminder** — it does not run the probes unattended.
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
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).
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
108
 
109
- **One-pass first-run:** Allow **`node`** to control **Mail**, **Messages**, **Contacts**, and **Calendar** in the same Automation pass. Contacts or Calendar being allowed does **not** grant Mail or Messages — they are separate Apple Events targets. A denied Mail grant hangs `mail_send` / `mail_draft` / `mail_reply` / `mail_forward` until the client disconnects; `dry_run` never talks to Mail, so that deny is invisible until a real compose.
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
110
 
111
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.
112
112
 
@@ -466,7 +466,7 @@ Two arguments are available on **every** write tool:
466
466
 
467
467
  **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 type the body into the compose window (System Events keystrokes). 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).
468
468
 
469
- Keystrokes use System Events, so **node needs Accessibility** (Privacy & Security → Accessibility) **and** Automation → Mail. On Mini, the LaunchAgent's node also needs **Automation → System Events** if writes run through the write bridge. An Accessibility / System Events deny is not a Mail Automation deny (`-1743` / `-10004`).
469
+ 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`).
470
470
 
471
471
  **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`).
472
472
 
@@ -474,11 +474,13 @@ Keystrokes use System Events, so **node needs Accessibility** (Privacy & Securit
474
474
 
475
475
  **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).
476
476
 
477
- **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 now **Tabs into the body** (runtime calibration against Mail `content of` the outgoing message, max Tab×6 as proven on Mini) and **types the body with Return between paragraphs**. Clipboard Cmd-V is only a fallback if the body needle is still missing. Fail-closed: missing needle 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.
477
+ **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.
478
+
479
+ **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.”
478
480
 
479
481
  **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`.
480
482
 
481
- **Manual prove (2.1.0 on the Mac host):** `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 caret must land in the **body** (not To/Subject): no `BODY_FOCUS_FAILED` / `BODY_PASTE_MISDIRECTED`, and the intended text must be in the native body before send, with newlines preserved. Inspect each Sent `.emlx`: the text/plain part 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`). On Mini, prove the production path (write-bridge with node → System Events allowed, **or** the documented in-process fallback). That quote-prefix dual-host Sent prove is a separate bar from the 2.0.7 verify contract.
483
+ **Manual prove (2.1.1 on the Mac 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 caret must land in the **body** (not To/Subject): 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`). On Mini, prove the production path (write-bridge with node → System Events allowed, **or** the documented in-process fallback). That quote-prefix dual-host Sent prove is a separate bar from the 2.0.7 verify contract.
482
484
 
483
485
  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.
484
486
 
@@ -599,9 +601,9 @@ Ensure Node.js has Full Disk Access (see Installation step 2).
599
601
 
600
602
  The message names which process macOS was actually asking about. Work through it in this order:
601
603
 
602
- 1. **MacBook / Terminal (`permissions` CLI):** there is **no** always-on indexer. Re-run `apple-tools-mcp permissions` from **Terminal.app**, Allow the **printed `process.execPath`**, and check System Settings → Privacy & Security → **Automation** for that node → Contacts, Calendar, Mail, and Messages. Do **not** start `apple-tools-indexer`.
604
+ 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`.
603
605
  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)).
604
- 3. **Did you approve the Automation prompts for `node`?** Re-run `apple-tools-mcp permissions` with the product `node` (it prints `process.execPath`). Open System Settings → Privacy & Security → **Automation** and confirm **that printed node** is allowed to control **Mail**, **Messages**, Contacts, and Calendar. On Mini the LaunchAgent binary is whatever the plist / `which node` reports (`/Users/petercoates/.local/node/bin/node` is a Mini *example* only). MacBook Claude nvm is `/Users/petercoates/.nvm/versions/node/v22.21.1/bin/node`, not Homebrew. Watch the host — Allow **`node`**, not any other app. Contacts or Calendar being allowed does **not** grant Mail. A `mail_send` hang is **Automation denied**, not “Mail.app could not be reached”; `dry_run` never talks to Mail so it cannot detect this. Do **not** approve the MCP client / host app that launched a short-lived stdio server, and do **not** try to add `node` via **+** in the Contacts or Calendars privacy lists — those panes often have no Add button. `tccutil reset AppleEvents` re-arms the Automation prompt so you can Allow **`node`** again. Full Disk Access is a separate read grant; npm Trusted Publisher / tokens are unrelated.
606
+ 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.
605
607
  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.
606
608
  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.
607
609
  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.
@@ -345,15 +345,19 @@ export const MAIL_ACCESSIBILITY_GUIDANCE =
345
345
  "This is not a Mail Automation deny (-1743 / -10004).";
346
346
 
347
347
  /**
348
- * System Events Apple Events / GUI scripting is missing, so the keystroke
349
- * compose path cannot run. Distinct from a Mail send hang: nothing was typed
350
- * or sent. Mini write-bridge should return unsupported so stdio falls back
351
- * in-process rather than wedging ~60s.
348
+ * Sticky Don't Allow for node → System Events: the Allow dialog will not
349
+ * reappear. Operators must flip the Automation row and re-run permissions.
352
350
  */
351
+ export const SYSTEM_EVENTS_STICKY_DENY_GUIDANCE =
352
+ "A prior Don't Allow for node → System Events is a sticky Deny: the Allow dialog will not appear again. " +
353
+ "Turn System Events on for this node in System Settings → Privacy & Security → Automation, then re-run apple-tools-mcp permissions after Allow.";
354
+
355
+ /** System Events GUI scripting missing — not a Mail send hang. */
353
356
  export const MAIL_GUI_SCRIPTING_GUIDANCE =
354
357
  "System Events GUI scripting is unavailable for this process, so the body cannot be typed into Mail. " +
355
358
  "mail_send / mail_draft use System Events keystrokes (Tab into the body, typed Returns) so Mail does not cite-wrap the Sent body (FB11734014). " +
356
- "On a Mini LaunchAgent / write-bridge host, Allow the daemon's node → System Events in System Settings → Privacy & Security → Automation (in addition to node → Mail) and keep Accessibility allowed. " +
359
+ "Allow node → System Events in System Settings → Privacy & Security → Automation (run apple-tools-mcp permissions after install/upgrade) in addition to node → Mail, and keep Accessibility allowed. " +
360
+ SYSTEM_EVENTS_STICKY_DENY_GUIDANCE + " " +
357
361
  "This is not a Mail send timeout and not a Mail Automation deny (-1743 / -10004).";
358
362
 
359
363
  /**
package/lib/mailWrite.js CHANGED
@@ -160,14 +160,16 @@ export const BODY_PASTE_MISDIRECTED_SENTINEL = "BODY_PASTE_MISDIRECTED";
160
160
  export const BODY_FOCUS_FAILED_SENTINEL = "BODY_FOCUS_FAILED";
161
161
  export const GUI_SCRIPTING_UNAVAILABLE_SENTINEL = "GUI_SCRIPTING_UNAVAILABLE";
162
162
  export const SYSTEM_EVENTS_PROBE_MARKER = "ATM_SYSTEM_EVENTS_PROBE";
163
- /** Compose body AX height vs To/Subject/Cc fields (those are ~22px). Kept for 2.0.8–2.0.10 compile tests; 2.1.0 compose does not walk AX. */
163
+ /** Permissions-only marker: process / GUI scripting, not application `name`. */
164
+ export const SYSTEM_EVENTS_AUTOMATION_PROBE_MARKER = "ATM_SYSTEM_EVENTS_AUTOMATION_PROBE";
165
+ /** Compose body AX height vs To/Subject/Cc fields (those are ~22px). Used to reject short header AXTextAreas after Tab; does not walk windows. */
164
166
  export const MAIL_BODY_MIN_AX_HEIGHT = 50;
165
167
  /** Mini-proven Tab count from default compose focus into the body. */
166
168
  export const MAIL_BODY_TAB_MAX = 6;
167
- /** Distinctive character typed then deleted to see whether the caret is in the body. */
168
- export const MAIL_BODY_CALIBRATE_PROBE = "§";
169
- /** Cheap System Events Automation probe; SIGKILL if it still hangs. */
169
+ /** Cheap pre-compose System Events check; SIGKILL if it still hangs. Not the permissions grant. */
170
170
  export const MAIL_SE_PROBE_TIMEOUT_MS = 4000;
171
+ /** First-run / upgrade permissions probe: long enough for the Allow click. */
172
+ export const SYSTEM_EVENTS_AUTOMATION_PROBE_TIMEOUT_MS = 30000;
171
173
  /** Compose osascript budget (make + keystroke + send). Far below the ~60s SE wedge. */
172
174
  export const MAIL_COMPOSE_TIMEOUT_MS = 25000;
173
175
  export const MAIL_SE_APPLEEVENT_TIMEOUT_SEC = 8;
@@ -250,6 +252,11 @@ export function isSystemEventsProbeScript(script) {
250
252
  return String(script || "").includes(SYSTEM_EVENTS_PROBE_MARKER);
251
253
  }
252
254
 
255
+ export function isSystemEventsAutomationProbeScript(script) {
256
+ return String(script || "").includes(SYSTEM_EVENTS_AUTOMATION_PROBE_MARKER);
257
+ }
258
+
259
+ /** Cheap pre-compose check. Do not use for `permissions` — `get name` can soft-pass. */
253
260
  export function buildSystemEventsProbeScript() {
254
261
  const secs = asInteger(MAIL_SE_PROBE_APPLEEVENT_TIMEOUT_SEC, {
255
262
  min: 1,
@@ -268,27 +275,153 @@ if atmUi is false then error "${ACCESSIBILITY_DENIED_SENTINEL}"
268
275
  return "OK"`;
269
276
  }
270
277
 
271
- export function probeSystemEventsGui() {
278
+ export function probeSystemEventsGui({ timeout = MAIL_SE_PROBE_TIMEOUT_MS } = {}) {
272
279
  return runAppleScript(buildSystemEventsProbeScript(), {
273
- timeout: MAIL_SE_PROBE_TIMEOUT_MS
280
+ timeout
274
281
  });
275
282
  }
276
283
 
284
+ /**
285
+ * First-run / upgrade probe. Soft `tell application "System Events" to get
286
+ * name` is not enough — Mini @2.1.1 reported granted while TCC auth_value=0
287
+ * for node → com.apple.systemevents. Process / GUI scripting (`tell process`)
288
+ * is the grant mail_send actually needs. Does not type into the focused UI.
289
+ * Timeout is long enough for the operator to click Allow. No short inner
290
+ * AppleEvent timeout (same as the Mail compose-and-discard probe).
291
+ */
292
+ export function buildSystemEventsAutomationProbeScript() {
293
+ return `-- ${SYSTEM_EVENTS_AUTOMATION_PROBE_MARKER}
294
+ tell application "System Events"
295
+ tell process "System Events"
296
+ set atmPid to unix id
297
+ set atmShown to name
298
+ if atmShown is "" then error "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}"
299
+ if atmPid is missing value then error "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}"
300
+ if (atmPid as integer) is less than or equal to 0 then error "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}"
301
+ count of UI elements
302
+ end tell
303
+ set atmUi to UI elements enabled
304
+ end tell
305
+ if atmUi is false then error "${ACCESSIBILITY_DENIED_SENTINEL}"
306
+ return "OK"`;
307
+ }
308
+
309
+ /**
310
+ * First-run / upgrade probe: a live System Events GUI-scripting Apple Event
311
+ * that creates or verifies Automation → System Events. Does not type.
312
+ * @returns {{ ok: boolean, message: string, kind: string|null }}
313
+ */
314
+ export function probeSystemEventsAutomation() {
315
+ const action = "system_events_automation_probe";
316
+ const summary = "verify System Events process / GUI scripting (Automation / Apple Events grant; nothing is typed)";
317
+ const result = runAppleScript(buildSystemEventsAutomationProbeScript(), {
318
+ timeout: SYSTEM_EVENTS_AUTOMATION_PROBE_TIMEOUT_MS
319
+ });
320
+ if (!result.ok) {
321
+ const kind = systemEventsGrantKind(result);
322
+ const guidance = isMailAccessibilityDenial(result)
323
+ ? MAIL_ACCESSIBILITY_GUIDANCE
324
+ : MAIL_GUI_SCRIPTING_GUIDANCE;
325
+ return {
326
+ ok: false,
327
+ kind,
328
+ message: `${action} failed — attempted to ${summary}. ${guidance}`
329
+ };
330
+ }
331
+ return {
332
+ ok: true,
333
+ kind: null,
334
+ message: writeSuccessMessage(
335
+ action,
336
+ "System Events Automation allowed (process GUI scripting verified; nothing was typed)"
337
+ )
338
+ };
339
+ }
340
+
341
+ function systemEventsGrantKind(result) {
342
+ if (!result) return "unknown";
343
+ if (result.kind === "timeout" || result.kind === "tcc" || result.kind === "attribution") {
344
+ return result.kind;
345
+ }
346
+ if (isMailGuiScriptingUnavailable(result) || isMailAccessibilityDenial(result)) {
347
+ return "tcc";
348
+ }
349
+ return result.kind || "unknown";
350
+ }
351
+
277
352
  /**
278
353
  * AppleScript handlers: Tab into the compose body and type it (typed Returns
279
354
  * preserve newlines). Does not walk System Events `windows of process Mail`
280
- * (macOS 26 reports 0). Calibration reads Mail `content of` the outgoing
281
- * message — never `set content` / `html content` (FB11734014 cite-wrap).
355
+ * (macOS 26 reports 0). Calibration / verify uses the focused AX role (and
356
+ * AX value when it is readable) — never live Mail `content of` the outgoing
357
+ * message. Dual-host Mail 16 / macOS 26: `set content` reads back via
358
+ * `content of`, but Tab + keystroke/paste into the UI body never appears in
359
+ * `content of` (`typedSeen=false`, bodyLen 0) even when typing still produces
360
+ * a plain Sent `.emlx`. Never `set content` / `html content` (FB11734014).
282
361
  *
283
- * Tab count is effect-calibrated up to MAIL_BODY_TAB_MAX (Mini-proven 6).
284
- * Fail-closed if the body needle is missing after type (Cmd-V fallback) and
285
- * header checks. System Events Apple events are wrapped in `with timeout`.
362
+ * Tab count is unchanged (MAIL_BODY_TAB_MAX, Mini-proven 6). Fail-closed if
363
+ * AX focus is not a body role after Tab. Cmd-V only if AX value is readable
364
+ * and the needle is still missing. Header checks stay. System Events Apple
365
+ * events are wrapped in `with timeout`.
286
366
  */
287
367
  export function buildMailBodyPasteHandler() {
288
368
  const tabMax = asInteger(MAIL_BODY_TAB_MAX, { min: 1, max: 12, field: "tabMax" });
289
369
  const seSecs = asInteger(MAIL_SE_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 30, field: "seTimeout" });
290
- const probe = asString(MAIL_BODY_CALIBRATE_PROBE);
291
- return `on atmSplitParagraphs(bodyText)
370
+ const bodyMinH = asInteger(MAIL_BODY_MIN_AX_HEIGHT, { min: 1, max: 500, field: "bodyMinHeight" });
371
+ return `${buildAtmFocusedElementHandler()}
372
+
373
+ on atmSafeToPaste()
374
+ tell application "System Events"
375
+ tell process "Mail"
376
+ try
377
+ set fe to my atmFocusedElement()
378
+ if fe is missing value then return false
379
+ set r to (role of fe) as string
380
+ if r is "AXWindow" then return false
381
+ if r is "AXTextField" then return false
382
+ if r is "AXComboBox" then return false
383
+ if r is "AXButton" then return false
384
+ if r is "AXMenuButton" then return false
385
+ if r is "AXPopUpButton" then return false
386
+ set atmH to 0
387
+ try
388
+ set {atmW, atmH} to size of fe
389
+ end try
390
+ if r is "AXTextArea" and atmH > 0 and atmH < ${bodyMinH} then return false
391
+ ignoring case
392
+ set bits to ""
393
+ try
394
+ set bits to bits & (description of fe as string) & " "
395
+ end try
396
+ try
397
+ set bits to bits & (name of fe as string) & " "
398
+ end try
399
+ if bits contains "To:" then return false
400
+ if bits contains "Cc:" then return false
401
+ if bits contains "Bcc:" then return false
402
+ if bits contains "Subject" then return false
403
+ end ignoring
404
+ if r is "AXWebArea" then return true
405
+ if r is "AXTextArea" then return true
406
+ if atmH >= ${bodyMinH} then return true
407
+ end try
408
+ return false
409
+ end tell
410
+ end tell
411
+ end atmSafeToPaste
412
+
413
+ on atmAxFocusedValue()
414
+ tell application "System Events"
415
+ try
416
+ set fe to my atmFocusedElement()
417
+ if fe is missing value then return ""
418
+ return value of fe as string
419
+ end try
420
+ end tell
421
+ return ""
422
+ end atmAxFocusedValue
423
+
424
+ on atmSplitParagraphs(bodyText)
292
425
  set atmSavedDelim to AppleScript's text item delimiters
293
426
  set AppleScript's text item delimiters to return
294
427
  set atmCrParts to text items of bodyText
@@ -326,16 +459,6 @@ on atmKeystrokeText(atmText)
326
459
  end tell
327
460
  end atmKeystrokeText
328
461
 
329
- on atmMailBodyContains(msg, needle)
330
- tell application "Mail"
331
- try
332
- set atmGot to content of msg as string
333
- if atmGot contains needle then return true
334
- end try
335
- end tell
336
- return false
337
- end atmMailBodyContains
338
-
339
462
  on atmTabIntoMailBody(msg)
340
463
  tell application "Mail" to activate
341
464
  delay 0.25
@@ -346,23 +469,7 @@ on atmTabIntoMailBody(msg)
346
469
  end timeout
347
470
  delay 0.08
348
471
  repeat with atmN from 0 to ${tabMax}
349
- with timeout of ${seSecs} seconds
350
- tell application "System Events"
351
- tell process "Mail"
352
- keystroke ${probe}
353
- end tell
354
- end tell
355
- end timeout
356
- delay 0.08
357
- set atmHit to my atmMailBodyContains(msg, ${probe})
358
- with timeout of ${seSecs} seconds
359
- tell application "System Events"
360
- tell process "Mail"
361
- key code 51
362
- end tell
363
- end tell
364
- end timeout
365
- if atmHit then return
472
+ if my atmSafeToPaste() then return
366
473
  if atmN is ${tabMax} then error "${BODY_FOCUS_FAILED_SENTINEL}"
367
474
  with timeout of ${seSecs} seconds
368
475
  tell application "System Events"
@@ -423,11 +530,15 @@ end atmPasteMailBodyFallback
423
530
  on atmFillMailBody(bodyText, needle, msg)
424
531
  try
425
532
  atmTabIntoMailBody(msg)
533
+ if my atmSafeToPaste() is false then error "${BODY_FOCUS_FAILED_SENTINEL}"
426
534
  atmTypeMailBody(bodyText)
427
535
  delay 0.12
428
536
  if needle is not "" then
429
- if my atmMailBodyContains(msg, needle) is false then
430
- atmPasteMailBodyFallback(bodyText)
537
+ set atmAxVal to my atmAxFocusedValue()
538
+ if atmAxVal is not "" then
539
+ if atmAxVal does not contain needle then
540
+ atmPasteMailBodyFallback(bodyText)
541
+ end if
431
542
  end if
432
543
  end if
433
544
  on error errMsg number errNum
@@ -457,20 +568,10 @@ function composeMisdirectChecks({ to, cc = [], bcc = [], subject, body }) {
457
568
  })
458
569
  .join("\n");
459
570
  const bodyCheck = needle
460
- ? ` set atmGotBody to ""
461
- try
462
- set atmGotBody to content of newMessage as string
463
- end try
464
- if atmGotBody is "" then
465
- try
466
- save newMessage
467
- end try
468
- delay 0.2
469
- try
470
- set atmGotBody to content of newMessage as string
471
- end try
472
- end if
473
- if atmGotBody does not contain ${asString(needle)} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"`
571
+ ? ` set atmAxVal to my atmAxFocusedValue()
572
+ if atmAxVal is not "" then
573
+ if atmAxVal does not contain ${asString(needle)} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
574
+ end if`
474
575
  : "";
475
576
  return ` if (count of to recipients of newMessage) is not ${toCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
476
577
  if (count of cc recipients of newMessage) is not ${ccCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
@@ -490,10 +591,13 @@ ${bodyCheck}`;
490
591
  * Do not set AppleScript `content` or `html content`: Ventura+ FB11734014
491
592
  * cite-wraps those setters (`>` prefixes + `<blockquote type="cite">`).
492
593
  * Recipients and subject are AppleScript properties (never paste). The body
493
- * is typed into the native compose editor via System Events (Tab into body,
494
- * typed Returns for newlines, Cmd-V only if the body needle is still
495
- * missing). Do not walk `windows of process Mail`. If the body never
496
- * receives the intended text, or headers change, abort + delete (nothing sent).
594
+ * is typed into the native compose editor via System Events (Tab into body
595
+ * until AX focused role is a body field, typed Returns for newlines, Cmd-V
596
+ * only if AX value is readable and the needle is still missing). Do not walk
597
+ * `windows of process Mail`. Do not use Mail `content of` as the oracle that
598
+ * typed/pasted text landed (Mail 16 / macOS 26: that property stays empty).
599
+ * If AX body focus cannot be established, or headers change, abort + delete
600
+ * (nothing sent). Ship prove that the body landed is Sent `.emlx` after send.
497
601
  */
498
602
  export function buildComposeScript({ to, cc, bcc, subject, body, send, html = false }) {
499
603
  const recipients = [
@@ -1,27 +1,29 @@
1
1
  /**
2
2
  * First-run / upgrade permissions command.
3
3
  *
4
- * Probes Contacts, Calendar, Mail, and Messages under process.execPath so
5
- * macOS can pop Allow dialogs for that node binary. The user clicks Allow;
6
- * this command cannot grant silently.
4
+ * Probes Contacts, Calendar, Mail, Messages, and System Events under
5
+ * process.execPath so macOS can pop Allow dialogs for that node binary.
6
+ * The user clicks Allow; this command cannot grant silently.
7
7
  *
8
8
  * Always runs in this process (never via the write bridge): the point is to
9
9
  * attach the dialogs to process.execPath. If the invoked CLI / shebang
10
10
  * points at a different node, print a WARN — Allows attach to execPath.
11
11
  * Mail and Messages use the existing live Apple Events helpers — dry_run of
12
12
  * mail_send / messages_send never talks to those apps and does not count.
13
+ * System Events uses process / GUI scripting (`tell process`), not a soft
14
+ * application `get name` — that can succeed without AppleEvents auth.
13
15
  */
14
16
 
15
17
  import fs from "fs";
16
18
  import path from "path";
17
- import { classifyAppleScriptError } from "./appleScript.js";
18
- import { probeMailAutomation } from "./mailWrite.js";
19
+ import { classifyAppleScriptError, SYSTEM_EVENTS_STICKY_DENY_GUIDANCE } from "./appleScript.js";
20
+ import { probeMailAutomation, probeSystemEventsAutomation } from "./mailWrite.js";
19
21
  import { probeMessagesAutomation } from "./messagesWrite.js";
20
22
  import { probeContactsAutomation } from "./contactsWrite.js";
21
23
  import { probeCalendarAutomation } from "./calendarWrite.js";
22
24
  import { detectLaunchAgentContext, hostAutomationAdvice } from "./writeRouting.js";
23
25
 
24
- export const REQUIRED_SURFACES = ["Contacts", "Calendar", "Mail", "Messages"];
26
+ export const REQUIRED_SURFACES = ["Contacts", "Calendar", "Mail", "Messages", "System Events"];
25
27
 
26
28
  /** Mini ship-gate host example — not a universal path. */
27
29
  export const EXAMPLE_MINI_NODE = "/Users/petercoates/.local/node/bin/node";
@@ -172,6 +174,9 @@ export function classifyGrantStatus(result) {
172
174
  if (kind === "app_not_running") {
173
175
  return GRANT_ERROR;
174
176
  }
177
+ if (/accessibility_denied|assistive access|gui_scripting_unavailable/i.test(text)) {
178
+ return GRANT_MISSING;
179
+ }
175
180
  if (/tcc|automation deny|not authorized|not permitted|not allowed|timed out|etimedout|responsible-process|attribution/i.test(text)) {
176
181
  return GRANT_MISSING;
177
182
  }
@@ -198,7 +203,7 @@ export function exitCodeForGrants(grants) {
198
203
 
199
204
  /**
200
205
  * Advisory Full Disk Access touch. Read tools need FDA on this node; it is
201
- * not one of the four required Automation grants and never fails the command.
206
+ * not one of the required Automation grants and never fails the command.
202
207
  *
203
208
  * @returns {{ status: "readable"|"missing"|"skipped", message: string }}
204
209
  */
@@ -247,7 +252,8 @@ export function defaultPermissionsProbes() {
247
252
  Contacts: probeContactsAutomation,
248
253
  Calendar: probeCalendarAutomation,
249
254
  Mail: probeMailAutomation,
250
- Messages: probeMessagesAutomation
255
+ Messages: probeMessagesAutomation,
256
+ "System Events": probeSystemEventsAutomation
251
257
  };
252
258
  }
253
259
 
@@ -267,12 +273,15 @@ function bannerLines(binary, version) {
267
273
  "dry_run of mail_send / messages_send does not count: this command uses real Apple Events.",
268
274
  "Nothing is sent to third parties. Mail composes and discards a temporary outgoing message.",
269
275
  "Messages only enumerates accounts. Contacts creates and deletes a throwaway person in-script.",
270
- "Calendar lists calendars only — no events are created."
276
+ "Calendar lists calendars only — no events are created.",
277
+ "System Events is probed with process / GUI scripting (tell process + unix id; nothing is typed).",
278
+ "A soft tell System Events to get name is not enough — that can succeed without AppleEvents auth.",
279
+ "A prior Don't Allow is a sticky Deny: turn System Events on for this node in Automation, then re-run this command."
271
280
  ];
272
281
  }
273
282
 
274
283
  /**
275
- * Run the four live probes and print a grant report.
284
+ * Run the live Automation probes and print a grant report.
276
285
  *
277
286
  * @param {object} [options]
278
287
  * @param {string} [options.execPath]
@@ -349,11 +358,14 @@ export async function runPermissionsCommand({
349
358
 
350
359
  const code = exitCodeForGrants(grants);
351
360
  if (code === 0) {
352
- log("Result: PASS — Contacts, Calendar, Mail, and Messages are granted for this node.");
361
+ log("Result: PASS — Contacts, Calendar, Mail, Messages, and System Events are granted for this node.");
353
362
  log("Safe to re-run; already-granted surfaces report OK without another click.");
354
363
  } else {
355
364
  log("Result: INCOMPLETE — one or more required grants are missing or errored.");
356
365
  log(hostAdvice);
366
+ if (grants["System Events"] !== GRANT_GRANTED) {
367
+ log(SYSTEM_EVENTS_STICKY_DENY_GUIDANCE);
368
+ }
357
369
  log("Click Allow for the missing surfaces and re-run this command. Exit is non-zero (fail closed).");
358
370
  }
359
371
  return code;
@@ -91,7 +91,7 @@ export function terminalAutomationAdvice(execPath = process.execPath) {
91
91
  return (
92
92
  `Run this from Terminal.app (short-lived CLI / MacBook host — no always-on indexer). ` +
93
93
  `Click Allow for the printed process.execPath${printed}. ` +
94
- `Check System Settings → Privacy & Security → Automation for that node → Contacts, Calendar, Mail, and Messages. ` +
94
+ `Check System Settings → Privacy & Security → Automation for that node → Contacts, Calendar, Mail, Messages, and System Events. ` +
95
95
  `Do not start apple-tools-indexer.`
96
96
  );
97
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "MCP server for semantic search and write actions across Apple Mail, Messages, Calendar, and Contacts",
5
5
  "type": "module",
6
6
  "main": "index.js",