apple-tools-mcp 2.1.3 → 2.1.5

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
@@ -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 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).
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 paste the body into the compose window (exact subject title, role `AXWebArea`, proven focus, Cmd-A + clipboard). 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
 
@@ -480,8 +480,14 @@ Keystrokes use System Events, so **node needs Accessibility** (Privacy & Securit
480
480
 
481
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
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
+
483
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`.
484
486
 
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
+ **Probe 3 body delivery (2.1.5).** Mini Probe 3 (`atm-p3-deliver.applescript`, classic AppleScript / System Events, not JXA) proved a click-free path: exact subject window title, first `AXWebArea` from `entire contents` of that window, `set focused of` that element to true, then an independent `AXFocusedUIElement` re-read that must match role `AXWebArea` and position/size before any keystroke. Delivery is Cmd-A + clipboard paste. No Mail `content` / `html content`. No `AXValue` write (`AXUIElementSetAttributeValue` on content is a silent no-op; 2.1.4 `atmAxHitFill` returned `OK` when that value was empty, so the keystroke recovery never ran). No coordinate `click at` (Probe 3: a click can land on another Space or app). Focus mismatch is `BODY_FOCUS_UNPROVEN`: the draft is deleted and nothing is sent. The post-paste body needle runs only when the focused element is still that proven body, so a To: recipient chip (`U+FFFC`) is not `BODY_PASTE_MISDIRECTED`. Sent/Outbox verify, `dry_run`, and confirm are unchanged.
490
+
485
491
  **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.
486
492
 
487
493
  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.
package/lib/mailWrite.js CHANGED
@@ -158,6 +158,10 @@ export function probeMailAutomation() {
158
158
  export const ACCESSIBILITY_DENIED_SENTINEL = "ACCESSIBILITY_DENIED";
159
159
  export const BODY_PASTE_MISDIRECTED_SENTINEL = "BODY_PASTE_MISDIRECTED";
160
160
  export const BODY_FOCUS_FAILED_SENTINEL = "BODY_FOCUS_FAILED";
161
+ /** Focus request ran, but the re-read AXFocusedUIElement was not the chosen body. */
162
+ export const BODY_FOCUS_UNPROVEN_SENTINEL = "BODY_FOCUS_UNPROVEN";
163
+ /** Mail To: recipient chips surface as U+FFFC (object replacement), not body text. */
164
+ export const MAIL_RECIPIENT_TOKEN_CHAR_ID = 65532;
161
165
  export const GUI_SCRIPTING_UNAVAILABLE_SENTINEL = "GUI_SCRIPTING_UNAVAILABLE";
162
166
  export const SYSTEM_EVENTS_PROBE_MARKER = "ATM_SYSTEM_EVENTS_PROBE";
163
167
  /** Permissions-only marker: process / GUI scripting, not application `name`. */
@@ -268,10 +272,11 @@ export function composeTitleMarker(subject) {
268
272
  }
269
273
 
270
274
  /**
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
+ * Optional coordinate finder kept off the compose delivery path.
276
+ * 2.1.3/2.1.4 called this and treated an empty AXValue as "OK", so the
277
+ * keystroke recovery never ran (Probe 2: AXValue writes are a silent no-op).
278
+ * Probe 3 delivery does not use it: no AXUIElementSetAttributeValue, no
279
+ * click, and an empty value is not success.
275
280
  */
276
281
  export function buildAtmAxHitTestJxa() {
277
282
  return `ObjC.import("ApplicationServices");
@@ -313,11 +318,9 @@ function isMailBodyHit(role, bits) {
313
318
  function run(argv) {
314
319
  // ${ATM_AX_HIT_TEST_MARKER}
315
320
  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
+ var pointStr = String(argv[1] || "");
322
+ var minH = parseInt(argv[2], 10);
323
+ if (!minH) minH = ${MAIL_BODY_MIN_AX_HEIGHT};
321
324
  if (!pid) return "BODY_FOCUS_FAILED";
322
325
  var app = $.AXUIElementCreateApplication(pid);
323
326
  var points = pointStr.split(";");
@@ -341,11 +344,7 @@ function run(argv) {
341
344
  }
342
345
  }
343
346
  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";
347
+ return "FOUND";
349
348
  }`;
350
349
  }
351
350
 
@@ -451,27 +450,29 @@ function systemEventsGrantKind(result) {
451
450
  }
452
451
 
453
452
  /**
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).
453
+ * AppleScript handlers: Probe 3 body delivery (Mini PASS, classic AppleScript).
462
454
  *
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`.
455
+ * Find the compose window by exact subject title (`name of` windows, then
456
+ * the 2.1.4 top-level exact-title fallback when `windows` is empty). Find
457
+ * the body by walking `entire contents` of that window and taking the first
458
+ * `AXWebArea`. Focus with System Events `set focused of <AXWebArea> to true`
459
+ * — not `set value`, not `AXUIElementSetAttributeValue`. Re-read process
460
+ * `AXFocusedUIElement` and require role `AXWebArea` plus the same position
461
+ * and size. Mismatch is `BODY_FOCUS_UNPROVEN` (draft deleted, nothing sent).
462
+ * Deliver with Cmd-A and clipboard paste. No coordinate click. No Mail
463
+ * `content` / `html content`. AXTitle reads in the title helpers stay inside
464
+ * `tell application "System Events"` (2.1.4 osacompile -2741).
468
465
  */
469
466
  export function buildMailBodyPasteHandler() {
470
467
  const seSecs = asInteger(MAIL_SE_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 30, field: "seTimeout" });
471
- const bodyMinH = asInteger(MAIL_BODY_MIN_AX_HEIGHT, { min: 1, max: 500, field: "bodyMinHeight" });
472
468
  const topMax = asInteger(MAIL_COMPOSE_TOP_UI_MAX, { min: 1, max: 64, field: "topUiMax" });
473
- const jxaLit = asString(buildAtmAxHitTestJxa());
474
- return `${buildAtmFocusedElementHandler()}
469
+ const tokenId = asInteger(MAIL_RECIPIENT_TOKEN_CHAR_ID, { min: 1, max: 1114111, field: "tokenCharId" });
470
+ return `property atmProvenBx : -1
471
+ property atmProvenBy : -1
472
+ property atmProvenBw : -1
473
+ property atmProvenBh : -1
474
+
475
+ ${buildAtmFocusedElementHandler()}
475
476
 
476
477
  on atmAxFocusedValue()
477
478
  tell application "System Events"
@@ -496,7 +497,9 @@ on atmTopElemBits(atmEl)
496
497
  set atmBits to atmBits & (description of atmEl as string) & " "
497
498
  end try
498
499
  try
499
- set atmBits to atmBits & (value of attribute "AXTitle" of atmEl as string) & " "
500
+ tell application "System Events"
501
+ set atmBits to atmBits & (value of attribute "AXTitle" of atmEl as string) & " "
502
+ end tell
500
503
  end try
501
504
  return atmBits
502
505
  end atmTopElemBits
@@ -509,205 +512,204 @@ on atmTopElemExactTitle(atmEl, atmMarker)
509
512
  if (title of atmEl as string) is atmMarker then return true
510
513
  end try
511
514
  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
515
  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
516
+ if (value of attribute "AXTitle" of atmEl as string) is atmMarker then return true
556
517
  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
518
  end try
579
- return atmOut
580
- end atmAxHitFill
519
+ return false
520
+ end atmTopElemExactTitle
581
521
 
582
- on atmSplitParagraphs(bodyText)
583
- set atmSavedDelim to AppleScript's text item delimiters
584
- set AppleScript's text item delimiters to return
585
- set atmCrParts to text items of bodyText
586
- set AppleScript's text item delimiters to linefeed
587
- set atmNorm to atmCrParts as string
588
- set atmLines to text items of atmNorm
589
- set AppleScript's text item delimiters to atmSavedDelim
590
- return atmLines
591
- end atmSplitParagraphs
592
-
593
- on atmSpacesForTabs(atmLine)
594
- set atmSavedDelim to AppleScript's text item delimiters
595
- set AppleScript's text item delimiters to tab
596
- set atmTabParts to text items of atmLine
597
- set AppleScript's text item delimiters to " "
598
- set atmOut to atmTabParts as string
599
- set AppleScript's text item delimiters to atmSavedDelim
600
- return atmOut
601
- end atmSpacesForTabs
602
-
603
- on atmKeystrokeText(atmText)
604
- set atmSafe to my atmSpacesForTabs(atmText)
605
- set atmLen to length of atmSafe
606
- if atmLen is 0 then return
607
- set atmPos to 1
522
+ on atmFocusedIsProvenBody()
523
+ set atmWantBx to my atmProvenBx
524
+ set atmWantBy to my atmProvenBy
525
+ set atmWantBw to my atmProvenBw
526
+ set atmWantBh to my atmProvenBh
527
+ if atmWantBw is -1 or atmWantBh is -1 then return false
608
528
  tell application "System Events"
609
529
  tell process "Mail"
610
- repeat while atmPos is less than or equal to atmLen
611
- set atmEndPos to atmPos + 31
612
- if atmEndPos > atmLen then set atmEndPos to atmLen
613
- keystroke (text atmPos thru atmEndPos of atmSafe)
614
- set atmPos to atmEndPos + 1
615
- end repeat
530
+ set atmFocusedElem to missing value
531
+ try
532
+ set atmFocusedElem to value of attribute "AXFocusedUIElement"
533
+ end try
534
+ if atmFocusedElem is missing value then return false
535
+ set atmFocRole to ""
536
+ set atmFx to -1
537
+ set atmFy to -1
538
+ set atmFw to -1
539
+ set atmFh to -1
540
+ try
541
+ set atmFocRole to (role of atmFocusedElem as string)
542
+ set atmFocPos to position of atmFocusedElem
543
+ set atmFocSz to size of atmFocusedElem
544
+ set atmFx to item 1 of atmFocPos
545
+ set atmFy to item 2 of atmFocPos
546
+ set atmFw to item 1 of atmFocSz
547
+ set atmFh to item 2 of atmFocSz
548
+ end try
549
+ if atmFocRole is not "AXWebArea" then return false
550
+ if atmFx is not atmWantBx then return false
551
+ if atmFy is not atmWantBy then return false
552
+ if atmFw is not atmWantBw then return false
553
+ if atmFh is not atmWantBh then return false
554
+ return true
616
555
  end tell
617
556
  end tell
618
- end atmKeystrokeText
619
-
620
- on atmTypeMailBody(bodyText)
621
- set atmLines to my atmSplitParagraphs(bodyText)
622
- set atmLast to count of atmLines
623
- if atmLast is 0 then return
624
- with timeout of ${seSecs} seconds
625
- repeat with atmI from 1 to atmLast
626
- set atmLine to item atmI of atmLines as string
627
- my atmKeystrokeText(atmLine)
628
- if atmI < atmLast then
629
- tell application "System Events"
630
- tell process "Mail"
631
- key code 36
632
- end tell
633
- end tell
634
- end if
635
- end repeat
636
- end timeout
637
- end atmTypeMailBody
638
-
639
- on atmPasteMailBodyFallback(bodyText)
640
- set savedClip to ""
641
- set hadClip to false
642
- try
643
- set savedClip to the clipboard as text
644
- set hadClip to true
645
- end try
646
- set the clipboard to bodyText
647
- try
648
- with timeout of ${seSecs} seconds
649
- tell application "System Events"
650
- tell process "Mail"
651
- keystroke "v" using command down
652
- end tell
653
- end tell
654
- end timeout
655
- delay 0.15
656
- on error errMsg number errNum
657
- if hadClip then set the clipboard to savedClip
658
- if errMsg contains "assistive access" or errNum is -25211 then error "${ACCESSIBILITY_DENIED_SENTINEL}"
659
- error errMsg number errNum
660
- end try
661
- if hadClip then set the clipboard to savedClip
662
- end atmPasteMailBodyFallback
557
+ return false
558
+ end atmFocusedIsProvenBody
559
+
560
+ on atmAxValueIsRecipientToken(atmVal)
561
+ if atmVal is "" then return false
562
+ set atmTok to character id ${tokenId}
563
+ if atmVal does not contain atmTok then return false
564
+ set atmSaved to AppleScript's text item delimiters
565
+ set AppleScript's text item delimiters to atmTok
566
+ set atmParts to text items of atmVal
567
+ set AppleScript's text item delimiters to ""
568
+ set atmRest to atmParts as string
569
+ set AppleScript's text item delimiters to " "
570
+ set atmSpaceParts to text items of atmRest
571
+ set AppleScript's text item delimiters to ""
572
+ set atmRest to atmSpaceParts as string
573
+ set AppleScript's text item delimiters to return
574
+ set atmLineParts to text items of atmRest
575
+ set AppleScript's text item delimiters to ""
576
+ set atmRest to atmLineParts as string
577
+ set AppleScript's text item delimiters to linefeed
578
+ set atmLfParts to text items of atmRest
579
+ set AppleScript's text item delimiters to ""
580
+ set atmRest to atmLfParts as string
581
+ set AppleScript's text item delimiters to tab
582
+ set atmTabParts to text items of atmRest
583
+ set AppleScript's text item delimiters to ""
584
+ set atmRest to atmTabParts as string
585
+ set AppleScript's text item delimiters to atmSaved
586
+ if atmRest is "" then return true
587
+ return false
588
+ end atmAxValueIsRecipientToken
663
589
 
664
590
  on atmFillMailBody(bodyText, needle, titleMarker, msg)
665
591
  try
666
592
  tell application "Mail" to activate
667
593
  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
594
+ set atmSaveBx to -1
595
+ set atmSaveBy to -1
596
+ set atmSaveBw to -1
597
+ set atmSaveBh to -1
675
598
  with timeout of ${seSecs} seconds
676
599
  tell application "System Events"
677
600
  tell process "Mail"
678
- set atmPid to unix id
601
+ set frontmost to true
602
+ delay 0.3
603
+ set atmWin to missing value
604
+ repeat with atmCand in windows
605
+ try
606
+ if (name of atmCand as string) is titleMarker then
607
+ set atmWin to atmCand
608
+ exit repeat
609
+ end if
610
+ end try
611
+ end repeat
612
+ if atmWin is missing value then
613
+ set atmElems to UI elements
614
+ set atmCount to count of atmElems
615
+ if atmCount > ${topMax} then set atmCount to ${topMax}
616
+ repeat with atmIdx from 1 to atmCount
617
+ set atmEl to item atmIdx of atmElems
618
+ set atmRole to ""
619
+ try
620
+ set atmRole to (role of atmEl as string)
621
+ end try
622
+ if atmRole is not "AXMenuBar" then
623
+ if my atmTopElemExactTitle(atmEl, titleMarker) then
624
+ set atmWin to atmEl
625
+ exit repeat
626
+ end if
627
+ end if
628
+ end repeat
629
+ end if
630
+ if atmWin is missing value then error "${BODY_FOCUS_FAILED_SENTINEL}"
631
+ try
632
+ perform action "AXRaise" of atmWin
633
+ end try
634
+ delay 0.3
635
+ set atmBody to missing value
636
+ set atmAll to {}
637
+ try
638
+ set atmAll to entire contents of atmWin
639
+ end try
640
+ repeat with atmEl in atmAll
641
+ try
642
+ if (role of atmEl as string) is "AXWebArea" then
643
+ set atmBody to atmEl
644
+ exit repeat
645
+ end if
646
+ end try
647
+ end repeat
648
+ if atmBody is missing value then error "${BODY_FOCUS_FAILED_SENTINEL}"
649
+ set atmBodyPos to position of atmBody
650
+ set atmBodySz to size of atmBody
651
+ set atmBx to item 1 of atmBodyPos
652
+ set atmBy to item 2 of atmBodyPos
653
+ set atmBw to item 1 of atmBodySz
654
+ set atmBh to item 2 of atmBodySz
655
+ try
656
+ set focused of atmBody to true
657
+ end try
658
+ delay 0.3
659
+ set atmFocusedElem to missing value
660
+ try
661
+ set atmFocusedElem to value of attribute "AXFocusedUIElement"
662
+ end try
663
+ if atmFocusedElem is missing value then error "${BODY_FOCUS_UNPROVEN_SENTINEL}"
664
+ set atmFocRole to ""
665
+ set atmFx to -1
666
+ set atmFy to -1
667
+ set atmFw to -1
668
+ set atmFh to -1
669
+ try
670
+ set atmFocRole to (role of atmFocusedElem as string)
671
+ set atmFocPos to position of atmFocusedElem
672
+ set atmFocSz to size of atmFocusedElem
673
+ set atmFx to item 1 of atmFocPos
674
+ set atmFy to item 2 of atmFocPos
675
+ set atmFw to item 1 of atmFocSz
676
+ set atmFh to item 2 of atmFocSz
677
+ end try
678
+ if atmFocRole is not "AXWebArea" then error "${BODY_FOCUS_UNPROVEN_SENTINEL}"
679
+ if atmFx is not atmBx then error "${BODY_FOCUS_UNPROVEN_SENTINEL}"
680
+ if atmFy is not atmBy then error "${BODY_FOCUS_UNPROVEN_SENTINEL}"
681
+ if atmFw is not atmBw then error "${BODY_FOCUS_UNPROVEN_SENTINEL}"
682
+ if atmFh is not atmBh then error "${BODY_FOCUS_UNPROVEN_SENTINEL}"
683
+ set atmSaveBx to atmBx
684
+ set atmSaveBy to atmBy
685
+ set atmSaveBw to atmBw
686
+ set atmSaveBh to atmBh
687
+ set savedClip to ""
688
+ set hadClip to false
689
+ try
690
+ set savedClip to the clipboard as text
691
+ set hadClip to true
692
+ end try
693
+ try
694
+ keystroke "a" using command down
695
+ delay 0.2
696
+ set the clipboard to bodyText
697
+ delay 0.2
698
+ keystroke "v" using command down
699
+ delay 0.6
700
+ on error errMsg number errNum
701
+ if hadClip then set the clipboard to savedClip
702
+ if errMsg contains "assistive access" or errNum is -25211 then error "${ACCESSIBILITY_DENIED_SENTINEL}"
703
+ error errMsg number errNum
704
+ end try
705
+ if hadClip then set the clipboard to savedClip
679
706
  end tell
680
707
  end tell
681
708
  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
708
- end if
709
- end if
710
- end if
709
+ set my atmProvenBx to atmSaveBx
710
+ set my atmProvenBy to atmSaveBy
711
+ set my atmProvenBw to atmSaveBw
712
+ set my atmProvenBh to atmSaveBh
711
713
  on error errMsg number errNum
712
714
  if errMsg contains "assistive access" or errNum is -25211 then error "${ACCESSIBILITY_DENIED_SENTINEL}"
713
715
  if errMsg contains "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}" then error "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}"
@@ -716,6 +718,7 @@ on atmFillMailBody(bodyText, needle, titleMarker, msg)
716
718
  end atmFillMailBody`;
717
719
  }
718
720
 
721
+
719
722
  export const buildMailBodyFillHandler = buildMailBodyPasteHandler;
720
723
 
721
724
  function composeMisdirectChecks({ to, cc = [], bcc = [], subject, body }) {
@@ -735,9 +738,13 @@ function composeMisdirectChecks({ to, cc = [], bcc = [], subject, body }) {
735
738
  })
736
739
  .join("\n");
737
740
  const bodyCheck = needle
738
- ? ` set atmAxVal to my atmAxFocusedValue()
739
- if atmAxVal is not "" then
740
- if atmAxVal does not contain ${asString(needle)} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
741
+ ? ` if my atmFocusedIsProvenBody() then
742
+ set atmAxVal to my atmAxFocusedValue()
743
+ if atmAxVal is not "" then
744
+ if my atmAxValueIsRecipientToken(atmAxVal) is false then
745
+ if atmAxVal does not contain ${asString(needle)} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
746
+ end if
747
+ end if
741
748
  end if`
742
749
  : "";
743
750
  return ` if (count of to recipients of newMessage) is not ${toCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
@@ -758,13 +765,16 @@ ${bodyCheck}`;
758
765
  * Do not set AppleScript `content` or `html content`: Ventura+ FB11734014
759
766
  * cite-wraps those setters (`>` prefixes + `<blockquote type="cite">`).
760
767
  * Recipients and subject are AppleScript properties (never paste). The body
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.
768
+ * is filled by Probe 3: exact subject title, first AXWebArea under that
769
+ * window (`entire contents`), `set focused of` that element, prove focus
770
+ * (role + position + size) via a fresh AXFocusedUIElement read, then Cmd-A
771
+ * and clipboard paste. No coordinate click. No AXValue write. No Mail
772
+ * `content of` oracle (Mail 16 / macOS 26 stays empty). Missing window or
773
+ * body is BODY_FOCUS_FAILED; a focus-proof mismatch is BODY_FOCUS_UNPROVEN.
774
+ * Either deletes the draft and sends nothing. Header checks stay. The body
775
+ * needle runs only when the focused element is still that proven body, so a
776
+ * To: U+FFFC chip is not BODY_PASTE_MISDIRECTED. Ship prove that the body
777
+ * landed is Sent `.emlx` after send.
768
778
  */
769
779
  export function buildComposeScript({ to, cc, bcc, subject, body, send, html = false }) {
770
780
  const recipients = [
@@ -1189,6 +1199,11 @@ export function isMailBodyFocusFailed(result) {
1189
1199
  return String(result.error || "").toLowerCase().includes("body_focus_failed");
1190
1200
  }
1191
1201
 
1202
+ export function isMailBodyFocusUnproven(result) {
1203
+ if (!result) return false;
1204
+ return String(result.error || "").toLowerCase().includes("body_focus_unproven");
1205
+ }
1206
+
1192
1207
  export function isMailBodyPasteMisdirected(result) {
1193
1208
  if (!result) return false;
1194
1209
  const text = String(result.error || "").toLowerCase();
@@ -1200,6 +1215,9 @@ function failure(action, summary, result, secrets) {
1200
1215
  if (err.includes("gui_scripting_unavailable")) {
1201
1216
  return `${action} failed — attempted to ${summary}. ${MAIL_GUI_SCRIPTING_GUIDANCE}`;
1202
1217
  }
1218
+ if (err.includes("body_focus_unproven")) {
1219
+ return `${action} failed — attempted to ${summary}. Keyboard focus was not on the message body (focus proof failed); the draft was discarded and nothing was sent.`;
1220
+ }
1203
1221
  if (err.includes("body_focus_failed")) {
1204
1222
  return `${action} failed — attempted to ${summary}. The compose caret could not be moved into the message body; nothing was sent.`;
1205
1223
  }
package/lib/writeTools.js CHANGED
@@ -57,7 +57,7 @@ export const WRITE_TOOL_DEFINITIONS = [
57
57
  bcc: { type: "array", items: { type: "string" }, description: "BCC email addresses" },
58
58
  subject: { type: "string", description: "Subject line (required)" },
59
59
  body: { type: "string", description: "Message body (required)" },
60
- body_format: { type: "string", enum: ["plain", "html"], description: "Plain text (default) or HTML. HTML is tag-stripped; the body is typed into Mail's compose window (System Events keystrokes). AppleScript content/html content is not set because it quote-wraps the Sent body" },
60
+ body_format: { type: "string", enum: ["plain", "html"], description: "Plain text (default) or HTML. HTML is tag-stripped; the body is pasted into Mail's compose window (System Events focus on the message body, then Cmd-A and clipboard paste). AppleScript content/html content is not set because it quote-wraps the Sent body" },
61
61
  ...CONFIRM_PROPS
62
62
  },
63
63
  required: ["to", "subject", "body"]
@@ -74,7 +74,7 @@ export const WRITE_TOOL_DEFINITIONS = [
74
74
  bcc: { type: "array", items: { type: "string" }, description: "BCC email addresses" },
75
75
  subject: { type: "string", description: "Subject line" },
76
76
  body: { type: "string", description: "Message body" },
77
- body_format: { type: "string", enum: ["plain", "html"], description: "Plain text (default) or HTML. HTML is tag-stripped; the body is typed into Mail's compose window (System Events keystrokes). AppleScript content/html content is not set because it quote-wraps the Sent body" },
77
+ body_format: { type: "string", enum: ["plain", "html"], description: "Plain text (default) or HTML. HTML is tag-stripped; the body is pasted into Mail's compose window (System Events focus on the message body, then Cmd-A and clipboard paste). AppleScript content/html content is not set because it quote-wraps the Sent body" },
78
78
  ...CONFIRM_PROPS
79
79
  },
80
80
  required: ["to"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "2.1.3",
3
+ "version": "2.1.5",
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",