apple-tools-mcp 2.1.0 → 2.1.1

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 asked for its process name (nothing is typed) so the keystroke compose path can pop **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**. 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.
@@ -353,7 +353,7 @@ export const MAIL_ACCESSIBILITY_GUIDANCE =
353
353
  export const MAIL_GUI_SCRIPTING_GUIDANCE =
354
354
  "System Events GUI scripting is unavailable for this process, so the body cannot be typed into Mail. " +
355
355
  "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. " +
356
+ "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. " +
357
357
  "This is not a Mail send timeout and not a Mail Automation deny (-1743 / -10004).";
358
358
 
359
359
  /**
package/lib/mailWrite.js CHANGED
@@ -160,14 +160,14 @@ 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
+ /** 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
164
  export const MAIL_BODY_MIN_AX_HEIGHT = 50;
165
165
  /** Mini-proven Tab count from default compose focus into the body. */
166
166
  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. */
167
+ /** Cheap System Events Automation probe used before compose; SIGKILL if it still hangs. */
170
168
  export const MAIL_SE_PROBE_TIMEOUT_MS = 4000;
169
+ /** First-run / upgrade permissions probe: long enough for the Allow click. */
170
+ export const SYSTEM_EVENTS_AUTOMATION_PROBE_TIMEOUT_MS = 30000;
171
171
  /** Compose osascript budget (make + keystroke + send). Far below the ~60s SE wedge. */
172
172
  export const MAIL_COMPOSE_TIMEOUT_MS = 25000;
173
173
  export const MAIL_SE_APPLEEVENT_TIMEOUT_SEC = 8;
@@ -268,27 +268,127 @@ if atmUi is false then error "${ACCESSIBILITY_DENIED_SENTINEL}"
268
268
  return "OK"`;
269
269
  }
270
270
 
271
- export function probeSystemEventsGui() {
271
+ export function probeSystemEventsGui({ timeout = MAIL_SE_PROBE_TIMEOUT_MS } = {}) {
272
272
  return runAppleScript(buildSystemEventsProbeScript(), {
273
- timeout: MAIL_SE_PROBE_TIMEOUT_MS
273
+ timeout
274
274
  });
275
275
  }
276
276
 
277
+ /**
278
+ * First-run / upgrade probe: a live System Events Apple Event that pops
279
+ * Automation → System Events. Does not type into the focused UI.
280
+ * Timeout is long enough for the operator to click Allow.
281
+ * @returns {{ ok: boolean, message: string, kind: string|null }}
282
+ */
283
+ export function probeSystemEventsAutomation() {
284
+ const action = "system_events_automation_probe";
285
+ const summary = "ask System Events for its name (Automation / Apple Events check; nothing is typed)";
286
+ const result = probeSystemEventsGui({ timeout: SYSTEM_EVENTS_AUTOMATION_PROBE_TIMEOUT_MS });
287
+ if (!result.ok) {
288
+ const kind = systemEventsGrantKind(result);
289
+ const guidance = isMailAccessibilityDenial(result)
290
+ ? MAIL_ACCESSIBILITY_GUIDANCE
291
+ : MAIL_GUI_SCRIPTING_GUIDANCE;
292
+ return {
293
+ ok: false,
294
+ kind,
295
+ message: `${action} failed — attempted to ${summary}. ${guidance}`
296
+ };
297
+ }
298
+ return {
299
+ ok: true,
300
+ kind: null,
301
+ message: writeSuccessMessage(
302
+ action,
303
+ "System Events Automation allowed (process name read; nothing was typed)"
304
+ )
305
+ };
306
+ }
307
+
308
+ function systemEventsGrantKind(result) {
309
+ if (!result) return "unknown";
310
+ if (result.kind === "timeout" || result.kind === "tcc" || result.kind === "attribution") {
311
+ return result.kind;
312
+ }
313
+ if (isMailGuiScriptingUnavailable(result) || isMailAccessibilityDenial(result)) {
314
+ return "tcc";
315
+ }
316
+ return result.kind || "unknown";
317
+ }
318
+
277
319
  /**
278
320
  * AppleScript handlers: Tab into the compose body and type it (typed Returns
279
321
  * 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).
322
+ * (macOS 26 reports 0). Calibration / verify uses the focused AX role (and
323
+ * AX value when it is readable) — never live Mail `content of` the outgoing
324
+ * message. Dual-host Mail 16 / macOS 26: `set content` reads back via
325
+ * `content of`, but Tab + keystroke/paste into the UI body never appears in
326
+ * `content of` (`typedSeen=false`, bodyLen 0) even when typing still produces
327
+ * a plain Sent `.emlx`. Never `set content` / `html content` (FB11734014).
282
328
  *
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`.
329
+ * Tab count is unchanged (MAIL_BODY_TAB_MAX, Mini-proven 6). Fail-closed if
330
+ * AX focus is not a body role after Tab. Cmd-V only if AX value is readable
331
+ * and the needle is still missing. Header checks stay. System Events Apple
332
+ * events are wrapped in `with timeout`.
286
333
  */
