apple-tools-mcp 2.0.7 → 2.0.8

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,13 @@ 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”).
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`).
470
472
 
471
473
  **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
474
 
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.
475
+ **Manual prove (2.0.8 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. 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** on body focus). That quote-prefix dual-host Sent prove is a separate bar from the 2.0.7 verify contract.
474
476
 
475
477
  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
478
 
package/lib/mailWrite.js CHANGED
@@ -155,6 +155,9 @@ 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;
158
161
 
159
162
  /**
160
163
  * Body text for native Mail compose (clipboard paste, not AppleScript content).
@@ -172,51 +175,117 @@ export function composeNativeBody(body, { html = false } = {}) {
172
175
  }
173
176
 
174
177
  /**
175
- * AppleScript handlers: focus the compose body (not To) and paste.
178
+ * Distinctive first line used to prove the native editor received the body.
179
+ * Full multiline `contains` can miss when Mail stores an HTML alternative.
180
+ */
181
+ export function composeBodyNeedle(body) {
182
+ const text = body === undefined || body === null ? "" : String(body);
183
+ const line = text.split(/\r?\n/).find((candidate) => candidate.trim()) || text;
184
+ const needle = line.trim();
185
+ return needle.length > 120 ? needle.slice(0, 120) : needle;
186
+ }
187
+
188
+ const BODY_MIN_H = asInteger(MAIL_BODY_MIN_AX_HEIGHT, { min: 1, max: 500, field: "bodyMinHeight" });
189
+
190
+ /**
191
+ * AppleScript handlers: focus the compose body (not To/Subject) and paste.
176
192
  * System Events needs Accessibility; Mail make/send still needs Automation.
177
193
  *
178
194
  * macOS 26.x System Events has no `web area` class (compile -2741,
179
195
  * "Expected class name but found identifier"). Hosts compile
180
- * `text area` / `scroll area` and `UI element whose role is "AXWebArea"`.
196
+ * `text area` / `scroll area` / `text field` and `UI element whose role is
197
+ * "AXWebArea"`. Never focus the first AXTextArea blindly — on MacBook Mail
198
+ * that is a header field, and Cmd-V becomes BODY_PASTE_MISDIRECTED.
199
+ *
200
+ * Strategies, each fail-closed unless the caret left the header:
201
+ * 1. Click a tall AXWebArea (HTML compose body)
202
+ * 2. Focus a tall `text area` (plain compose body), never a short header
203
+ * 3. Click Subject then Tab into the body (stable Mini/MacBook order)
204
+ * 4. Click the lower two-thirds of the compose window
181
205
  */
182
206
  export function buildMailBodyPasteHandler() {
183
- return `on atmFocusMailBody()
207
+ return `on atmSafeToPaste()
184
208
  tell application "System Events"
185
209
  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
210
  try
190
- set focused of text area 1 of scroll area 1 of w to true
191
- return
192
- end try
193
- try
194
- set focused of text area 1 of w to true
195
- return
211
+ set fe to focused UI element
212
+ set r to (role of fe) as string
213
+ if r is "AXTextField" then return false
214
+ if r is "AXComboBox" then return false
215
+ if r is "AXButton" then return false
216
+ if r is "AXMenuButton" then return false
217
+ if r is "AXPopUpButton" then return false
218
+ set eh to 0
219
+ try
220
+ set {ew, eh} to size of fe
221
+ end try
222
+ if r is "AXTextArea" and eh < ${BODY_MIN_H} then return false
223
+ ignoring case
224
+ set bits to ""
225
+ try
226
+ set bits to bits & (description of fe as string) & " "
227
+ end try
228
+ try
229
+ set bits to bits & (name of fe as string) & " "
230
+ end try
231
+ if bits contains "To:" then return false
232
+ if bits contains "Cc:" then return false
233
+ if bits contains "Bcc:" then return false
234
+ if bits contains "Subject" then return false
235
+ end ignoring
236
+ if r is "AXWebArea" then return true
237
+ if r is "AXTextArea" then return true
238
+ if eh >= ${BODY_MIN_H} then return true
196
239
  end try
240
+ return false
241
+ end tell
242
+ end tell
243
+ end atmSafeToPaste
244
+
245
+ on atmFocusMailBody(expectedSubject)
246
+ tell application "System Events"
247
+ tell process "Mail"
248
+ set frontmost to true
249
+ if (count of windows) < 1 then error "MAIL_COMPOSE_WINDOW_MISSING"
250
+ set w to window 1
251
+ if expectedSubject is not "" then
252
+ repeat with i from 1 to (count of windows)
253
+ try
254
+ if (name of window i as string) is expectedSubject then
255
+ set w to window i
256
+ exit repeat
257
+ end if
258
+ end try
259
+ end repeat
260
+ end if
197
261
  try
198
- set focused of (first UI element of w whose role is "AXTextArea") to true
199
- return
262
+ perform action "AXRaise" of w
200
263
  end try
201
264
  try
202
265
  click (first UI element of w whose role is "AXWebArea")
203
- return
266
+ delay 0.08
267
+ if my atmSafeToPaste() then return
204
268
  end try
205
269
  try
206
270
  click (first UI element of scroll area 1 of w whose role is "AXWebArea")
207
- return
271
+ delay 0.08
272
+ if my atmSafeToPaste() then return
208
273
  end try
209
274
  try
210
275
  click (first UI element of scroll area 1 of splitter group 1 of w whose role is "AXWebArea")
211
- return
276
+ delay 0.08
277
+ if my atmSafeToPaste() then return
212
278
  end try
213
279
  try
214
280
  click (first UI element of scroll area 1 of group 1 of splitter group 1 of w whose role is "AXWebArea")
215
- return
281
+ delay 0.08
282
+ if my atmSafeToPaste() then return
216
283
  end try
284
+ set atmTallText to missing value
285
+ set atmSubjectElem to missing value
217
286
  set atmQueue to UI elements of w
218
287
  set atmWalked to 0
219
- repeat while (count of atmQueue) > 0 and atmWalked < 80
288
+ repeat while (count of atmQueue) > 0 and atmWalked < 120
220
289
  set atmWalked to atmWalked + 1
221
290
  set atmElem to item 1 of atmQueue
222
291
  if (count of atmQueue) is 1 then
@@ -226,14 +295,33 @@ export function buildMailBodyPasteHandler() {
226
295
  end if
227
296
  try
228
297
  if (role of atmElem as string) is "AXWebArea" then
229
- click atmElem
230
- return
298
+ set {ew, eh} to size of atmElem
299
+ if eh >= ${BODY_MIN_H} then
300
+ click atmElem
301
+ delay 0.08
302
+ if my atmSafeToPaste() then return
303
+ end if
231
304
  end if
232
305
  end try
233
306
  try
234
307
  if (role of atmElem as string) is "AXTextArea" then
235
- set focused of atmElem to true
236
- return
308
+ set {tw, th} to size of atmElem
309
+ if th >= ${BODY_MIN_H} and atmTallText is missing value then set atmTallText to atmElem
310
+ end if
311
+ end try
312
+ try
313
+ set r to (role of atmElem as string)
314
+ if r is "AXTextField" or r is "AXTextArea" then
315
+ set bits to ""
316
+ try
317
+ set bits to bits & (description of atmElem as string)
318
+ end try
319
+ try
320
+ set bits to bits & (name of atmElem as string)
321
+ end try
322
+ ignoring case
323
+ if bits contains "Subject" and atmSubjectElem is missing value then set atmSubjectElem to atmElem
324
+ end ignoring
237
325
  end if
238
326
  end try
239
327
  try
@@ -243,14 +331,52 @@ export function buildMailBodyPasteHandler() {
243
331
  end repeat
244
332
  end try
245
333
  end repeat
334
+ try
335
+ set ta to text area 1 of scroll area 1 of w
336
+ set {tw, th} to size of ta
337
+ if th >= ${BODY_MIN_H} then
338
+ set focused of ta to true
339
+ delay 0.08
340
+ if my atmSafeToPaste() then return
341
+ end if
342
+ end try
343
+ if atmTallText is not missing value then
344
+ try
345
+ set focused of atmTallText to true
346
+ end try
347
+ try
348
+ click atmTallText
349
+ end try
350
+ delay 0.08
351
+ if my atmSafeToPaste() then return
352
+ end if
353
+ try
354
+ click (first text field of w whose description contains "Subject")
355
+ delay 0.08
356
+ key code 48
357
+ delay 0.08
358
+ if my atmSafeToPaste() then return
359
+ end try
360
+ if atmSubjectElem is not missing value then
361
+ try
362
+ click atmSubjectElem
363
+ delay 0.08
364
+ key code 48
365
+ delay 0.08
366
+ if my atmSafeToPaste() then return
367
+ end try
368
+ end if
246
369
  set {wx, wy} to position of w
247
370
  set {ww, wh} to size of w
248
371
  click at {(wx + (ww div 2)) as integer, (wy + ((wh * 2) div 3)) as integer}
372
+ delay 0.08
373
+ if my atmSafeToPaste() then return
374
+ error "${BODY_FOCUS_FAILED_SENTINEL}"
249
375
  end tell
250
376
  end tell
251
377
  end atmFocusMailBody
252
378
 
253
- on atmPasteMailBody(bodyText)
379
+ on atmPasteMailBody(bodyText, expectedSubject)
254
380
  set savedClip to ""
255
381
  set hadClip to false
256
382
  try
@@ -259,15 +385,16 @@ on atmPasteMailBody(bodyText)
259
385
  end try
260
386
  set the clipboard to bodyText
261
387
  tell application "Mail" to activate
262
- delay 0.25
388
+ delay 0.4
263
389
  try
264
- atmFocusMailBody()
390
+ atmFocusMailBody(expectedSubject)
391
+ if atmSafeToPaste() is false then error "${BODY_FOCUS_FAILED_SENTINEL}"
265
392
  tell application "System Events"
266
393
  tell process "Mail"
267
394
  keystroke "v" using command down
268
395
  end tell
269
396
  end tell
270
- delay 0.15
397
+ delay 0.2
271
398
  on error errMsg number errNum
272
399
  if hadClip then set the clipboard to savedClip
273
400
  if errMsg contains "assistive access" or errNum is -25211 then error "${ACCESSIBILITY_DENIED_SENTINEL}"
@@ -277,8 +404,11 @@ on atmPasteMailBody(bodyText)
277
404
  end atmPasteMailBody`;
