apple-tools-mcp 2.0.7 → 2.0.9

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
@@ -466,11 +466,15 @@ Two arguments are available on **every** write tool:
466
466
 
467
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 (System Events). 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.
468
468
 
469
- Paste uses System Events, so **node needs Accessibility** (Privacy & Security → Accessibility) in addition to Automation → Mail. That Accessibility deny is not a Mail Automation deny (`-1743` / `-10004`). Body focus uses `text area` / `scroll area` and `UI element whose role is "AXWebArea"` — never the System Events class `web area`, which does not compile on macOS 26.x (**-2741**, “Expected class name but found identifier”).
469
+ Paste uses System Events, so **node needs Accessibility** (Privacy & Security → Accessibility) in addition to Automation → Mail. That Accessibility deny is not a Mail Automation deny (`-1743` / `-10004`). Body focus uses `text area` / `scroll area` / `text field` and `UI element whose role is "AXWebArea"` — never the System Events class `web area`, which does not compile on macOS 26.x (**-2741**, “Expected class name but found identifier”). The pre-paste focus read uses `first UI element whose focused is true` and `value of attribute "AXFocusedUIElement"` — never the compound `focused UI element`, which does not compile on System Events 26.x (**-2741** on MacBook 26.1, **-2740** on Mini 26.6.2).
470
+
471
+ **Compose body focus (2.0.8).** After `make new outgoing message`, To and Subject are AppleScript properties (never paste). Cmd-V runs only after the caret leaves the header: a tall `AXWebArea` (HTML compose body), a tall `text area` (plain compose; short To/Subject fields are skipped), Subject then Tab, or a click in the lower two-thirds of the compose window. If focus is still a header field, the tool raises `BODY_FOCUS_FAILED` and does **not** paste. After paste it checks To/Cc/Bcc counts, the exact subject, and that the native body contains the intended text. A mismatch is `BODY_PASTE_MISDIRECTED`: the outgoing message is deleted and nothing is sent. Quote-prefix Sent prove and Sent/Outbox verify stay gated on this paste-focus contract — a MacBook 2.0.7 live `mail_send` with Accessibility allowed still hit `BODY_PASTE_MISDIRECTED` when Cmd-V landed in To/Subject (`ATP-207-SENT-PROVE-20260921-084048`).
472
+
473
+ **macOS 26 focus compile (2.0.9).** Dual-host Sent prove on `@2.0.8` never reached paste: `atmSafeToPaste` used bare `focused UI element`, which System Events 26.x rejects at compile. The 2.0.8 fail-closed gates stay; only the focus *query* changed to the forms hosts compiled.
470
474
 
471
475
  **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.
472
476
 