287
334
  export function buildMailBodyPasteHandler() {
288
335
  const tabMax = asInteger(MAIL_BODY_TAB_MAX, { min: 1, max: 12, field: "tabMax" });
289
336
  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)
337
+ const bodyMinH = asInteger(MAIL_BODY_MIN_AX_HEIGHT, { min: 1, max: 500, field: "bodyMinHeight" });
338
+ return `${buildAtmFocusedElementHandler()}
339
+
340
+ on atmSafeToPaste()
341
+ tell application "System Events"
342
+ tell process "Mail"
343
+ try
344
+ set fe to my atmFocusedElement()
345
+ if fe is missing value then return false
346
+ set r to (role of fe) as string
347
+ if r is "AXWindow" then return false
348
+ if r is "AXTextField" then return false
349
+ if r is "AXComboBox" then return false
350
+ if r is "AXButton" then return false
351
+ if r is "AXMenuButton" then return false
352
+ if r is "AXPopUpButton" then return false
353
+ set atmH to 0
354
+ try
355
+ set {atmW, atmH} to size of fe
356
+ end try
357
+ if r is "AXTextArea" and atmH > 0 and atmH < ${bodyMinH} then return false
358
+ ignoring case
359
+ set bits to ""
360
+ try
361
+ set bits to bits & (description of fe as string) & " "
362
+ end try
363
+ try
364
+ set bits to bits & (name of fe as string) & " "
365
+ end try
366
+ if bits contains "To:" then return false
367
+ if bits contains "Cc:" then return false
368
+ if bits contains "Bcc:" then return false
369
+ if bits contains "Subject" then return false
370
+ end ignoring
371
+ if r is "AXWebArea" then return true
372
+ if r is "AXTextArea" then return true
373
+ if atmH >= ${bodyMinH} then return true
374
+ end try
375
+ return false
376
+ end tell
377
+ end tell
378
+ end atmSafeToPaste
379
+
380
+ on atmAxFocusedValue()
381
+ tell application "System Events"
382
+ try
383
+ set fe to my atmFocusedElement()
384
+ if fe is missing value then return ""
385
+ return value of fe as string
386
+ end try
387
+ end tell
388
+ return ""
389
+ end atmAxFocusedValue
390
+
391
+ on atmSplitParagraphs(bodyText)
292
392
  set atmSavedDelim to AppleScript's text item delimiters
293
393
  set AppleScript's text item delimiters to return
294
394
  set atmCrParts to text items of bodyText
@@ -326,16 +426,6 @@ on atmKeystrokeText(atmText)
326
426
  end tell
327
427
  end atmKeystrokeText
328
428
 
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
429
  on atmTabIntoMailBody(msg)
340
430
  tell application "Mail" to activate
341
431
  delay 0.25
@@ -346,23 +436,7 @@ on atmTabIntoMailBody(msg)
346
436
  end timeout
347
437
  delay 0.08
348
438
  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
439
+ if my atmSafeToPaste() then return
366
440
  if atmN is ${tabMax} then error "${BODY_FOCUS_FAILED_SENTINEL}"
367
441
  with timeout of ${seSecs} seconds
368
442
  tell application "System Events"
@@ -423,11 +497,15 @@ end atmPasteMailBodyFallback
423
497
  on atmFillMailBody(bodyText, needle, msg)
424
498
  try
425
499
  atmTabIntoMailBody(msg)
500
+ if my atmSafeToPaste() is false then error "${BODY_FOCUS_FAILED_SENTINEL}"
426
501
  atmTypeMailBody(bodyText)
427
502
  delay 0.12
428
503
  if needle is not "" then
429
- if my atmMailBodyContains(msg, needle) is false then
430
- atmPasteMailBodyFallback(bodyText)
504
+ set atmAxVal to my atmAxFocusedValue()
505
+ if atmAxVal is not "" then
506
+ if atmAxVal does not contain needle then
507
+ atmPasteMailBodyFallback(bodyText)
508
+ end if
431
509
  end if
432
510
  end if
433
511
  on error errMsg number errNum
@@ -457,20 +535,10 @@ function composeMisdirectChecks({ to, cc = [], bcc = [], subject, body }) {
457
535
  })
458
536
  .join("\n");
459
537
  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}"`
538
+ ? ` set atmAxVal to my atmAxFocusedValue()
539
+ if atmAxVal is not "" then
540
+ if atmAxVal does not contain ${asString(needle)} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
541
+ end if`
474
542
  : "";
475
543
  return ` if (count of to recipients of newMessage) is not ${toCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
476
544
  if (count of cc recipients of newMessage) is not ${ccCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
@@ -490,10 +558,13 @@ ${bodyCheck}`;
490
558
  * Do not set AppleScript `content` or `html content`: Ventura+ FB11734014
