apple-tools-mcp 2.1.1 → 2.1.3

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
@@ -104,7 +104,7 @@ If the invoked CLI path and `process.execPath` differ, the command prints a **WA
104
104
 
105
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). System Events is asked for its process name (nothing is typed) so the keystroke compose path can pop **Allow**.
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
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
 
@@ -308,7 +308,7 @@ Missing `config.json` is fine — env then the 5-minute default apply.
308
308
 
309
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
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 type the body via keystrokes). 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.
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
312
 
313
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
314
 
@@ -464,7 +464,7 @@ Two arguments are available on **every** write tool:
464
464
 
465
465
  `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.
466
466
 
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).
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 fill the body into the compose window (subject title-marker + coordinate AX hit-test, then AXValue). 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
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
 
@@ -478,9 +478,11 @@ Keystrokes use System Events, so **node needs Accessibility** (Privacy & Securit
478
478
 
479
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.”
480
480
 
481
+ **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.
482
+
481
483
  **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`.
482
484
 
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.
485
+ **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.
484
486
 
485
487
  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.
486
488
 
@@ -603,7 +605,7 @@ The message names which process macOS was actually asking about. Work through it
603
605
 
604
606
  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`.
605
607
  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)).
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.
608
+ 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.
607
609
  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.
608
610
  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.
609
611
  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.
@@ -340,20 +340,24 @@ export const MAIL_TCC_GUIDANCE =
340
340
  */
341
341
  export const MAIL_ACCESSIBILITY_GUIDANCE =
342
342
  "macOS denied Accessibility (System Events could not drive Mail's compose window). " +
343
- "mail_send / mail_draft type or paste the body so Mail does not cite-wrap it (FB11734014); Mail's mailto command does not return an outgoing message on current Mini/MacBook Mail (-2753). " +
343
+ "mail_send / mail_draft fill the body via AX hit-test so Mail does not cite-wrap it (FB11734014); Mail's mailto command does not return an outgoing message on current Mini/MacBook Mail (-2753). " +
344
344
  "Allow node in System Settings → Privacy & Security → Accessibility, and keep Automation → Mail allowed. " +
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
- "mail_send / mail_draft use System Events keystrokes (Tab into the body, typed Returns) so Mail does not cite-wrap the Sent body (FB11734014). " +
358
+ "mail_send / mail_draft locate the compose body via System Events (title-marker + AX hit-test) so Mail does not cite-wrap the Sent body (FB11734014). " +
356
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,11 +160,19 @@ 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). Used to reject short header AXTextAreas after Tab; does not walk windows. */
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; does not walk windows. */
164
166
  export const MAIL_BODY_MIN_AX_HEIGHT = 50;
165
- /** Mini-proven Tab count from default compose focus into the body. */
167
+ /** Historical Mini Tab count. 2.1.3 finds the body via title-marker + AX hit-test, not Tab. */
166
168
  export const MAIL_BODY_TAB_MAX = 6;
167
- /** Cheap System Events Automation probe used before compose; SIGKILL if it still hangs. */
169
+ /** Mail window titles may truncate; match this many leading subject characters. */
170
+ export const MAIL_COMPOSE_TITLE_MARKER_MAX = 60;
171
+ /** Top-level `UI elements` of process Mail only — never a deep tree walk. */
172
+ export const MAIL_COMPOSE_TOP_UI_MAX = 48;
173
+ /** Marker inside the JXA hit-test so tests can pin the proven Mini path. */
174
+ export const ATM_AX_HIT_TEST_MARKER = "ATM_AX_HIT_TEST";
175
+ /** Cheap pre-compose System Events check; SIGKILL if it still hangs. Not the permissions grant. */
168
176
  export const MAIL_SE_PROBE_TIMEOUT_MS = 4000;
169
177
  /** First-run / upgrade permissions probe: long enough for the Allow click. */
170
178
  export const SYSTEM_EVENTS_AUTOMATION_PROBE_TIMEOUT_MS = 30000;
@@ -246,10 +254,110 @@ export function composeBodyNeedle(body) {
246
254
  return needle.length > 120 ? needle.slice(0, 120) : needle;
247
255
  }
248
256
 
257
+ /**
258
+ * Subject fragment used to identify the compose window among top-level
259
+ * System Events UI elements. Mail's window title is the subject after
260
+ * `make new outgoing message`; empty drafts show "New Message".
261
+ */
262
+ export function composeTitleMarker(subject) {
263
+ const text = subject === undefined || subject === null ? "" : String(subject).trim();
264
+ if (!text) return "New Message";
265
+ return text.length > MAIL_COMPOSE_TITLE_MARKER_MAX
266
+ ? text.slice(0, MAIL_COMPOSE_TITLE_MARKER_MAX)
267
+ : text;
268
+ }
269
+
270
+ /**
271
+ * JXA AX hit-test: `AXUIElementCopyElementAtPosition` at compose-body
272
+ * coordinates, then AXFocused + AXValue on the `AXWebArea` "message body".
273
+ * Mini-proven (computer-use): deep UI-element walks truncate before the
274
+ * WebArea; a coordinate hit-test does not. No mouse click.
275
+ */
276
+ export function buildAtmAxHitTestJxa() {
277
+ return `ObjC.import("ApplicationServices");
278
+ ObjC.import("Foundation");
279
+ function axStr(el, attr) {
280
+ var out = Ref();
281
+ if ($.AXUIElementCopyAttributeValue(el, attr, out) !== 0) return "";
282
+ if (out[0] == null) return "";
283
+ try { return ObjC.unwrap(out[0]).toString(); } catch (e) { return ""; }
284
+ }
285
+ function axHeight(el) {
286
+ var out = Ref();
287
+ if ($.AXUIElementCopyAttributeValue(el, "AXSize", out) !== 0 || out[0] == null) return 0;
288
+ try {
289
+ var u = ObjC.unwrap(out[0]);
290
+ if (u && typeof u.height === "number") return u.height;
291
+ if (u && u.Height != null) return Number(u.Height);
292
+ if (u && u[1] != null) return Number(u[1]);
293
+ } catch (e) {}
294
+ return 0;
295
+ }
296
+ function axBits(el) {
297
+ return [axStr(el, "AXDescription"), axStr(el, "AXTitle"), axStr(el, "AXRoleDescription")].join(" ");
298
+ }
299
+ function isHeaderHit(role, bits, height, minH) {
300
+ if (role === "AXTextField" || role === "AXComboBox" || role === "AXButton" || role === "AXMenuButton" || role === "AXPopUpButton" || role === "AXWindow") return true;
301
+ if (role === "AXTextArea" && height > 0 && height < minH) return true;
302
+ var lower = bits.toLowerCase();
303
+ if (lower.indexOf("to:") !== -1) return true;
304
+ if (lower.indexOf("cc:") !== -1) return true;
305
+ if (lower.indexOf("bcc:") !== -1) return true;
306
+ if (lower.indexOf("subject") !== -1 && lower.indexOf("message body") === -1) return true;
307
+ return false;
308
+ }
309
+ function isMailBodyHit(role, bits) {
310
+ if (role !== "AXWebArea") return false;
311
+ return bits.toLowerCase().indexOf("message body") !== -1;
312
+ }
313
+ function run(argv) {
314
+ // ${ATM_AX_HIT_TEST_MARKER}
315
+ var pid = parseInt(argv[0], 10);
316
+ var body = String(argv[1] || "");
317
+ var needle = String(argv[2] || "");
318
+ var pointStr = String(argv[3] || "");
319
+ var minH = parseInt(argv[4], 10);
320
+ if (!minH) minH = 50;
321
+ if (!pid) return "BODY_FOCUS_FAILED";
322
+ var app = $.AXUIElementCreateApplication(pid);
323
+ var points = pointStr.split(";");
324
+ var chosen = null;
325
+ for (var i = 0; i < points.length; i++) {
326
+ var xy = points[i].split(",");
327
+ var x = parseFloat(xy[0]);
328
+ var y = parseFloat(xy[1]);
329
+ if (!isFinite(x) || !isFinite(y)) continue;
330
+ var hit = Ref();
331
+ var err = $.AXUIElementCopyElementAtPosition(app, x, y, hit);
332
+ if (err !== 0 || hit[0] == null) continue;
333
+ var el = hit[0];
334
+ var role = axStr(el, "AXRole");
335
+ var bits = axBits(el);
336
+ var height = axHeight(el);
337
+ if (isHeaderHit(role, bits, height, minH)) continue;
338
+ if (isMailBodyHit(role, bits)) {
339
+ chosen = el;
340
+ break;
341
+ }
342
+ }
343
+ if (!chosen) return "BODY_FOCUS_FAILED";
344
+ try { $.AXUIElementSetAttributeValue(chosen, "AXFocused", true); } catch (e) {}
345
+ try { $.AXUIElementSetAttributeValue(chosen, "AXValue", body); } catch (e) {}
346
+ var val = axStr(chosen, "AXValue");
347
+ if (val !== "" && needle !== "" && val.indexOf(needle) === -1) return "VALUE_MISS";
348
+ return "OK";
349
+ }`;
350
+ }
351
+
249
352
  export function isSystemEventsProbeScript(script) {
250
353
  return String(script || "").includes(SYSTEM_EVENTS_PROBE_MARKER);
251
354
  }
252
355
 
356
+ export function isSystemEventsAutomationProbeScript(script) {
357
+ return String(script || "").includes(SYSTEM_EVENTS_AUTOMATION_PROBE_MARKER);
358
+ }
359
+
360
+ /** Cheap pre-compose check. Do not use for `permissions` — `get name` can soft-pass. */
253
361
  export function buildSystemEventsProbeScript() {
254
362
  const secs = asInteger(MAIL_SE_PROBE_APPLEEVENT_TIMEOUT_SEC, {
255
363
  min: 1,
@@ -275,15 +383,41 @@ export function probeSystemEventsGui({ timeout = MAIL_SE_PROBE_TIMEOUT_MS } = {}
275
383
  }
276
384
 
277
385
  /**
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.
386
+ * First-run / upgrade probe. Soft `tell application "System Events" to get
387
+ * name` is not enough — Mini @2.1.1 reported granted while TCC auth_value=0
388
+ * for node → com.apple.systemevents. Process / GUI scripting (`tell process`)
389
+ * is the grant mail_send actually needs. Does not type into the focused UI.
390
+ * Timeout is long enough for the operator to click Allow. No short inner
391
+ * AppleEvent timeout (same as the Mail compose-and-discard probe).
392
+ */
393
+ export function buildSystemEventsAutomationProbeScript() {
394
+ return `-- ${SYSTEM_EVENTS_AUTOMATION_PROBE_MARKER}
395
+ tell application "System Events"
396
+ tell process "System Events"
397
+ set atmPid to unix id
398
+ set atmShown to name
399
+ if atmShown is "" then error "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}"
400
+ if atmPid is missing value then error "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}"
401
+ if (atmPid as integer) is less than or equal to 0 then error "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}"
402
+ count of UI elements
403
+ end tell
404
+ set atmUi to UI elements enabled
405
+ end tell
406
+ if atmUi is false then error "${ACCESSIBILITY_DENIED_SENTINEL}"
407
+ return "OK"`;
408
+ }
409
+
410
+ /**
411
+ * First-run / upgrade probe: a live System Events GUI-scripting Apple Event
412
+ * that creates or verifies Automation → System Events. Does not type.
281
413
  * @returns {{ ok: boolean, message: string, kind: string|null }}
282
414
  */
283
415
  export function probeSystemEventsAutomation() {
284
416
  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 });
417
+ const summary = "verify System Events process / GUI scripting (Automation / Apple Events grant; nothing is typed)";
418
+ const result = runAppleScript(buildSystemEventsAutomationProbeScript(), {
419
+ timeout: SYSTEM_EVENTS_AUTOMATION_PROBE_TIMEOUT_MS
420
+ });
287
421
  if (!result.ok) {
288
422
  const kind = systemEventsGrantKind(result);
289
423
  const guidance = isMailAccessibilityDenial(result)
@@ -300,7 +434,7 @@ export function probeSystemEventsAutomation() {
300
434
  kind: null,
301
435
  message: writeSuccessMessage(
302
436
  action,
303
- "System Events Automation allowed (process name read; nothing was typed)"
437
+ "System Events Automation allowed (process GUI scripting verified; nothing was typed)"
304
438
  )
305
439
  };
306
440
  }
@@ -317,66 +451,28 @@ function systemEventsGrantKind(result) {
317
451
  }
318
452
 
319
453
  /**
320
- * AppleScript handlers: Tab into the compose body and type it (typed Returns
321
- * preserve newlines). Does not walk System Events `windows of process Mail`
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).
454
+ * AppleScript handlers: find the compose window by subject title among
455
+ * top-level System Events UI elements (macOS 26 `windows of process Mail`
456
+ * is 0), then coordinate AX hit-test (`AXUIElementCopyElementAtPosition`)
457
+ * to the `AXWebArea` "message body". Mini computer-use: deep UI-element
458
+ * walks truncate after ~23 header/toolbar nodes and never reach the body;
459
+ * AXFocusedUIElement role coerce fails (-1700). No mouse click. No
460
+ * `entire contents`. Never live Mail `content of` (Mail 16 stays empty
461
+ * after UI fill). Never `set content` / `html content` (FB11734014).
328
462
  *
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`.
463
+ * Hit must be AXWebArea "message body"; a short AXTextField is fail-closed
464
+ * (BODY_FOCUS_FAILED). Focus + AXValue (Mini: plain Sent .emlx, cite=0).
465
+ * Typed Returns / Cmd-V only if a readable AX value is still missing the
466
+ * needle. Header checks stay. System Events Apple events are wrapped in
467
+ * `with timeout`.
333
468
  */
