apple-tools-mcp 2.1.4 → 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
 
@@ -486,6 +486,8 @@ Keystrokes use System Events, so **node needs Accessibility** (Privacy & Securit
486
486
 
487
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
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
+
489
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.
490
492
 
491
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,33 +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`.
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).
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).
474
465
  */
475
466
  export function buildMailBodyPasteHandler() {
476
467
  const seSecs = asInteger(MAIL_SE_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 30, field: "seTimeout" });
477
- const bodyMinH = asInteger(MAIL_BODY_MIN_AX_HEIGHT, { min: 1, max: 500, field: "bodyMinHeight" });
478
468
  const topMax = asInteger(MAIL_COMPOSE_TOP_UI_MAX, { min: 1, max: 64, field: "topUiMax" });
479
- const jxaLit = asString(buildAtmAxHitTestJxa());
480
- 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()}
481
476
 
482
477
  on atmAxFocusedValue()
483
478
  tell application "System Events"
@@ -524,200 +519,197 @@ on atmTopElemExactTitle(atmEl, atmMarker)
524
519
  return false
525
520
  end atmTopElemExactTitle
526
521
 
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
-
592
- on atmSplitParagraphs(bodyText)
593
- set atmSavedDelim to AppleScript's text item delimiters
594
- set AppleScript's text item delimiters to return
595
- set atmCrParts to text items of bodyText
596
- set AppleScript's text item delimiters to linefeed
597
- set atmNorm to atmCrParts as string
598
- set atmLines to text items of atmNorm
599
- set AppleScript's text item delimiters to atmSavedDelim
600
- return atmLines
601
- end atmSplitParagraphs
602
-
603
- on atmSpacesForTabs(atmLine)
604
- set atmSavedDelim to AppleScript's text item delimiters
605
- set AppleScript's text item delimiters to tab
606
- set atmTabParts to text items of atmLine
607
- set AppleScript's text item delimiters to " "
608
- set atmOut to atmTabParts as string
609
- set AppleScript's text item delimiters to atmSavedDelim
610
- return atmOut
611
- end atmSpacesForTabs
612
-
613
- on atmKeystrokeText(atmText)
614
- set atmSafe to my atmSpacesForTabs(atmText)
615
- set atmLen to length of atmSafe
616
- if atmLen is 0 then return
617
- 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
618
528
  tell application "System Events"
619
529
  tell process "Mail"
620
- repeat while atmPos is less than or equal to atmLen
621
- set atmEndPos to atmPos + 31
622
- if atmEndPos > atmLen then set atmEndPos to atmLen
623
- keystroke (text atmPos thru atmEndPos of atmSafe)
624
- set atmPos to atmEndPos + 1
625
- 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
626
555
  end tell
627
556
  end tell
628
- end atmKeystrokeText
629
-
630
- on atmTypeMailBody(bodyText)
631
- set atmLines to my atmSplitParagraphs(bodyText)
632
- set atmLast to count of atmLines
633
- if atmLast is 0 then return
634
- with timeout of ${seSecs} seconds
635
- repeat with atmI from 1 to atmLast
636
- set atmLine to item atmI of atmLines as string
637
- my atmKeystrokeText(atmLine)
638
- if atmI < atmLast then
639
- tell application "System Events"
640
- tell process "Mail"
641
- key code 36
642
- end tell
643
- end tell
644
- end if
645
- end repeat
646
- end timeout
647
- end atmTypeMailBody
648
-
649
- on atmPasteMailBodyFallback(bodyText)
650
- set savedClip to ""
651
- set hadClip to false
652
- try
653
- set savedClip to the clipboard as text
654
- set hadClip to true
655
- end try
656
- set the clipboard to bodyText
657
- try
658
- with timeout of ${seSecs} seconds
659
- tell application "System Events"
660
- tell process "Mail"
661
- keystroke "v" using command down
662
- end tell
663
- end tell
664
- end timeout
665
- delay 0.15
666
- on error errMsg number errNum
667
- if hadClip then set the clipboard to savedClip
668
- if errMsg contains "assistive access" or errNum is -25211 then error "${ACCESSIBILITY_DENIED_SENTINEL}"
669
- error errMsg number errNum
670
- end try
671
- if hadClip then set the clipboard to savedClip
672
- 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
673
589
 
674
590
  on atmFillMailBody(bodyText, needle, titleMarker, msg)
675
591
  try
676
592
  tell application "Mail" to activate
677
593
  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
594
+ set atmSaveBx to -1
595
+ set atmSaveBy to -1
596
+ set atmSaveBw to -1
597
+ set atmSaveBh to -1
685
598
  with timeout of ${seSecs} seconds
686
599
  tell application "System Events"
687
600
  tell process "Mail"
688
- 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
689
706
  end tell
690
707
  end tell
691
708
  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
718
- end if
719
- end if
720
- 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
721
713
  on error errMsg number errNum
722
714
  if errMsg contains "assistive access" or errNum is -25211 then error "${ACCESSIBILITY_DENIED_SENTINEL}"
723
715
  if errMsg contains "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}" then error "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}"
@@ -726,6 +718,7 @@ on atmFillMailBody(bodyText, needle, titleMarker, msg)
726
718
  end atmFillMailBody`;
727
719
  }
728
720
 
721
+
729
722
  export const buildMailBodyFillHandler = buildMailBodyPasteHandler;
730
723
 
731
724
  function composeMisdirectChecks({ to, cc = [], bcc = [], subject, body }) {
@@ -745,9 +738,13 @@ function composeMisdirectChecks({ to, cc = [], bcc = [], subject, body }) {
745
738
  })
746
739
  .join("\n");
747
740
  const bodyCheck = needle
748
- ? ` set atmAxVal to my atmAxFocusedValue()
749
- if atmAxVal is not "" then
750
- 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
751
748
  end if`
752
749
  : "";
753
750
  return ` if (count of to recipients of newMessage) is not ${toCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
@@ -768,13 +765,16 @@ ${bodyCheck}`;
768
765
  * Do not set AppleScript `content` or `html content`: Ventura+ FB11734014
769
766
  * cite-wraps those setters (`>` prefixes + `<blockquote type="cite">`).
770
767
  * Recipients and subject are AppleScript properties (never paste). The body
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.
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.
778
778
  */
779
779
  export function buildComposeScript({ to, cc, bcc, subject, body, send, html = false }) {
780
780
  const recipients = [
@@ -1199,6 +1199,11 @@ export function isMailBodyFocusFailed(result) {
1199
1199
  return String(result.error || "").toLowerCase().includes("body_focus_failed");
1200
1200
  }
1201
1201
 
1202
+ export function isMailBodyFocusUnproven(result) {
1203
+ if (!result) return false;
1204
+ return String(result.error || "").toLowerCase().includes("body_focus_unproven");
1205
+ }
1206
+
1202
1207
  export function isMailBodyPasteMisdirected(result) {
1203
1208
  if (!result) return false;
1204
1209
  const text = String(result.error || "").toLowerCase();
@@ -1210,6 +1215,9 @@ function failure(action, summary, result, secrets) {
1210
1215
  if (err.includes("gui_scripting_unavailable")) {
1211
1216
  return `${action} failed — attempted to ${summary}. ${MAIL_GUI_SCRIPTING_GUIDANCE}`;
1212
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
+ }
1213
1221
  if (err.includes("body_focus_failed")) {
1214
1222
  return `${action} failed — attempted to ${summary}. The compose caret could not be moved into the message body; nothing was sent.`;
1215
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.4",
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",