apple-tools-mcp 2.1.2 → 2.1.4

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,15 @@ 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
+
483
+ **AXTitle compile (2.1.4).** Mini host: 2.1.3 `mail_send` never reached hit-test because `osacompile` failed **-2741** on bare `value of attribute "AXTitle" of atmEl` in `atmTopElemBits` / `atmTopElemExactTitle` (those handlers sit outside the System Events tell that walks top-level UI elements). Mini solution probe (`results-214/SOLUTION-PROBE-REPORT.md`): wrapping those AXTitle reads inside `tell application "System Events"` compiles (`osacompile` exit 0). Hit-test + fail-closed `AXTextField` + plain Sent behavior is unchanged from 2.1.3. Live Sent prove is after npm.
484
+
481
485
  **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
486
 
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.
487
+ **Compile prove (2.1.4 on the Mini host):** Unpatched `buildComposeScript` / `buildMailBodyPasteHandler` `osacompile` **-2741** on those bare AXTitle reads; patched AXTitle-inside-System-Events tell `osacompile` exit 0. Report: `results-214/SOLUTION-PROBE-REPORT.md`. Global install untouched. Live Sent is the 2.1.3 ship prove after npm.
488
+
489
+ **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
490
 
485
491
  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
492
 
@@ -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,34 @@ 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`.
468
+ *
469
+ * Mini 2.1.3 compile: bare `value of attribute "AXTitle"` in
470
+ * atmTopElemBits / atmTopElemExactTitle is osacompile **-2741** (those
471
+ * handlers sit outside the System Events tell that walks top-level UI
472
+ * elements). 2.1.4 wraps those AXTitle reads inside
473
+ * `tell application "System Events"` (Mini solution-probe PASS).
366
474
  */
367
475
  export function buildMailBodyPasteHandler() {
368
- const tabMax = asInteger(MAIL_BODY_TAB_MAX, { min: 1, max: 12, field: "tabMax" });
369
476
  const seSecs = asInteger(MAIL_SE_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 30, field: "seTimeout" });
370
477
  const bodyMinH = asInteger(MAIL_BODY_MIN_AX_HEIGHT, { min: 1, max: 500, field: "bodyMinHeight" });
478
+ const topMax = asInteger(MAIL_COMPOSE_TOP_UI_MAX, { min: 1, max: 64, field: "topUiMax" });
479
+ const jxaLit = asString(buildAtmAxHitTestJxa());
371
480
  return `${buildAtmFocusedElementHandler()}
372
481
 
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
482
  on atmAxFocusedValue()
414
483
  tell application "System Events"
415
484
  try
@@ -421,6 +490,105 @@ on atmAxFocusedValue()
421
490
  return ""
422
491
  end atmAxFocusedValue
423
492
 