334
469
  export function buildMailBodyPasteHandler() {
335
- const tabMax = asInteger(MAIL_BODY_TAB_MAX, { min: 1, max: 12, field: "tabMax" });
336
470
  const seSecs = asInteger(MAIL_SE_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 30, field: "seTimeout" });
337
471
  const bodyMinH = asInteger(MAIL_BODY_MIN_AX_HEIGHT, { min: 1, max: 500, field: "bodyMinHeight" });
472
+ const topMax = asInteger(MAIL_COMPOSE_TOP_UI_MAX, { min: 1, max: 64, field: "topUiMax" });
473
+ const jxaLit = asString(buildAtmAxHitTestJxa());
338
474
  return `${buildAtmFocusedElementHandler()}
339
475
 
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
476
  on atmAxFocusedValue()
381
477
  tell application "System Events"
382
478
  try
@@ -388,6 +484,101 @@ on atmAxFocusedValue()
388
484
  return ""
389
485
  end atmAxFocusedValue
390
486
 
487
+ on atmTopElemBits(atmEl)
488
+ set atmBits to ""
489
+ try
490
+ set atmBits to atmBits & (name of atmEl as string) & " "
491
+ end try
492
+ try
493
+ set atmBits to atmBits & (title of atmEl as string) & " "
494
+ end try
495
+ try
496
+ set atmBits to atmBits & (description of atmEl as string) & " "
497
+ end try
498
+ try
499
+ set atmBits to atmBits & (value of attribute "AXTitle" of atmEl as string) & " "
500
+ end try
501
+ return atmBits
502
+ end atmTopElemBits
503
+
504
+ on atmTopElemExactTitle(atmEl, atmMarker)
505
+ try
506
+ if (name of atmEl as string) is atmMarker then return true
507
+ end try
508
+ try
509
+ if (title of atmEl as string) is atmMarker then return true
510
+ end try
511
+ try
512
+ if (value of attribute "AXTitle" of atmEl as string) is atmMarker then return true
513
+ end try
514
+ return false
515
+ end atmTopElemExactTitle
516
+
517
+ on atmComposeFrameByTitle(atmMarker)
518
+ with timeout of ${seSecs} seconds
519
+ tell application "System Events"
520
+ tell process "Mail"
521
+ set atmElems to UI elements
522
+ set atmCount to count of atmElems
523
+ if atmCount > ${topMax} then set atmCount to ${topMax}
524
+ repeat with atmIdx from 1 to atmCount
525
+ set atmEl to item atmIdx of atmElems
526
+ set atmRole to ""
527
+ try
528
+ set atmRole to (role of atmEl as string)
529
+ end try
530
+ if atmRole is not "AXMenuBar" then
531
+ if my atmTopElemExactTitle(atmEl, atmMarker) then
532
+ set atmPos to position of atmEl
533
+ set atmSz to size of atmEl
534
+ return {item 1 of atmPos, item 2 of atmPos, item 1 of atmSz, item 2 of atmSz}
535
+ end if
536
+ end if
537
+ end repeat
538
+ repeat with atmIdx from 1 to atmCount
539
+ set atmEl to item atmIdx of atmElems
540
+ set atmRole to ""
541
+ try
542
+ set atmRole to (role of atmEl as string)
543
+ end try
544
+ if atmRole is not "AXMenuBar" then
545
+ set atmBits to my atmTopElemBits(atmEl)
546
+ ignoring case
547
+ if atmBits contains atmMarker then
548
+ set atmPos to position of atmEl
549
+ set atmSz to size of atmEl
550
+ return {item 1 of atmPos, item 2 of atmPos, item 1 of atmSz, item 2 of atmSz}
551
+ end if
552
+ end ignoring
553
+ end if
554
+ end repeat
555
+ end tell
556
+ end tell
557
+ end timeout
558
+ error "${BODY_FOCUS_FAILED_SENTINEL}"
559
+ end atmComposeFrameByTitle
560
+
561
+ on atmBodyHitPointList(atmFrameX, atmFrameY, atmFrameW, atmFrameH)
562
+ set atmMidX to (atmFrameX + (atmFrameW / 2)) as integer
563
+ set atmHitY1 to (atmFrameY + ((atmFrameH * 2) / 3)) as integer
564
+ set atmHitY2 to (atmFrameY + ((atmFrameH * 3) / 4)) as integer
565
+ set atmHitX2 to (atmFrameX + ((atmFrameW * 2) / 5)) as integer
566
+ set atmHitY3 to (atmFrameY + ((atmFrameH * 7) / 10)) as integer
567
+ return (atmMidX as string) & "," & (atmHitY1 as string) & ";" & (atmMidX as string) & "," & (atmHitY2 as string) & ";" & (atmHitX2 as string) & "," & (atmHitY3 as string)
568
+ end atmBodyHitPointList
569
+
570
+ on atmAxHitFill(atmPid, atmBody, atmNeedle, atmPoints)
571
+ set atmJxa to ${jxaLit}
572
+ set atmOut to ""
573
+ try
574
+ set atmOut to (run script atmJxa in "JavaScript" with parameters {atmPid, atmBody, atmNeedle, atmPoints, ${bodyMinH}}) as string
575
+ on error errMsg number errNum
576
+ if errMsg contains "assistive access" or errNum is -25211 then error "${ACCESSIBILITY_DENIED_SENTINEL}"
577
+ error errMsg number errNum
578
+ end try
579
+ return atmOut
580
+ end atmAxHitFill
581
+
391
582
  on atmSplitParagraphs(bodyText)
392
583
  set atmSavedDelim to AppleScript's text item delimiters
393
584
  set AppleScript's text item delimiters to return
@@ -426,30 +617,6 @@ on atmKeystrokeText(atmText)
426
617
  end tell
427
618
  end atmKeystrokeText
428
619
 
429
- on atmTabIntoMailBody(msg)
430
- tell application "Mail" to activate
431
- delay 0.25
432
- with timeout of ${seSecs} seconds
433
- tell application "System Events"
434
- tell process "Mail" to set frontmost to true
435
- end tell
436
- end timeout
437
- delay 0.08
438
- repeat with atmN from 0 to ${tabMax}
439
- if my atmSafeToPaste() then return
440
- if atmN is ${tabMax} then error "${BODY_FOCUS_FAILED_SENTINEL}"
441
- with timeout of ${seSecs} seconds
442
- tell application "System Events"
443
- tell process "Mail"
444
- key code 48
445
- end tell
446
- end tell
447
- end timeout
448
- delay 0.08
449
- end repeat
450
- error "${BODY_FOCUS_FAILED_SENTINEL}"
451
- end atmTabIntoMailBody
452
-
453
620
  on atmTypeMailBody(bodyText)
454
621
  set atmLines to my atmSplitParagraphs(bodyText)
455
622
  set atmLast to count of atmLines
@@ -494,17 +661,50 @@ on atmPasteMailBodyFallback(bodyText)
494
661
  if hadClip then set the clipboard to savedClip
495
662
  end atmPasteMailBodyFallback
496
663
 
497
- on atmFillMailBody(bodyText, needle, msg)
664
+ on atmFillMailBody(bodyText, needle, titleMarker, msg)
498
665
  try
499
- atmTabIntoMailBody(msg)
500
- if my atmSafeToPaste() is false then error "${BODY_FOCUS_FAILED_SENTINEL}"
501
- atmTypeMailBody(bodyText)
502
- delay 0.12
503
- if needle is not "" then
504
- set atmAxVal to my atmAxFocusedValue()
505
- if atmAxVal is not "" then
506
- if atmAxVal does not contain needle then
507
- atmPasteMailBodyFallback(bodyText)
666
+ tell application "Mail" to activate
667
+ delay 0.25
668
+ with timeout of ${seSecs} seconds
669
+ tell application "System Events"
670
+ tell process "Mail" to set frontmost to true
671
+ end tell
672
+ end timeout
673
+ delay 0.08
674
+ set atmPid to 0
675
+ with timeout of ${seSecs} seconds
676
+ tell application "System Events"
677
+ tell process "Mail"
678
+ set atmPid to unix id
679
+ end tell
680
+ end tell
681
+ end timeout
682
+ if atmPid is 0 then error "${BODY_FOCUS_FAILED_SENTINEL}"
683
+ set atmFrame to my atmComposeFrameByTitle(titleMarker)
684
+ set atmFrameX to item 1 of atmFrame
685
+ set atmFrameY to item 2 of atmFrame
686
+ set atmFrameW to item 3 of atmFrame
687
+ set atmFrameH to item 4 of atmFrame
688
+ if atmFrameW < 40 or atmFrameH < ${bodyMinH} then error "${BODY_FOCUS_FAILED_SENTINEL}"
689
+ set atmPoints to my atmBodyHitPointList(atmFrameX, atmFrameY, atmFrameW, atmFrameH)
690
+ set atmHit to my atmAxHitFill(atmPid, bodyText, needle, atmPoints)
691
+ if atmHit is "${BODY_FOCUS_FAILED_SENTINEL}" then error "${BODY_FOCUS_FAILED_SENTINEL}"
692
+ if atmHit is "VALUE_MISS" then
693
+ with timeout of ${seSecs} seconds
694
+ tell application "System Events"
695
+ tell process "Mail"
696
+ keystroke "a" using command down
697
+ end tell
698
+ end tell
699
+ end timeout
700
+ atmTypeMailBody(bodyText)
701
+ delay 0.12
702
+ if needle is not "" then
703
+ set atmAxVal to my atmAxFocusedValue()
704
+ if atmAxVal is not "" then
705
+ if atmAxVal does not contain needle then
706
+ atmPasteMailBodyFallback(bodyText)
707
+ end if
508
708
  end if
509
709
  end if
510
710
  end if
@@ -558,13 +758,13 @@ ${bodyCheck}`;
558
758
  * Do not set AppleScript `content` or `html content`: Ventura+ FB11734014