491
559
  * cite-wraps those setters (`>` prefixes + `<blockquote type="cite">`).
492
560
  * 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).
561
+ * is typed into the native compose editor via System Events (Tab into body
562
+ * until AX focused role is a body field, typed Returns for newlines, Cmd-V
563
+ * only if AX value is readable and the needle is still missing). Do not walk
564
+ * `windows of process Mail`. Do not use Mail `content of` as the oracle that
565
+ * typed/pasted text landed (Mail 16 / macOS 26: that property stays empty).
566
+ * If AX body focus cannot be established, or headers change, abort + delete
567
+ * (nothing sent). Ship prove that the body landed is Sent `.emlx` after send.
497
568
  */
498
569
  export function buildComposeScript({ to, cc, bcc, subject, body, send, html = false }) {
499
570
  const recipients = [
@@ -1,9 +1,9 @@
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
@@ -15,13 +15,13 @@
15
15
  import fs from "fs";
16
16
  import path from "path";
17
17
  import { classifyAppleScriptError } from "./appleScript.js";
18
- import { probeMailAutomation } from "./mailWrite.js";
18
+ import { probeMailAutomation, probeSystemEventsAutomation } from "./mailWrite.js";
19
19
  import { probeMessagesAutomation } from "./messagesWrite.js";
20
20
  import { probeContactsAutomation } from "./contactsWrite.js";
21
21
  import { probeCalendarAutomation } from "./calendarWrite.js";
22
22
  import { detectLaunchAgentContext, hostAutomationAdvice } from "./writeRouting.js";
23
23
 
24
- export const REQUIRED_SURFACES = ["Contacts", "Calendar", "Mail", "Messages"];
24
+ export const REQUIRED_SURFACES = ["Contacts", "Calendar", "Mail", "Messages", "System Events"];
25
25
 
26
26
  /** Mini ship-gate host example — not a universal path. */
27
27
  export const EXAMPLE_MINI_NODE = "/Users/petercoates/.local/node/bin/node";
@@ -172,6 +172,9 @@ export function classifyGrantStatus(result) {
172
172
  if (kind === "app_not_running") {
173
173
  return GRANT_ERROR;
174
174
  }
175
+ if (/accessibility_denied|assistive access|gui_scripting_unavailable/i.test(text)) {
176
+ return GRANT_MISSING;
177
+ }
175
178
  if (/tcc|automation deny|not authorized|not permitted|not allowed|timed out|etimedout|responsible-process|attribution/i.test(text)) {
176
179
  return GRANT_MISSING;
177
180
  }
@@ -198,7 +201,7 @@ export function exitCodeForGrants(grants) {
198
201
 
199
202
  /**
200
203
  * 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.
204
+ * not one of the required Automation grants and never fails the command.
202
205
  *
203
206
  * @returns {{ status: "readable"|"missing"|"skipped", message: string }}
204
207
  */
@@ -247,7 +250,8 @@ export function defaultPermissionsProbes() {
247
250
  Contacts: probeContactsAutomation,
248
251
  Calendar: probeCalendarAutomation,
249
252
  Mail: probeMailAutomation,
250
- Messages: probeMessagesAutomation
253
+ Messages: probeMessagesAutomation,
254
+ "System Events": probeSystemEventsAutomation
251
255
  };
252
256
  }
253
257
 
@@ -267,12 +271,13 @@ function bannerLines(binary, version) {
267
271
  "dry_run of mail_send / messages_send does not count: this command uses real Apple Events.",
268
272
  "Nothing is sent to third parties. Mail composes and discards a temporary outgoing message.",
269
273
  "Messages only enumerates accounts. Contacts creates and deletes a throwaway person in-script.",
270
- "Calendar lists calendars only — no events are created."
274
+ "Calendar lists calendars only — no events are created.",
275
+ "System Events is asked for its process name (mail_send keystrokes; nothing is typed)."
271
276
  ];
272
277
  }
273
278
 
274
279
  /**
275
- * Run the four live probes and print a grant report.
280
+ * Run the live Automation probes and print a grant report.
276
281
  *
277
282
  * @param {object} [options]
278
283
  * @param {string} [options.execPath]
@@ -349,7 +354,7 @@ export async function runPermissionsCommand({
349
354
 
350
355
  const code = exitCodeForGrants(grants);
351
356
  if (code === 0) {
352
- log("Result: PASS — Contacts, Calendar, Mail, and Messages are granted for this node.");
357
+ log("Result: PASS — Contacts, Calendar, Mail, Messages, and System Events are granted for this node.");
353
358
  log("Safe to re-run; already-granted surfaces report OK without another click.");
354
359
  } else {
355
360
  log("Result: INCOMPLETE — one or more required grants are missing or errored.");
@@ -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.1",
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",