apple-tools-mcp 2.1.2 → 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
@@ -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
 
@@ -340,7 +340,7 @@ 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
 
@@ -355,7 +355,7 @@ export const SYSTEM_EVENTS_STICKY_DENY_GUIDANCE =
355
355
  /** System Events GUI scripting missing — not a Mail send hang. */
356
356
  export const MAIL_GUI_SCRIPTING_GUIDANCE =
357
357
  "System Events GUI scripting is unavailable for this process, so the body cannot be typed into Mail. " +
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). " +
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). " +
359
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
360
  SYSTEM_EVENTS_STICKY_DENY_GUIDANCE + " " +
361
361
  "This is not a Mail send timeout and not a Mail Automation deny (-1743 / -10004).";
package/lib/mailWrite.js CHANGED
@@ -162,10 +162,16 @@ export const GUI_SCRIPTING_UNAVAILABLE_SENTINEL = "GUI_SCRIPTING_UNAVAILABLE";
162
162
  export const SYSTEM_EVENTS_PROBE_MARKER = "ATM_SYSTEM_EVENTS_PROBE";
163
163
  /** Permissions-only marker: process / GUI scripting, not application `name`. */
164
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. */
165
+ /** Compose body AX height vs To/Subject/Cc fields (those are ~22px). Used to reject short header AXTextAreas; does not walk windows. */
166
166
  export const MAIL_BODY_MIN_AX_HEIGHT = 50;
167
- /** 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. */
168
168
  export const MAIL_BODY_TAB_MAX = 6;
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";
169
175
  /** Cheap pre-compose System Events check; SIGKILL if it still hangs. Not the permissions grant. */
170
176
  export const MAIL_SE_PROBE_TIMEOUT_MS = 4000;
171
177
  /** First-run / upgrade permissions probe: long enough for the Allow click. */
@@ -248,6 +254,101 @@ export function composeBodyNeedle(body) {
248
254
  return needle.length > 120 ? needle.slice(0, 120) : needle;
249
255
  }
250
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
+
251
352
  export function isSystemEventsProbeScript(script) {
252
353
  return String(script || "").includes(SYSTEM_EVENTS_PROBE_MARKER);
253
354
  }
@@ -350,66 +451,28 @@ function systemEventsGrantKind(result) {
350
451
  }
351
452
 
352
453
  /**
353
- * AppleScript handlers: Tab into the compose body and type it (typed Returns
354
- * preserve newlines). Does not walk System Events `windows of process Mail`
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).
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).
361
462
  *
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`.
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`.
366
468
  */
367
469
  export function buildMailBodyPasteHandler() {
368
- const tabMax = asInteger(MAIL_BODY_TAB_MAX, { min: 1, max: 12, field: "tabMax" });
369
470
  const seSecs = asInteger(MAIL_SE_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 30, field: "seTimeout" });
370
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());
371
474
  return `${buildAtmFocusedElementHandler()}
372
475
 
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
476
  on atmAxFocusedValue()
414
477
  tell application "System Events"
415
478
  try
@@ -421,6 +484,101 @@ on atmAxFocusedValue()
421
484
  return ""
422
485
  end atmAxFocusedValue
423
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
+
424
582
  on atmSplitParagraphs(bodyText)
425
583
  set atmSavedDelim to AppleScript's text item delimiters
426
584
  set AppleScript's text item delimiters to return
@@ -459,30 +617,6 @@ on atmKeystrokeText(atmText)
459
617
  end tell
460
618
  end atmKeystrokeText
461
619
 
462
- on atmTabIntoMailBody(msg)
463
- tell application "Mail" to activate
464
- delay 0.25
465
- with timeout of ${seSecs} seconds
466
- tell application "System Events"
467
- tell process "Mail" to set frontmost to true
468
- end tell
469
- end timeout
470
- delay 0.08
471
- repeat with atmN from 0 to ${tabMax}
472
- if my atmSafeToPaste() then return
473
- if atmN is ${tabMax} then error "${BODY_FOCUS_FAILED_SENTINEL}"
474
- with timeout of ${seSecs} seconds
475
- tell application "System Events"
476
- tell process "Mail"
477
- key code 48
478
- end tell
479
- end tell
480
- end timeout
481
- delay 0.08
482
- end repeat
483
- error "${BODY_FOCUS_FAILED_SENTINEL}"
484
- end atmTabIntoMailBody
485
-
486
620
  on atmTypeMailBody(bodyText)
487
621
  set atmLines to my atmSplitParagraphs(bodyText)
488
622
  set atmLast to count of atmLines
@@ -527,17 +661,50 @@ on atmPasteMailBodyFallback(bodyText)
527
661
  if hadClip then set the clipboard to savedClip
528
662
  end atmPasteMailBodyFallback
529
663
 
530
- on atmFillMailBody(bodyText, needle, msg)
664
+ on atmFillMailBody(bodyText, needle, titleMarker, msg)
531
665
  try
532
- atmTabIntoMailBody(msg)
533
- if my atmSafeToPaste() is false then error "${BODY_FOCUS_FAILED_SENTINEL}"
534
- atmTypeMailBody(bodyText)
535
- delay 0.12
536
- if needle is not "" then
537
- set atmAxVal to my atmAxFocusedValue()
538
- if atmAxVal is not "" then
539
- if atmAxVal does not contain needle then
540
- 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
541
708
  end if
542
709
  end if
543
710
  end if
@@ -591,13 +758,13 @@ ${bodyCheck}`;
591
758
  * Do not set AppleScript `content` or `html content`: Ventura+ FB11734014
592
759
  * cite-wraps those setters (`>` prefixes + `<blockquote type="cite">`).
593
760
  * Recipients and subject are AppleScript properties (never paste). The body
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.
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.
601
768
  */
602
769
  export function buildComposeScript({ to, cc, bcc, subject, body, send, html = false }) {
603
770
  const recipients = [
@@ -609,8 +776,9 @@ export function buildComposeScript({ to, cc, bcc, subject, body, send, html = fa
609
776
  const mailSecs = asInteger(MAIL_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 60, field: "mailTimeout" });
610
777
  const plainBody = composeNativeBody(body, { html });
611
778
  const needle = composeBodyNeedle(plainBody);
779
+ const titleMarker = composeTitleMarker(subject);
612
780
  const pasteAndVerify = plainBody
613
- ? ` atmFillMailBody(${asString(plainBody)}, ${asString(needle)}, newMessage)
781
+ ? ` atmFillMailBody(${asString(plainBody)}, ${asString(needle)}, ${asString(titleMarker)}, newMessage)
614
782
  tell application "Mail"
615
783
  ${composeMisdirectChecks({ to, cc, bcc, subject, body: plainBody })}
616
784
  end tell`
@@ -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.2",
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",