559
759
  * cite-wraps those setters (`>` prefixes + `<blockquote type="cite">`).
560
760
  * Recipients and subject are AppleScript properties (never paste). The body
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.
761
+ * is filled via title-marker compose window + coordinate AX hit-test to the
762
+ * AXWebArea "message body" (AXFocused + AXValue; Mini-proven plain Sent).
763
+ * Do not walk `windows of process Mail`. Do not deep-walk UI elements.
764
+ * Do not use Mail `content of` as the oracle that text landed (Mail 16 /
765
+ * macOS 26: that property stays empty). If the body element is not found or
766
+ * is a short AXTextField, or headers change, abort + delete (nothing sent).
767
+ * Ship prove that the body landed is Sent `.emlx` after send.
568
768
  */
569
769
  export function buildComposeScript({ to, cc, bcc, subject, body, send, html = false }) {
570
770
  const recipients = [
@@ -576,8 +776,9 @@ export function buildComposeScript({ to, cc, bcc, subject, body, send, html = fa
576
776
  const mailSecs = asInteger(MAIL_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 60, field: "mailTimeout" });
577
777
  const plainBody = composeNativeBody(body, { html });
578
778
  const needle = composeBodyNeedle(plainBody);
779
+ const titleMarker = composeTitleMarker(subject);
579
780
  const pasteAndVerify = plainBody
580
- ? ` atmFillMailBody(${asString(plainBody)}, ${asString(needle)}, newMessage)
781
+ ? ` atmFillMailBody(${asString(plainBody)}, ${asString(needle)}, ${asString(titleMarker)}, newMessage)
581
782
  tell application "Mail"
582
783
  ${composeMisdirectChecks({ to, cc, bcc, subject, body: plainBody })}
583
784
  end tell`
@@ -10,11 +10,13 @@
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";
19
+ import { classifyAppleScriptError, SYSTEM_EVENTS_STICKY_DENY_GUIDANCE } from "./appleScript.js";
18
20
  import { probeMailAutomation, probeSystemEventsAutomation } from "./mailWrite.js";
19
21
  import { probeMessagesAutomation } from "./messagesWrite.js";
20
22
  import { probeContactsAutomation } from "./contactsWrite.js";
@@ -272,7 +274,9 @@ function bannerLines(binary, version) {
272
274
  "Nothing is sent to third parties. Mail composes and discards a temporary outgoing message.",
273
275
  "Messages only enumerates accounts. Contacts creates and deletes a throwaway person in-script.",
274
276
  "Calendar lists calendars only — no events are created.",
275
- "System Events is asked for its process name (mail_send keystrokes; nothing is typed)."
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."
276
280
  ];
277
281
  }
278
282
 
@@ -359,6 +363,9 @@ export async function runPermissionsCommand({
359
363
  } else {
360
364
  log("Result: INCOMPLETE — one or more required grants are missing or errored.");
361
365
  log(hostAdvice);
366
+ if (grants["System Events"] !== GRANT_GRANTED) {
367
+ log(SYSTEM_EVENTS_STICKY_DENY_GUIDANCE);
368
+ }
362
369
  log("Click Allow for the missing surfaces and re-run this command. Exit is non-zero (fail closed).");
363
370
  }
364
371
  return code;
@@ -102,7 +102,7 @@ export function terminalAutomationAdvice(execPath = process.execPath) {
102
102
  export function miniLaunchAgentAdvice() {
103
103
  return (
104
104
  "This Mac has a Mini write-bridge / LaunchAgent. Grant the daemon's node Full Disk Access (reads) and " +
105
- "Allow that node in System Settings → Privacy & Security → Automation for Mail.app, Messages.app, Contacts.app, Calendar.app, and System Events (mail_send / mail_draft type the body via keystrokes). " +
105
+ "Allow that node in System Settings → Privacy & Security → Automation for Mail.app, Messages.app, Contacts.app, Calendar.app, and System Events (mail_send / mail_draft fill the body via AX hit-test). " +
106
106
  "Do not add node via + in the Contacts or Calendars privacy lists."
107
107
  );
108
108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
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",