493
+ on atmTopElemBits(atmEl)
494
+ set atmBits to ""
495
+ try
496
+ set atmBits to atmBits & (name of atmEl as string) & " "
497
+ end try
498
+ try
499
+ set atmBits to atmBits & (title of atmEl as string) & " "
500
+ end try
501
+ try
502
+ set atmBits to atmBits & (description of atmEl as string) & " "
503
+ end try
504
+ try
505
+ tell application "System Events"
506
+ set atmBits to atmBits & (value of attribute "AXTitle" of atmEl as string) & " "
507
+ end tell
508
+ end try
509
+ return atmBits
510
+ end atmTopElemBits
511
+
512
+ on atmTopElemExactTitle(atmEl, atmMarker)
513
+ try
514
+ if (name of atmEl as string) is atmMarker then return true
515
+ end try
516
+ try
517
+ if (title of atmEl as string) is atmMarker then return true
518
+ end try
519
+ try
520
+ tell application "System Events"
521
+ if (value of attribute "AXTitle" of atmEl as string) is atmMarker then return true
522
+ end tell
523
+ end try
524
+ return false
525
+ end atmTopElemExactTitle
526
+
527
+ on atmComposeFrameByTitle(atmMarker)
528
+ with timeout of ${seSecs} seconds
529
+ tell application "System Events"
530
+ tell process "Mail"
531
+ set atmElems to UI elements
532
+ set atmCount to count of atmElems
533
+ if atmCount > ${topMax} then set atmCount to ${topMax}
534
+ repeat with atmIdx from 1 to atmCount
535
+ set atmEl to item atmIdx of atmElems
536
+ set atmRole to ""
537
+ try
538
+ set atmRole to (role of atmEl as string)
539
+ end try
540
+ if atmRole is not "AXMenuBar" then
541
+ if my atmTopElemExactTitle(atmEl, atmMarker) then
542
+ set atmPos to position of atmEl
543
+ set atmSz to size of atmEl
544
+ return {item 1 of atmPos, item 2 of atmPos, item 1 of atmSz, item 2 of atmSz}
545
+ end if
546
+ end if
547
+ end repeat
548
+ repeat with atmIdx from 1 to atmCount
549
+ set atmEl to item atmIdx of atmElems
550
+ set atmRole to ""
551
+ try
552
+ set atmRole to (role of atmEl as string)
553
+ end try
554
+ if atmRole is not "AXMenuBar" then
555
+ set atmBits to my atmTopElemBits(atmEl)
556
+ ignoring case
557
+ if atmBits contains atmMarker then
558
+ set atmPos to position of atmEl
559
+ set atmSz to size of atmEl
560
+ return {item 1 of atmPos, item 2 of atmPos, item 1 of atmSz, item 2 of atmSz}
561
+ end if
562
+ end ignoring
563
+ end if
564
+ end repeat
565
+ end tell
566
+ end tell
567
+ end timeout
568
+ error "${BODY_FOCUS_FAILED_SENTINEL}"
569
+ end atmComposeFrameByTitle
570
+
571
+ on atmBodyHitPointList(atmFrameX, atmFrameY, atmFrameW, atmFrameH)
572
+ set atmMidX to (atmFrameX + (atmFrameW / 2)) as integer
573
+ set atmHitY1 to (atmFrameY + ((atmFrameH * 2) / 3)) as integer
574
+ set atmHitY2 to (atmFrameY + ((atmFrameH * 3) / 4)) as integer
575
+ set atmHitX2 to (atmFrameX + ((atmFrameW * 2) / 5)) as integer
576
+ set atmHitY3 to (atmFrameY + ((atmFrameH * 7) / 10)) as integer
577
+ return (atmMidX as string) & "," & (atmHitY1 as string) & ";" & (atmMidX as string) & "," & (atmHitY2 as string) & ";" & (atmHitX2 as string) & "," & (atmHitY3 as string)
578
+ end atmBodyHitPointList
579
+
580
+ on atmAxHitFill(atmPid, atmBody, atmNeedle, atmPoints)
581
+ set atmJxa to ${jxaLit}
582
+ set atmOut to ""
583
+ try
584
+ set atmOut to (run script atmJxa in "JavaScript" with parameters {atmPid, atmBody, atmNeedle, atmPoints, ${bodyMinH}}) as string
585
+ on error errMsg number errNum
586
+ if errMsg contains "assistive access" or errNum is -25211 then error "${ACCESSIBILITY_DENIED_SENTINEL}"
587
+ error errMsg number errNum
588
+ end try
589
+ return atmOut
590
+ end atmAxHitFill
591
+
424
592
  on atmSplitParagraphs(bodyText)
425
593
  set atmSavedDelim to AppleScript's text item delimiters
426
594
  set AppleScript's text item delimiters to return
@@ -459,30 +627,6 @@ on atmKeystrokeText(atmText)
459
627
  end tell
460
628
  end atmKeystrokeText
461
629
 
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
630
  on atmTypeMailBody(bodyText)
487
631
  set atmLines to my atmSplitParagraphs(bodyText)
488
632
  set atmLast to count of atmLines
@@ -527,17 +671,50 @@ on atmPasteMailBodyFallback(bodyText)
527
671
  if hadClip then set the clipboard to savedClip
528
672
  end atmPasteMailBodyFallback
529
673
 
530
- on atmFillMailBody(bodyText, needle, msg)
674
+ on atmFillMailBody(bodyText, needle, titleMarker, msg)
531
675
  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)