278
405
  }
279
406
 
280
- function recipientStillPresentChecks(to) {
407
+ function composeMisdirectChecks({ to, cc = [], bcc = [], subject, body }) {
281
408
  const toCount = asInteger(to.length, { min: 1, max: 99, field: "toCount" });
409
+ const ccCount = asInteger(cc.length, { min: 0, max: 99, field: "ccCount" });
410
+ const bccCount = asInteger(bcc.length, { min: 0, max: 99, field: "bccCount" });
411
+ const needle = composeBodyNeedle(body);
282
412
  const stillThere = to
283
413
  .map((address, i) => {
284
414
  const n = asInteger(i, { min: 0, max: 99, field: "recipientIndex" });
@@ -290,8 +420,38 @@ function recipientStillPresentChecks(to) {
290
420
  if atmFound${n} is false then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"`;
291
421
  })
292
422
  .join("\n");
423
+ const bodyCheck = needle
424
+ ? ` set atmGotBody to ""
425
+ try
426
+ set atmGotBody to content of newMessage as string
427
+ end try
428
+ if atmGotBody is "" then
429
+ try
430
+ save newMessage
431
+ end try
432
+ delay 0.2
433
+ try
434
+ set atmGotBody to content of newMessage as string
435
+ end try
436
+ end if
437
+ if atmGotBody does not contain ${asString(needle)} then
438
+ try
439
+ tell application "System Events"
440
+ tell process "Mail"
441
+ set atmAxVal to value of focused UI element as string
442
+ if atmAxVal contains ${asString(needle)} then set atmGotBody to atmAxVal
443
+ end tell
444
+ end tell
445
+ end try
446
+ end if
447
+ if atmGotBody does not contain ${asString(needle)} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"`
448
+ : "";
293
449
  return ` if (count of to recipients of newMessage) is not ${toCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
294
- ${stillThere}`;
450
+ if (count of cc recipients of newMessage) is not ${ccCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
451
+ if (count of bcc recipients of newMessage) is not ${bccCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
452
+ ${stillThere}
453
+ if (subject of newMessage as string) is not ${asString(subject)} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
454
+ ${bodyCheck}`;
295
455
  }
296
456
 
297
457
  /**
@@ -303,9 +463,10 @@ ${stillThere}`;
303
463
  *
304
464
  * Do not set AppleScript `content` or `html content`: Ventura+ FB11734014
305
465
  * 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).
466
+ * Recipients and subject are AppleScript properties (never paste). The body
467
+ * is pasted into the compose window (native editor) via System Events only
468
+ * after caret focus leaves the header. If paste lands in To/Subject, or the
469
+ * body never receives the intended text, abort + delete (nothing sent).
309
470
  */
310
471
  export function buildComposeScript({ to, cc, bcc, subject, body, send, html = false }) {
311
472
  const recipients = [
@@ -316,9 +477,9 @@ export function buildComposeScript({ to, cc, bcc, subject, body, send, html = fa
316
477
 
317
478
  const plainBody = composeNativeBody(body, { html });
318
479
  const pasteAndVerify = plainBody
319
- ? ` atmPasteMailBody(${asString(plainBody)})
480
+ ? ` atmPasteMailBody(${asString(plainBody)}, ${asString(subject)})
320
481
  tell application "Mail"
321
- ${recipientStillPresentChecks(to)}
482
+ ${composeMisdirectChecks({ to, cc, bcc, subject, body: plainBody })}
322
483
  end tell`
323
484
  : "";
324
485
 
@@ -710,8 +871,22 @@ export function isMailAccessibilityDenial(result) {
710
871
  return text.includes("accessibility_denied") || text.includes("assistive access");
711
872
  }
712
873
 
874
+ export function isMailBodyFocusFailed(result) {
875
+ if (!result) return false;
876
+ return String(result.error || "").toLowerCase().includes("body_focus_failed");
877
+ }
878
+
879
+ export function isMailBodyPasteMisdirected(result) {
880
+ if (!result) return false;
881
+ const text = String(result.error || "").toLowerCase();
882
+ return text.includes("body_paste_misdirected") || text.includes("body_focus_failed");
883
+ }
884
+
713
885
  function failure(action, summary, result, secrets) {
714
886
  const err = String((result && result.error) || "").toLowerCase();
887
+ if (err.includes("body_focus_failed")) {
888
+ return `${action} failed — attempted to ${summary}. The compose caret could not be moved into the message body; nothing was sent.`;
889
+ }
715
890
  if (err.includes("body_paste_misdirected")) {
716
891
  return `${action} failed — attempted to ${summary}. The body was pasted into a header field instead of the message body; nothing was sent.`;
717
892
  }
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.8",
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",