473
- **Manual prove (2.0.6 on the Mac host):** `mail_send` a short 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. Inspect each Sent `.emlx`: the text/plain part 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`, no **-2741** on body focus). That quote-prefix dual-host Sent prove is a separate bar from the 2.0.7 verify contract.
477
+ **Manual prove (2.0.9 on the Mac host):** AppleScript for safe-to-paste / focus check **compiles** (no `-2741`/`-2740` on `focused UI element`). Then `mail_send` a short 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`, and the intended text must be in the native body before send. Inspect each Sent `.emlx`: the text/plain part 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`, no **-2741** / **-2740** on body focus). That quote-prefix dual-host Sent prove is a separate bar from the 2.0.7 verify contract.
474
478
 
475
479
  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.
476
480
 
package/lib/mailWrite.js CHANGED
@@ -155,6 +155,40 @@ export function probeMailAutomation() {
155
155
 
156
156
  export const ACCESSIBILITY_DENIED_SENTINEL = "ACCESSIBILITY_DENIED";
157
157
  export const BODY_PASTE_MISDIRECTED_SENTINEL = "BODY_PASTE_MISDIRECTED";
158
+ export const BODY_FOCUS_FAILED_SENTINEL = "BODY_FOCUS_FAILED";
159
+ /** Compose body AX height vs To/Subject/Cc fields (those are ~22px). */
160
+ export const MAIL_BODY_MIN_AX_HEIGHT = 50;
161
+
162
+ /**
163
+ * macOS 26.x System Events rejects the compound `focused UI element`
164
+ * (MacBook 26.1 compile -2741; Mini 26.6.2 compile -2740). Hosts compile
165
+ * these instead — same class of dictionary break as bare `web area`.
166
+ */
167
+ export const ATM_FOCUSED_WHOSE_QUERY = "first UI element whose focused is true";
168
+ export const ATM_FOCUSED_AX_QUERY = 'value of attribute "AXFocusedUIElement"';
169
+
170
+ /**
171
+ * Resolve the focused System Events element without `focused UI element`.
172
+ * AXFocusedUIElement is tried first (nested caret); `whose focused is true`
173
+ * is the compile-proven fallback. A process-level whose match can be the
174
+ * key window — callers must still reject header roles / AXWindow.
175
+ */
176
+ export function buildAtmFocusedElementHandler() {
177
+ return `on atmFocusedElement()
178
+ tell application "System Events"
179
+ tell process "Mail"
180
+ try
181
+ set fe to ${ATM_FOCUSED_AX_QUERY}
182
+ if fe is not missing value then return fe
183
+ end try
184
+ try
185
+ return ${ATM_FOCUSED_WHOSE_QUERY}
186
+ end try
187
+ end tell
188
+ end tell
189
+ return missing value
190
+ end atmFocusedElement`;
191
+ }
158
192
 
159
193
  /**
160
194
  * Body text for native Mail compose (clipboard paste, not AppleScript content).
@@ -172,51 +206,124 @@ export function composeNativeBody(body, { html = false } = {}) {
172
206
  }
173
207
 
174
208
  /**
175
- * AppleScript handlers: focus the compose body (not To) and paste.
209
+ * Distinctive first line used to prove the native editor received the body.
210
+ * Full multiline `contains` can miss when Mail stores an HTML alternative.
211
+ */
212
+ export function composeBodyNeedle(body) {
213
+ const text = body === undefined || body === null ? "" : String(body);
214
+ const line = text.split(/\r?\n/).find((candidate) => candidate.trim()) || text;
215
+ const needle = line.trim();
216
+ return needle.length > 120 ? needle.slice(0, 120) : needle;
217
+ }
218
+
219
+ const BODY_MIN_H = asInteger(MAIL_BODY_MIN_AX_HEIGHT, { min: 1, max: 500, field: "bodyMinHeight" });
220
+
221
+ /**
222
+ * AppleScript handlers: focus the compose body (not To/Subject) and paste.
176
223
  * System Events needs Accessibility; Mail make/send still needs Automation.
177
224
  *
178
225
  * macOS 26.x System Events has no `web area` class (compile -2741,
179
- * "Expected class name but found identifier"). Hosts compile
180
- * `text area` / `scroll area` and `UI element whose role is "AXWebArea"`.
226
+ * "Expected class name but found identifier") and no compound
227
+ * `focused UI element` (compile -2741 / -2740). Hosts compile
228
+ * `text area` / `scroll area` / `text field`, `UI element whose role is
229
+ * "AXWebArea"`, `first UI element whose focused is true`, and
230
+ * `value of attribute "AXFocusedUIElement"`. Never focus the first
231
+ * AXTextArea blindly — on MacBook Mail that is a header field, and
232
+ * Cmd-V becomes BODY_PASTE_MISDIRECTED.
233
+ *
234
+ * Strategies, each fail-closed unless the caret left the header:
235
+ * 1. Click a tall AXWebArea (HTML compose body)
236
+ * 2. Focus a tall `text area` (plain compose body), never a short header
237
+ * 3. Click Subject then Tab into the body (stable Mini/MacBook order)
238
+ * 4. Click the lower two-thirds of the compose window
181
239
  */
182
240
  export function buildMailBodyPasteHandler() {
183
- return `on atmFocusMailBody()
241
+ return `${buildAtmFocusedElementHandler()}
242
+
243
+ on atmSafeToPaste()
184
244
  tell application "System Events"
185
245
  tell process "Mail"
186
- set frontmost to true
187
- if (count of windows) < 1 then error "MAIL_COMPOSE_WINDOW_MISSING"
188
- set w to first window
189
- try
190
- set focused of text area 1 of scroll area 1 of w to true
191
- return
192
- end try
193
246
  try
194
- set focused of text area 1 of w to true
195
- return
247
+ set fe to my atmFocusedElement()
248
+ if fe is missing value then return false
249
+ set r to (role of fe) as string
250
+ if r is "AXWindow" then return false
251
+ if r is "AXTextField" then return false
252
+ if r is "AXComboBox" then return false
253
+ if r is "AXButton" then return false
254
+ if r is "AXMenuButton" then return false
255
+ if r is "AXPopUpButton" then return false
256
+ set eh to 0
257
+ try
258
+ set {ew, eh} to size of fe
259
+ end try
260
+ if r is "AXTextArea" and eh < ${BODY_MIN_H} then return false
261
+ ignoring case
262
+ set bits to ""
263
+ try
264
+ set bits to bits & (description of fe as string) & " "
265
+ end try
266
+ try
267
+ set bits to bits & (name of fe as string) & " "
268
+ end try
269
+ if bits contains "To:" then return false
270
+ if bits contains "Cc:" then return false
271
+ if bits contains "Bcc:" then return false
272
+ if bits contains "Subject" then return false
273
+ end ignoring
274
+ if r is "AXWebArea" then return true
275
+ if r is "AXTextArea" then return true
276
+ if eh >= ${BODY_MIN_H} then return true
196
277
  end try
278
+ return false
279
+ end tell
280
+ end tell
281
+ end atmSafeToPaste
282
+
283
+ on atmFocusMailBody(expectedSubject)
284
+ tell application "System Events"
285
+ tell process "Mail"
286
+ set frontmost to true
287
+ if (count of windows) < 1 then error "MAIL_COMPOSE_WINDOW_MISSING"
288
+ set w to window 1
289
+ if expectedSubject is not "" then
290
+ repeat with i from 1 to (count of windows)
291
+ try
292
+ if (name of window i as string) is expectedSubject then
293
+ set w to window i
294
+ exit repeat
295
+ end if
296
+ end try
297
+ end repeat
298
+ end if
197
299
  try
198
- set focused of (first UI element of w whose role is "AXTextArea") to true
199
- return
300
+ perform action "AXRaise" of w
200
301
  end try
201
302
  try
202
303
  click (first UI element of w whose role is "AXWebArea")
203
- return
304
+ delay 0.08
305
+ if my atmSafeToPaste() then return
204
306
  end try
205
307
  try
206
308
  click (first UI element of scroll area 1 of w whose role is "AXWebArea")
207
- return
309
+ delay 0.08
310
+ if my atmSafeToPaste() then return
208
311
  end try
209
312
  try
210
313
  click (first UI element of scroll area 1 of splitter group 1 of w whose role is "AXWebArea")
211
- return
314
+ delay 0.08
315
+ if my atmSafeToPaste() then return
212
316
  end try
213
317
  try
214
318
  click (first UI element of scroll area 1 of group 1 of splitter group 1 of w whose role is "AXWebArea")
215
- return
319
+ delay 0.08
320
+ if my atmSafeToPaste() then return
216
321
  end try
322
+ set atmTallText to missing value
323
+ set atmSubjectElem to missing value
217
324
  set atmQueue to UI elements of w
218
325
  set atmWalked to 0
219
- repeat while (count of atmQueue) > 0 and atmWalked < 80
326
+ repeat while (count of atmQueue) > 0 and atmWalked < 120
220
327
  set atmWalked to atmWalked + 1
221
328
  set atmElem to item 1 of atmQueue
222
329
  if (count of atmQueue) is 1 then
@@ -226,14 +333,33 @@ export function buildMailBodyPasteHandler() {
226
333
  end if
227
334
  try
228
335
  if (role of atmElem as string) is "AXWebArea" then
229
- click atmElem
230
- return
336
+ set {ew, eh} to size of atmElem
337
+ if eh >= ${BODY_MIN_H} then
338
+ click atmElem
339
+ delay 0.08
340
+ if my atmSafeToPaste() then return
341
+ end if
231
342
  end if
232
343
  end try
233
344
  try
234
345
  if (role of atmElem as string) is "AXTextArea" then
235
- set focused of atmElem to true
236
- return
346
+ set {tw, th} to size of atmElem
347
+ if th >= ${BODY_MIN_H} and atmTallText is missing value then set atmTallText to atmElem
348
+ end if
349
+ end try
350
+ try
351
+ set r to (role of atmElem as string)
352
+ if r is "AXTextField" or r is "AXTextArea" then
353
+ set bits to ""
354
+ try
355
+ set bits to bits & (description of atmElem as string)
356
+ end try
357
+ try
358
+ set bits to bits & (name of atmElem as string)
359
+ end try
360
+ ignoring case
361
+ if bits contains "Subject" and atmSubjectElem is missing value then set atmSubjectElem to atmElem
362
+ end ignoring
237
363
  end if
238
364
  end try
239
365
  try
@@ -243,14 +369,52 @@ export function buildMailBodyPasteHandler() {
243
369
  end repeat
244
370
  end try
245
371
  end repeat
372
+ try
373
+ set ta to text area 1 of scroll area 1 of w
374
+ set {tw, th} to size of ta
375
+ if th >= ${BODY_MIN_H} then
376
+ set focused of ta to true
377
+ delay 0.08
378
+ if my atmSafeToPaste() then return
379
+ end if
380
+ end try
381
+ if atmTallText is not missing value then
382
+ try
383
+ set focused of atmTallText to true
384
+ end try
385
+ try
386
+ click atmTallText
387
+ end try
388
+ delay 0.08
389
+ if my atmSafeToPaste() then return
390
+ end if
391
+ try
392
+ click (first text field of w whose description contains "Subject")
393
+ delay 0.08
394
+ key code 48
395
+ delay 0.08
396
+ if my atmSafeToPaste() then return
397
+ end try
398
+ if atmSubjectElem is not missing value then
399
+ try
400
+ click atmSubjectElem
401
+ delay 0.08
402
+ key code 48
403
+ delay 0.08
404
+ if my atmSafeToPaste() then return
405
+ end try
406
+ end if
246
407
  set {wx, wy} to position of w
247
408
  set {ww, wh} to size of w
248
409
  click at {(wx + (ww div 2)) as integer, (wy + ((wh * 2) div 3)) as integer}
410
+ delay 0.08
411
+ if my atmSafeToPaste() then return
412
+ error "${BODY_FOCUS_FAILED_SENTINEL}"
249
413
  end tell
250
414
  end tell
251
415
  end atmFocusMailBody
252
416
 
253
- on atmPasteMailBody(bodyText)
417
+ on atmPasteMailBody(bodyText, expectedSubject)
254
418
  set savedClip to ""
255
419
  set hadClip to false
256
420
  try
@@ -259,15 +423,16 @@ on atmPasteMailBody(bodyText)
259
423
  end try
260
424
  set the clipboard to bodyText
261
425
  tell application "Mail" to activate
262
- delay 0.25
426
+ delay 0.4
263
427
  try
264
- atmFocusMailBody()
428
+ atmFocusMailBody(expectedSubject)
429
+ if atmSafeToPaste() is false then error "${BODY_FOCUS_FAILED_SENTINEL}"
265
430
  tell application "System Events"
266
431
  tell process "Mail"
267
432
  keystroke "v" using command down
268
433
  end tell
269
434
  end tell
270
- delay 0.15
435
+ delay 0.2
271
436
  on error errMsg number errNum
272
437
  if hadClip then set the clipboard to savedClip
273
438
  if errMsg contains "assistive access" or errNum is -25211 then error "${ACCESSIBILITY_DENIED_SENTINEL}"
@@ -277,8 +442,11 @@ on atmPasteMailBody(bodyText)
277
442
  end atmPasteMailBody`;