676
+ tell application "Mail" to activate
677
+ delay 0.25
678
+ with timeout of ${seSecs} seconds
679
+ tell application "System Events"
680
+ tell process "Mail" to set frontmost to true
681
+ end tell
682
+ end timeout
683
+ delay 0.08
684
+ set atmPid to 0
685
+ with timeout of ${seSecs} seconds
686
+ tell application "System Events"
687
+ tell process "Mail"
688
+ set atmPid to unix id
689
+ end tell
690
+ end tell
691
+ end timeout
692
+ if atmPid is 0 then error "${BODY_FOCUS_FAILED_SENTINEL}"
693
+ set atmFrame to my atmComposeFrameByTitle(titleMarker)
694
+ set atmFrameX to item 1 of atmFrame
695
+ set atmFrameY to item 2 of atmFrame
696
+ set atmFrameW to item 3 of atmFrame
697
+ set atmFrameH to item 4 of atmFrame
698
+ if atmFrameW < 40 or atmFrameH < ${bodyMinH} then error "${BODY_FOCUS_FAILED_SENTINEL}"
699
+ set atmPoints to my atmBodyHitPointList(atmFrameX, atmFrameY, atmFrameW, atmFrameH)
700
+ set atmHit to my atmAxHitFill(atmPid, bodyText, needle, atmPoints)
701
+ if atmHit is "${BODY_FOCUS_FAILED_SENTINEL}" then error "${BODY_FOCUS_FAILED_SENTINEL}"
702
+ if atmHit is "VALUE_MISS" then
703
+ with timeout of ${seSecs} seconds
704
+ tell application "System Events"
705
+ tell process "Mail"
706
+ keystroke "a" using command down
707
+ end tell
708
+ end tell
709
+ end timeout
710
+ atmTypeMailBody(bodyText)
711
+ delay 0.12
712
+ if needle is not "" then
713
+ set atmAxVal to my atmAxFocusedValue()
714
+ if atmAxVal is not "" then
715
+ if atmAxVal does not contain needle then
716
+ atmPasteMailBodyFallback(bodyText)
717
+ end if
541
718
  end if
542
719
  end if
543
720
  end if
@@ -591,13 +768,13 @@ ${bodyCheck}`;
591
768
  * Do not set AppleScript `content` or `html content`: Ventura+ FB11734014
592
769
  * cite-wraps those setters (`>` prefixes + `<blockquote type="cite">`).
593
770
  * 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.
771
+ * is filled via title-marker compose window + coordinate AX hit-test to the
772
+ * AXWebArea "message body" (AXFocused + AXValue; Mini-proven plain Sent).
773
+ * Do not walk `windows of process Mail`. Do not deep-walk UI elements.
774
+ * Do not use Mail `content of` as the oracle that text landed (Mail 16 /
775
+ * macOS 26: that property stays empty). If the body element is not found or
776
+ * is a short AXTextField, or headers change, abort + delete (nothing sent).
777
+ * Ship prove that the body landed is Sent `.emlx` after send.
601
778
  */
602
779
  export function buildComposeScript({ to, cc, bcc, subject, body, send, html = false }) {
603
780
  const recipients = [
@@ -609,8 +786,9 @@ export function buildComposeScript({ to, cc, bcc, subject, body, send, html = fa
609
786
  const mailSecs = asInteger(MAIL_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 60, field: "mailTimeout" });
610
787
  const plainBody = composeNativeBody(body, { html });
611
788
  const needle = composeBodyNeedle(plainBody);
789
+ const titleMarker = composeTitleMarker(subject);
612
790
  const pasteAndVerify = plainBody
613
- ? ` atmFillMailBody(${asString(plainBody)}, ${asString(needle)}, newMessage)
791
+ ? ` atmFillMailBody(${asString(plainBody)}, ${asString(needle)}, ${asString(titleMarker)}, newMessage)
614
792
  tell application "Mail"
615
793
  ${composeMisdirectChecks({ to, cc, bcc, subject, body: plainBody })}
616
794
  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.4",
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",