278
443
  }
279
444
 
280
- function recipientStillPresentChecks(to) {
445
+ function composeMisdirectChecks({ to, cc = [], bcc = [], subject, body }) {
281
446
  const toCount = asInteger(to.length, { min: 1, max: 99, field: "toCount" });
447
+ const ccCount = asInteger(cc.length, { min: 0, max: 99, field: "ccCount" });
448
+ const bccCount = asInteger(bcc.length, { min: 0, max: 99, field: "bccCount" });
449
+ const needle = composeBodyNeedle(body);
282
450
  const stillThere = to
283
451
  .map((address, i) => {
284
452
  const n = asInteger(i, { min: 0, max: 99, field: "recipientIndex" });
@@ -290,8 +458,39 @@ function recipientStillPresentChecks(to) {
290
458
  if atmFound${n} is false then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"`;
291
459
  })
292
460
  .join("\n");
461
+ const bodyCheck = needle
462
+ ? ` set atmGotBody to ""
463
+ try
464
+ set atmGotBody to content of newMessage as string
465
+ end try
466
+ if atmGotBody is "" then
467
+ try
468
+ save newMessage
469
+ end try
470
+ delay 0.2
471
+ try
472
+ set atmGotBody to content of newMessage as string
473
+ end try
474
+ end if
475
+ if atmGotBody does not contain ${asString(needle)} then
476
+ try
477
+ set atmFe to my atmFocusedElement()
478
+ if atmFe is not missing value then
479
+ tell application "System Events"
480
+ set atmAxVal to value of atmFe as string
481
+ if atmAxVal contains ${asString(needle)} then set atmGotBody to atmAxVal
482
+ end tell
483
+ end if
484
+ end try
485
+ end if
486
+ if atmGotBody does not contain ${asString(needle)} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"`
487
+ : "";
293
488
  return ` if (count of to recipients of newMessage) is not ${toCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
294
- ${stillThere}`;
489
+ if (count of cc recipients of newMessage) is not ${ccCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
490
+ if (count of bcc recipients of newMessage) is not ${bccCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
491
+ ${stillThere}
492
+ if (subject of newMessage as string) is not ${asString(subject)} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
493
+ ${bodyCheck}`;
295
494
  }
296
495
 
297
496
  /**
@@ -303,9 +502,10 @@ ${stillThere}`;
303
502
  *
304
503
  * Do not set AppleScript `content` or `html content`: Ventura+ FB11734014
305
504
  * cite-wraps those setters (`>` prefixes + `<blockquote type="cite">`).
306
- * Recipients are AppleScript `make new … recipient`. The body is pasted
307
- * into the compose window (native editor) via System Events. If paste
308
- * lands in To, recipient count changes and we abort + delete (nothing sent).
505
+ * Recipients and subject are AppleScript properties (never paste). The body
506
+ * is pasted into the compose window (native editor) via System Events only
507
+ * after caret focus leaves the header. If paste lands in To/Subject, or the
508
+ * body never receives the intended text, abort + delete (nothing sent).
309
509
  */
310
510
  export function buildComposeScript({ to, cc, bcc, subject, body, send, html = false }) {
311
511
  const recipients = [
@@ -316,9 +516,9 @@ export function buildComposeScript({ to, cc, bcc, subject, body, send, html = fa
316
516
 
317
517
  const plainBody = composeNativeBody(body, { html });
318
518
  const pasteAndVerify = plainBody
319
- ? ` atmPasteMailBody(${asString(plainBody)})
519
+ ? ` atmPasteMailBody(${asString(plainBody)}, ${asString(subject)})
320
520
  tell application "Mail"
321
- ${recipientStillPresentChecks(to)}
521
+ ${composeMisdirectChecks({ to, cc, bcc, subject, body: plainBody })}
322
522
  end tell`
323
523
  : "";
324
524
 
@@ -710,8 +910,22 @@ export function isMailAccessibilityDenial(result) {
710
910
  return text.includes("accessibility_denied") || text.includes("assistive access");
711
911
  }
712
912
 
913
+ export function isMailBodyFocusFailed(result) {
914
+ if (!result) return false;
915
+ return String(result.error || "").toLowerCase().includes("body_focus_failed");
916
+ }
917
+
918
+ export function isMailBodyPasteMisdirected(result) {
919
+ if (!result) return false;
920
+ const text = String(result.error || "").toLowerCase();
921
+ return text.includes("body_paste_misdirected") || text.includes("body_focus_failed");
922
+ }
923
+
713
924
  function failure(action, summary, result, secrets) {
714
925
  const err = String((result && result.error) || "").toLowerCase();
926
+ if (err.includes("body_focus_failed")) {
927
+ return `${action} failed — attempted to ${summary}. The compose caret could not be moved into the message body; nothing was sent.`;
928
+ }
715
929
  if (err.includes("body_paste_misdirected")) {
716
930
  return `${action} failed — attempted to ${summary}. The body was pasted into a header field instead of the message body; nothing was sent.`;
717
931
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "2.0.7",
3
+ "version": "2.0.9",
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",