apple-tools-mcp 2.0.5 → 2.0.7

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
@@ -446,6 +446,7 @@ Two arguments are available on **every** write tool:
446
446
  - **Deletes always need `confirm: true`** — `mail_trash`, `calendar_remove`, `contacts_remove`, and `contacts_edit` when it clears every email or phone. Without it the call returns a `CONFIRMATION REQUIRED` preview and changes nothing. There is no bulk delete tool and no silent mass delete: one id per call.
447
447
  - **Multi-recipient sends always need `confirm: true`** — `mail_send` / `mail_forward` when `to` + `cc` + `bcc` total more than one address, `mail_reply` with `reply_all: true`, and `messages_send` to multiple handles or to a group chat. A single-recipient send runs on the first call.
448
448
  - **`mail_draft` is exempt from the recipient rule** because a draft is never delivered.
449
+ - **`dry_run` and confirm-blocked calls are not deliveries.** Planned writes return `ok: false`, `planned: true`, `delivered: false`, and MCP sets `isError: true`, so a client that only checks `isError` cannot treat a preview as sent. The text is `DRY RUN` / `CONFIRMATION REQUIRED` and never `verified in Sent`. Re-run with `dry_run=false` (and `confirm=true` when required) to actually send.
449
450
  - Responses name what happened (tool, ids, recipients, titles) and never echo message bodies — including on the error path.
450
451
  - Writes never invent data. A missing or malformed `message_id`, `event_id`, `contact_id`, `chat_id`, or recipient is refused with a message saying so. Calendar times must be explicit local datetimes (`YYYY-MM-DD HH:MM`); natural language such as "next Tuesday" is rejected for writes.
451
452
 
@@ -465,13 +466,15 @@ Two arguments are available on **every** write tool:
465
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 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.
467
468
 
468
- 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`).
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
470
 
470
- **Manual prove (2.0.5 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`).
471
+ **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
+
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.
471
474
 
472
475
  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.
473
476
 
474
- **Send timeout vs TCC — check Sent before retrying.** `mail_send` / `mail_reply` / `mail_forward` can hang after Mail has already put the message in Sent. That hang is a **timeout** (`ETIMEDOUT` / `-1712` / AppleEvent timed out), not a TCC deny. The same hang-vs-TCC split applies to **find / reply / open before send** — an Allowed `ETIMEDOUT` there is never `MAIL_TCC_GUIDANCE`. Real Mail Automation denials report **`-1743`**, **`-10004`**, or “not authorized to send Apple events”. After a send hang the tool looks in Sent (and Outbox) and returns **success** if the message is there — never label a delivered send as TCC fail or timeout. Replies match `In-Reply-To` or an exact recent `Re:` + original subject (the fallback compose does not set reply headers). Forwards match the intended recipient plus an exact `Fwd:` subject or the original Message-ID in a forwarded body — not `In-Reply-To`, and not an unrelated `Fwd:` that only shares a To. Neither scan treats the original itself as this send. If Sent-verify misses, the error tells you it was a hang and to check Sent. **Clients must Sent-check before retrying a timed-out send**; retrying a message that already landed sends a second copy. This is not a silent TCC grant.
477
+ **Send timeout vs TCC — check Sent before retrying.** `mail_send` / `mail_reply` / `mail_forward` can hang after Mail has already put the message in Sent. That hang is a **timeout** (`ETIMEDOUT` / `-1712` / AppleEvent timed out), not a TCC deny. The same hang-vs-TCC split applies to **find / reply / open before send** — an Allowed `ETIMEDOUT` there is never `MAIL_TCC_GUIDANCE`. Real Mail Automation denials report **`-1743`**, **`-10004`**, or “not authorized to send Apple events”. After a send hang the tool looks in Sent (and Outbox) and returns **success** if the message is there — never label a delivered send as TCC fail or timeout. Replies match `In-Reply-To` or an exact recent `Re:` + original subject (the fallback compose does not set reply headers). Forwards match the intended recipient plus an exact `Fwd:` subject or the original Message-ID in a forwarded body — not `In-Reply-To`, and not an unrelated `Fwd:` that only shares a To. Compose (`mail_send`) matches the intended **To plus exact subject** (and Message-ID when available), never subject alone. Neither scan treats the original itself as this send. A real send that returns without throw is also verified the same way before success. If Sent-verify misses, the error tells you it was a hang (or a verify-miss after send returned) and to check Sent. **Clients must Sent-check before retrying a timed-out send**; retrying a message that already landed sends a second copy. This is not a silent TCC grant.
475
478
 
476
479
  ### Messages write tool
477
480
 
package/index.js CHANGED
@@ -27,7 +27,8 @@ import {
27
27
  WRITE_TOOL_DEFINITIONS,
28
28
  isWriteTool,
29
29
  dispatchWriteTool,
30
- executeWriteToolLocally
30
+ executeWriteToolLocally,
31
+ mcpWriteResult
31
32
  } from "./lib/writeTools.js";
32
33
  import { startWriteBridgeServer, defaultSocketPath } from "./lib/writeBridge.js";
33
34
  import { closeEventKitSession, ensureEventKitSession } from "./lib/eventKitSession.js";
@@ -1373,10 +1374,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1373
1374
  socketPath: WRITE_SOCKET_PATH,
1374
1375
  log: (msg) => console.error(msg)
1375
1376
  });
1376
- return {
1377
- content: [{ type: "text", text: writeResult.message }],
1378
- ...(writeResult.ok === false ? { isError: true } : {})
1379
- };
1377
+ return mcpWriteResult(writeResult);
1380
1378
  }
1381
1379
 
1382
1380
  switch (name) {
@@ -353,7 +353,7 @@ export const MAIL_SEND_TIMEOUT_GUIDANCE =
353
353
  "Mail AppleScript timed out (ETIMEDOUT / -1712 / AppleEvent timed out) — this is a find/reply/send hang (including find/reply/open before send), not a TCC / Automation deny. " +
354
354
  "Real Mail Automation denials report -1743, -10004, or \"not authorized to send Apple events\". " +
355
355
  "A timeout can happen after Mail already delivered the message. Check Sent (and Outbox) for this send before retrying; a retry after a successful delivery sends a second copy. " +
356
- "This tool verifies Sent when a send hangs; if nothing is there yet, wait and look again rather than immediately resending.";
356
+ "This tool verifies Sent/Outbox when a send hangs (To + subject, Message-ID when available — never subject alone); if nothing is there yet, wait and look again rather than immediately resending.";
357
357
 
358
358
  /**
359
359
  * Messages writes are a separate Automation target from Mail / Contacts / Calendar.
@@ -22,6 +22,7 @@ import {
22
22
  } from "./appleScript.js";
23
23
  import {
24
24
  planWrite,
25
+ plannedWriteResult,
25
26
  validateEventId,
26
27
  validateEventKitId,
27
28
  validateCalendarName,
@@ -772,7 +773,7 @@ export function calendarAdd(args = {}) {
772
773
  `${alerts.minutes.length ? ` with alerts ${alerts.minutes.join(", ")} min before` : ""}`;
773
774
 
774
775
  const plan = planWrite({ action, summary, dryRun: isFlagTrue(args.dry_run), confirm: isFlagTrue(args.confirm) });
775
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
776
+ if (!plan.proceed) return plannedWriteResult(plan);
776
777
 
777
778
  // Non-recurring creates MUST go through EventKit. Mini 8be6cd5: EventKit
778
779
  // add failed (likely writeOnly cannot list calendars) and we silently
@@ -1037,7 +1038,7 @@ export function calendarEdit(args = {}) {
1037
1038
 
1038
1039
  const summary = `update event ${eventId}: ${changed.join(", ")}`;
1039
1040
  const plan = planWrite({ action, summary, dryRun: isFlagTrue(args.dry_run), confirm: isFlagTrue(args.confirm) });
1040
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1041
+ if (!plan.proceed) return plannedWriteResult(plan);
1041
1042
 
1042
1043
  const preferEventKit = Boolean(eventKitId) || eventId.includes(":");
1043
1044
  if (preferEventKit && !recurrence.rule) {
@@ -1219,7 +1220,7 @@ export function calendarRemove(args = {}) {
1219
1220
  dryRun: isFlagTrue(args.dry_run),
1220
1221
  confirm: isFlagTrue(args.confirm)
1221
1222
  });
1222
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1223
+ if (!plan.proceed) return plannedWriteResult(plan);
1223
1224
 
1224
1225
  // writeOnly cannot re-query; prefer the EventKit session that cached
1225
1226
  // the EKEvent from create. One-shot lookup is a full-access fallback.
@@ -1339,7 +1340,7 @@ export function calendarRsvp(args = {}) {
1339
1340
 
1340
1341
  const summary = `RSVP ${responseRaw} to event ${eventId}`;
1341
1342
  const plan = planWrite({ action, summary, dryRun: isFlagTrue(args.dry_run), confirm: isFlagTrue(args.confirm) });
1342
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1343
+ if (!plan.proceed) return plannedWriteResult(plan);
1343
1344
 
1344
1345
  const result = runAppleScript(
1345
1346
  buildRsvpScript({ eventId, status: RSVP_STATUS[responseRaw], attendeeEmail }),
@@ -20,6 +20,7 @@ import {
20
20
  import { safeOpenApp } from "./shell.js";
21
21
  import {
22
22
  planWrite,
23
+ plannedWriteResult,
23
24
  normalizeList,
24
25
  isEmailAddress,
25
26
  isPhoneNumber,
@@ -254,7 +255,7 @@ export function contactsAdd(args = {}, deps = {}) {
254
255
  `${phones.phones.length ? ` and ${phones.phones.length} phone number(s)` : ""}`;
255
256
 
256
257
  const plan = planWrite({ action, summary, dryRun: isFlagTrue(args.dry_run), confirm: isFlagTrue(args.confirm) });
257
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
258
+ if (!plan.proceed) return plannedWriteResult(plan);
258
259
 
259
260
  const properties = {};
260
261
  if (firstName.text) properties["first name"] = firstName.text;
@@ -357,7 +358,7 @@ export function contactsEdit(args = {}, deps = {}) {
357
358
  dryRun: isFlagTrue(args.dry_run),
358
359
  confirm: isFlagTrue(args.confirm)
359
360
  });
360
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
361
+ if (!plan.proceed) return plannedWriteResult(plan);
361
362
 
362
363
  const ready = ensureContactsAppReady(deps);
363
364
  if (!ready.ok) return { ok: false, message: failure(action, summary, ready) };
@@ -413,7 +414,7 @@ export function contactsRemove(args = {}, deps = {}) {
413
414
  dryRun: isFlagTrue(args.dry_run),
414
415
  confirm: isFlagTrue(args.confirm)
415
416
  });
416
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
417
+ if (!plan.proceed) return plannedWriteResult(plan);
417
418
 
418
419
  const ready = ensureContactsAppReady(deps);
419
420
  if (!ready.ok) return { ok: false, message: failure(action, summary, ready) };
package/lib/mailWrite.js CHANGED
@@ -7,10 +7,12 @@
7
7
  * is resolved to a Message-ID by reading the .emlx headers, so callers never
8
8
  * have to invent an identifier.
9
9
  *
10
- * After a send/reply/forward hang, Sent (and Outbox) is checked before the
11
- * tool reports failure. A message already in Sent is success — never a TCC
12
- * fail. ETIMEDOUT / -1712 is a timeout (find/reply/open before send too);
13
- * -1743 / -10004 is a hard deny.
10
+ * After a real send (and after a send/reply/forward hang), Sent and Outbox
11
+ * are checked before the tool reports success. AppleScript `send` returning
12
+ * without throw is not enough. A message already in Sent or still in Outbox
13
+ * is success — never a TCC fail. Hang recover matches To + subject (and
14
+ * Message-ID when available), never subject alone. ETIMEDOUT / -1712 is a
15
+ * timeout (find/reply/open before send too); -1743 / -10004 is a hard deny.
14
16
  */
15
17
 
16
18
  import fs from "fs";
@@ -29,6 +31,7 @@ import {
29
31
  } from "./appleScript.js";
30
32
  import {
31
33
  planWrite,
34
+ plannedWriteResult,
32
35
  validateEmailList,
33
36
  validateBody,
34
37
  validateSubject,
@@ -171,6 +174,10 @@ export function composeNativeBody(body, { html = false } = {}) {
171
174
  /**
172
175
  * AppleScript handlers: focus the compose body (not To) and paste.
173
176
  * System Events needs Accessibility; Mail make/send still needs Automation.
177
+ *
178
+ * 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"`.
174
181
  */
175
182
  export function buildMailBodyPasteHandler() {
176
183
  return `on atmFocusMailBody()
@@ -184,21 +191,58 @@ export function buildMailBodyPasteHandler() {
184
191
  return
185
192
  end try
186
193
  try
187
- click (first web area of w)
194
+ set focused of text area 1 of w to true
195
+ return
196
+ end try
197
+ try
198
+ set focused of (first UI element of w whose role is "AXTextArea") to true
199
+ return
200
+ end try
201
+ try
202
+ click (first UI element of w whose role is "AXWebArea")
188
203
  return
189
204
  end try
190
205
  try
191
- click (first web area of scroll area 1 of w)
206
+ click (first UI element of scroll area 1 of w whose role is "AXWebArea")
192
207
  return
193
208
  end try
194
209
  try
195
- click (first web area of scroll area 1 of splitter group 1 of w)
210
+ click (first UI element of scroll area 1 of splitter group 1 of w whose role is "AXWebArea")
196
211
  return
197
212
  end try
198
213
  try
199
- click (first web area of scroll area 1 of group 1 of splitter group 1 of w)
214
+ click (first UI element of scroll area 1 of group 1 of splitter group 1 of w whose role is "AXWebArea")
200
215
  return
201
216
  end try
217
+ set atmQueue to UI elements of w
218
+ set atmWalked to 0
219
+ repeat while (count of atmQueue) > 0 and atmWalked < 80
220
+ set atmWalked to atmWalked + 1
221
+ set atmElem to item 1 of atmQueue
222
+ if (count of atmQueue) is 1 then
223
+ set atmQueue to {}
224
+ else
225
+ set atmQueue to items 2 thru -1 of atmQueue
226
+ end if
227
+ try
228
+ if (role of atmElem as string) is "AXWebArea" then
229
+ click atmElem
230
+ return
231
+ end if
232
+ end try
233
+ try
234
+ if (role of atmElem as string) is "AXTextArea" then
235
+ set focused of atmElem to true
236
+ return
237
+ end if
238
+ end try
239
+ try
240
+ set atmKids to UI elements of atmElem
241
+ repeat with i from 1 to (count of atmKids)
242
+ set end of atmQueue to item i of atmKids
243
+ end repeat
244
+ end try
245
+ end repeat
202
246
  set {wx, wy} to position of w
203
247
  set {ww, wh} to size of w
204
248
  click at {(wx + (ww div 2)) as integer, (wy + ((wh * 2) div 3)) as integer}
@@ -295,9 +339,14 @@ on error errMsg number errNum
295
339
  end try
296
340
  error errMsg number errNum
297
341
  end try
342
+ set atmOutgoingId to ""
298
343
  tell application "Mail"
344
+ try
345
+ set atmOutgoingId to message id of newMessage as string
346
+ end try
299
347
  ${send ? "send newMessage" : "save newMessage"}
300
348
  end tell
349
+ if atmOutgoingId is not "" then return atmOutgoingId
301
350
  return "OK"`;
302
351
  }
303
352
 
@@ -309,8 +358,31 @@ function summarizeRecipients(to, cc, bcc) {
309
358
  return parts.join("; ");
310
359
  }
311
360
 
361
+ export const SENT_VERIFY_SENT = "SENT";
362
+ export const SENT_VERIFY_OUTBOX = "OUTBOX";
363
+ export const SENT_VERIFY_NOT_FOUND = "NOT_FOUND";
364
+ /** Older hang-recovery scripts returned FOUND to mean Sent. Still accepted. */
312
365
  export const SENT_VERIFY_FOUND = "FOUND";
313
366
  export const SENT_VERIFY_TIMEOUT_MS = 15000;
367
+ export const SENT_VERIFY_ATTEMPTS = 3;
368
+ export const SENT_VERIFY_RETRY_MS = 400;
369
+
370
+ export const MAIL_VERIFY_MISS_GUIDANCE =
371
+ "Mail reported the send call succeeded, but the message was not found in Sent or Outbox. Do not assume it was delivered.";
372
+
373
+ function defaultSentVerifySleep(ms) {
374
+ if (!ms || ms <= 0) return;
375
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
376
+ }
377
+
378
+ /** Test hook: skip the retry delay without changing attempt count. */
379
+ export const sentVerifyClock = {
380
+ sleep: defaultSentVerifySleep
381
+ };
382
+
383
+ export function resetSentVerifyClock() {
384
+ sentVerifyClock.sleep = defaultSentVerifySleep;
385
+ }
314
386
 
315
387
  /**
316
388
  * Hard Mail Automation deny: -1743 / -10004 / "not authorized…", or
@@ -335,10 +407,10 @@ function mailBoxesPreamble() {
335
407
  return ` set cutoff to (current date) - (10 * minutes)
336
408
  set boxes to {}
337
409
  try
338
- set end of boxes to sent mailbox
410
+ set end of boxes to outgoing mailbox
339
411
  end try
340
412
  try
341
- set end of boxes to outgoing mailbox
413
+ set end of boxes to sent mailbox
342
414
  end try
343
415
  repeat with acct in accounts
344
416
  try
@@ -351,6 +423,48 @@ function appleScriptToList(addresses = []) {
351
423
  return `{${addresses.map((address) => asString(address)).join(", ")}}`;
352
424
  }
353
425
 
426
+ function loadMailboxMessagesSnippet() {
427
+ return ` set recentMsgs to {}
428
+ set scanAll to false
429
+ try
430
+ if boxRef is outgoing mailbox then set scanAll to true
431
+ end try
432
+ try
433
+ set nm to name of boxRef as string
434
+ if nm contains "Outbox" then set scanAll to true
435
+ if nm contains "Outgoing" then set scanAll to true
436
+ end try
437
+ if scanAll then
438
+ set recentMsgs to messages of boxRef
439
+ else
440
+ set recentMsgs to (messages of boxRef whose date sent > cutoff)
441
+ end if`;
442
+ }
443
+
444
+ function verifyHitReturn() {
445
+ return ` try
446
+ if boxRef is outgoing mailbox then return "${SENT_VERIFY_OUTBOX}"
447
+ end try
448
+ try
449
+ set nm to name of boxRef as string
450
+ if nm contains "Outbox" then return "${SENT_VERIFY_OUTBOX}"
451
+ if nm contains "Outgoing" then return "${SENT_VERIFY_OUTBOX}"
452
+ end try
453
+ return "${SENT_VERIFY_SENT}"`;
454
+ }
455
+
456
+ function recipientHitSnippet() {
457
+ return ` set hitTo to false
458
+ try
459
+ repeat with recip in (to recipients of msg)
460
+ set recipAddr to address of recip as string
461
+ repeat with wanted in wantedTos
462
+ if recipAddr is (wanted as string) then set hitTo to true
463
+ end repeat
464
+ end repeat
465
+ end try`;
466
+ }
467
+
354
468
  /**
355
469
  * Look in Sent / Outbox for a reply of `messageId`.
356
470
  * Proper Mail replies set In-Reply-To. The fallback `make new outgoing
@@ -373,7 +487,7 @@ tell application "Mail"
373
487
  ${mailBoxesPreamble()}
374
488
  repeat with boxRef in boxes
375
489
  try
376
- set recentMsgs to (messages of boxRef whose date sent > cutoff)
490
+ ${loadMailboxMessagesSnippet()}
377
491
  repeat with msg in recentMsgs
378
492
  try
379
493
  set candId to message id of msg
@@ -382,12 +496,18 @@ ${mailBoxesPreamble()}
382
496
  else
383
497
  try
384
498
  set src to source of msg
385
- if src contains ("In-Reply-To: " & ${needleAngle}) then return "${SENT_VERIFY_FOUND}"
386
- if src contains ("In-Reply-To: " & ${needleBare}) then return "${SENT_VERIFY_FOUND}"
499
+ if src contains ("In-Reply-To: " & ${needleAngle}) then
500
+ ${verifyHitReturn()}
501
+ end if
502
+ if src contains ("In-Reply-To: " & ${needleBare}) then
503
+ ${verifyHitReturn()}
504
+ end if
387
505
  end try
388
506
  if origSubject is not "" then
389
507
  set subj to subject of msg
390
- if subj is ("Re: " & origSubject) then return "${SENT_VERIFY_FOUND}"
508
+ if subj is ("Re: " & origSubject) then
509
+ ${verifyHitReturn()}
510
+ end if
391
511
  end if
392
512
  end if
393
513
  end try
@@ -395,7 +515,7 @@ ${mailBoxesPreamble()}
395
515
  end try
396
516
  end repeat
397
517
  end tell
398
- return "NOT_FOUND"`;
518
+ return "${SENT_VERIFY_NOT_FOUND}"`;
399
519
  }
400
520
 
401
521
  /**
@@ -422,7 +542,7 @@ ${mailBoxesPreamble()}
422
542
  set wantedTos to ${toList}
423
543
  repeat with boxRef in boxes
424
544
  try
425
- set recentMsgs to (messages of boxRef whose date sent > cutoff)
545
+ ${loadMailboxMessagesSnippet()}
426
546
  repeat with msg in recentMsgs
427
547
  try
428
548
  set candId to message id of msg
@@ -433,15 +553,7 @@ ${mailBoxesPreamble()}
433
553
  if src contains ("In-Reply-To: " & ${needleAngle}) or src contains ("In-Reply-To: " & ${needleBare}) then
434
554
  -- A reply to the original is not this forward.
435
555
  else
436
- set hitTo to false
437
- try
438
- repeat with recip in (to recipients of msg)
439
- set recipAddr to address of recip as string
440
- repeat with wanted in wantedTos
441
- if recipAddr is (wanted as string) then set hitTo to true
442
- end repeat
443
- end repeat
444
- end try
556
+ ${recipientHitSnippet()}
445
557
  if hitTo then
446
558
  set subj to subject of msg
447
559
  set exactFwd to false
@@ -457,8 +569,12 @@ ${mailBoxesPreamble()}
457
569
  set mentionsOrigId to false
458
570
  if src contains ${needleAngle} then set mentionsOrigId to true
459
571
  if src contains ${needleBare} then set mentionsOrigId to true
460
- if exactFwd then return "${SENT_VERIFY_FOUND}"
461
- if looksForward and mentionsOrigId then return "${SENT_VERIFY_FOUND}"
572
+ if exactFwd then
573
+ ${verifyHitReturn()}
574
+ end if
575
+ if looksForward and mentionsOrigId then
576
+ ${verifyHitReturn()}
577
+ end if
462
578
  end if
463
579
  end if
464
580
  end if
@@ -467,45 +583,125 @@ ${mailBoxesPreamble()}
467
583
  end try
468
584
  end repeat
469
585
  end tell
470
- return "NOT_FOUND"`;
586
+ return "${SENT_VERIFY_NOT_FOUND}"`;
471
587
  }
472
588
 
473
589
  /**
474
- * Look in Sent / Outbox for a compose whose subject matches exactly and
475
- * was sent in the last 10 minutes.
590
+ * Look in Sent / Outbox for a compose matching To + subject.
591
+ * Never matches subject alone — short subjects like "test" are unsafe.
592
+ * When `messageId` is present, prefer that id together with To.
476
593
  */
477
- export function buildFindSentBySubjectScript(subject) {
478
- const subj = asString(subject);
594
+ export function buildFindSentByRecipientAndSubjectScript(subject, toAddresses = [], messageId = null) {
595
+ const toList = appleScriptToList(toAddresses);
596
+ const subj = asString(subject || "");
597
+ const idLit = messageId ? asString(messageId) : null;
598
+ const matchBody = idLit
599
+ ? `${recipientHitSnippet()}
600
+ set candId to message id of msg
601
+ set hitId to false
602
+ try
603
+ if candId is ${idLit} then set hitId to true
604
+ end try
605
+ if hitId and hitTo then
606
+ ${verifyHitReturn()}
607
+ end if
608
+ if hitTo then
609
+ set subj to subject of msg
610
+ if subj is ${subj} then
611
+ ${verifyHitReturn()}
612
+ end if
613
+ end if`
614
+ : `${recipientHitSnippet()}
615
+ if hitTo then
616
+ set subj to subject of msg
617
+ if subj is ${subj} then
618
+ ${verifyHitReturn()}
619
+ end if
620
+ end if`;
621
+
479
622
  return `tell application "Mail"
480
623
  ${mailBoxesPreamble()}
624
+ set wantedTos to ${toList}
481
625
  repeat with boxRef in boxes
482
626
  try
483
- set hits to (messages of boxRef whose subject is ${subj} and date sent > cutoff)
484
- if (count of hits) > 0 then return "${SENT_VERIFY_FOUND}"
627
+ ${loadMailboxMessagesSnippet()}
628
+ repeat with msg in recentMsgs
629
+ try
630
+ ${matchBody}
631
+ end try
632
+ end repeat
485
633
  end try
486
634
  end repeat
487
635
  end tell
488
- return "NOT_FOUND"`;
636
+ return "${SENT_VERIFY_NOT_FOUND}"`;
489
637
  }
490
638
 
491
639
  /**
492
- * After a send hang, ask Mail whether the message is already in Sent.
493
- * Returns a hit object or null. Verify failure / timeout is not success.
640
+ * @deprecated Subject-only matching is unsafe. Requires To as the second
641
+ * argument; without recipients the scan cannot hit.
494
642
  */
495
- export function recoverIfInSent({ inReplyTo = null, subject = null, forwardTo = null } = {}) {
496
- const script = forwardTo && inReplyTo
497
- ? buildFindSentForwardScript(inReplyTo, forwardTo)
498
- : inReplyTo
499
- ? buildFindSentByInReplyToScript(inReplyTo)
500
- : subject
501
- ? buildFindSentBySubjectScript(subject)
502
- : null;
643
+ export function buildFindSentBySubjectScript(subject, toAddresses = []) {
644
+ return buildFindSentByRecipientAndSubjectScript(subject, toAddresses);
645
+ }
646
+
647
+ export function parseSentVerifyOutput(output) {
648
+ const out = String(output || "").trim();
649
+ if (out === SENT_VERIFY_SENT || out === SENT_VERIFY_FOUND) {
650
+ return { found: true, mailbox: "sent" };
651
+ }
652
+ if (out === SENT_VERIFY_OUTBOX) {
653
+ return { found: true, mailbox: "outbox" };
654
+ }
655
+ return null;
656
+ }
657
+
658
+ export function parseOutgoingMessageId(output) {
659
+ const raw = String(output || "").trim();
660
+ if (!raw || raw === "OK") return null;
661
+ return validateMessageId(raw);
662
+ }
663
+
664
+ function pickSentVerifyScript({
665
+ inReplyTo = null,
666
+ subject = null,
667
+ forwardTo = null,
668
+ to = null,
669
+ messageId = null
670
+ } = {}) {
671
+ if (forwardTo && inReplyTo) return buildFindSentForwardScript(inReplyTo, forwardTo);
672
+ if (inReplyTo) return buildFindSentByInReplyToScript(inReplyTo);
673
+ const toList = Array.isArray(to) ? to : [];
674
+ if (toList.length > 0 && (subject || messageId)) {
675
+ return buildFindSentByRecipientAndSubjectScript(subject || "", toList, messageId);
676
+ }
677
+ return null;
678
+ }
679
+
680
+ /**
681
+ * After a send hang, ask Mail whether the message is already in Sent/Outbox.
682
+ * Compose matches require To + subject (Message-ID when available) — never
683
+ * subject alone. Returns a hit object or null. Verify failure is not success.
684
+ */
685
+ export function recoverIfInSent(match = {}) {
686
+ const script = pickSentVerifyScript(match);
503
687
  if (!script) return null;
504
688
  const result = runAppleScript(script, { timeout: SENT_VERIFY_TIMEOUT_MS, appName: "Mail" });
505
689
  if (!result.ok) return null;
506
- const out = String(result.output || "").trim();
507
- if (out === SENT_VERIFY_FOUND) return { found: true };
508
- return null;
690
+ return parseSentVerifyOutput(result.output);
691
+ }
692
+
693
+ /**
694
+ * Poll Sent/Outbox after a send that returned without throw.
695
+ */
696
+ export function verifyQueuedMessage(match = {}, { attempts = SENT_VERIFY_ATTEMPTS, retryMs = SENT_VERIFY_RETRY_MS } = {}) {
697
+ let last = null;
698
+ const n = Math.max(1, attempts);
699
+ for (let i = 0; i < n; i++) {
700
+ last = recoverIfInSent(match);
701
+ if (last) return last;
702
+ if (i < n - 1) sentVerifyClock.sleep(retryMs);
703
+ }
704
+ return last;
509
705
  }
510
706
 
511
707
  export function isMailAccessibilityDenial(result) {
@@ -545,9 +741,41 @@ function failure(action, summary, result, secrets) {
545
741
  }
546
742
 
547
743
  /**
548
- * Finish a compose/reply/forward. A send hang is not labeled TCC; if Mail
549
- * already delivered, return success. Hard -1743/-10004 stays a deny.
744
+ * Finish a compose/reply/forward. A real send is not success until Sent or
745
+ * Outbox verify hits. A send hang is not labeled TCC; if Mail already
746
+ * delivered, return success. Hard -1743/-10004 stays a deny.
550
747
  */
748
+ function deliverySummary(successSummary, mailbox, recovered) {
749
+ if (mailbox === "outbox") {
750
+ return recovered
751
+ ? "queued in Outbox (still sending; verified after AppleScript hang)"
752
+ : "queued in Outbox (still sending)";
753
+ }
754
+ return recovered
755
+ ? `${successSummary} (verified in Sent after AppleScript hang)`
756
+ : `${successSummary} (verified in Sent)`;
757
+ }
758
+
759
+ function sendVerifiedSuccess({ action, successSummary, details, verified, recovered }) {
760
+ const mailbox = verified.mailbox;
761
+ const delivered = mailbox === "sent";
762
+ return {
763
+ ok: true,
764
+ delivered,
765
+ mailbox,
766
+ recovered: recovered || undefined,
767
+ message: writeSuccessMessage(action, deliverySummary(successSummary, mailbox, recovered), {
768
+ ...details,
769
+ mailbox,
770
+ delivery: delivered ? "sent" : "outbox"
771
+ })
772
+ };
773
+ }
774
+
775
+ function verifyMissMessage(action, summary) {
776
+ return `${action} failed — attempted to ${summary}. ${MAIL_VERIFY_MISS_GUIDANCE}`;
777
+ }
778
+
551
779
  function finalizeMailWrite({
552
780
  action,
553
781
  summary,
@@ -557,30 +785,33 @@ function finalizeMailWrite({
557
785
  inReplyTo = null,
558
786
  subject = null,
559
787
  forwardTo = null,
788
+ to = null,
789
+ messageId = null,
560
790
  successSummary,
561
791
  details
562
792
  }) {
793
+ const match = { inReplyTo, subject, forwardTo, to, messageId };
794
+
563
795
  if (result.ok) {
564
- return { ok: true, message: writeSuccessMessage(action, successSummary, details) };
796
+ if (!sendNow) {
797
+ return { ok: true, delivered: false, mailbox: null, message: writeSuccessMessage(action, successSummary, details) };
798
+ }
799
+ const verified = verifyQueuedMessage(match);
800
+ if (!verified) {
801
+ return { ok: false, delivered: false, mailbox: null, message: verifyMissMessage(action, summary) };
802
+ }
803
+ return sendVerifiedSuccess({ action, successSummary, details, verified, recovered: false });
565
804
  }
566
805
 
567
806
  if (sendNow && isMailSendTimeout(result)) {
568
- const recovered = recoverIfInSent({ inReplyTo, subject, forwardTo });
807
+ const recovered = recoverIfInSent(match);
569
808
  if (recovered) {
570
- return {
571
- ok: true,
572
- recovered: true,
573
- message: writeSuccessMessage(
574
- action,
575
- `${successSummary} (verified in Sent after AppleScript hang)`,
576
- details
577
- )
578
- };
809
+ return sendVerifiedSuccess({ action, successSummary, details, verified: recovered, recovered: true });
579
810
  }
580
- return { ok: false, message: failure(action, summary, result, secrets) };
811
+ return { ok: false, delivered: false, mailbox: null, message: failure(action, summary, result, secrets) };
581
812
  }
582
813
 
583
- return { ok: false, message: failure(action, summary, result, secrets) };
814
+ return { ok: false, delivered: false, mailbox: null, message: failure(action, summary, result, secrets) };
584
815
  }
585
816
 
586
817
  /**
@@ -623,7 +854,7 @@ export function mailCompose(args = {}, { draft = false } = {}) {
623
854
  dryRun: isFlagTrue(args.dry_run),
624
855
  confirm: isFlagTrue(args.confirm)
625
856
  });
626
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
857
+ if (!plan.proceed) return plannedWriteResult(plan);
627
858
 
628
859
  const script = buildComposeScript({
629
860
  to: to.addresses,
@@ -643,6 +874,8 @@ export function mailCompose(args = {}, { draft = false } = {}) {
643
874
  secrets: [body.text, subject.text],
644
875
  sendNow: !draft,
645
876
  subject: draft ? null : subject.text,
877
+ to: draft ? null : to.addresses,
878
+ messageId: draft ? null : parseOutgoingMessageId(result.output),
646
879
  successSummary: draft ? "draft saved to Drafts" : "sent",
647
880
  details: {
648
881
  to: to.addresses.join(", "),
@@ -702,7 +935,7 @@ export function mailReply(args = {}) {
702
935
  dryRun: isFlagTrue(args.dry_run),
703
936
  confirm: isFlagTrue(args.confirm)
704
937
  });
705
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
938
+ if (!plan.proceed) return plannedWriteResult(plan);
706
939
 
707
940
  const result = runAppleScript(
708
941
  buildReplyScript({ messageId: resolved.messageId, body: body.text, replyAll, sendNow }),
@@ -765,7 +998,7 @@ export function mailForward(args = {}) {
765
998
  dryRun: isFlagTrue(args.dry_run),
766
999
  confirm: isFlagTrue(args.confirm)
767
1000
  });
768
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1001
+ if (!plan.proceed) return plannedWriteResult(plan);
769
1002
 
770
1003
  const result = runAppleScript(
771
1004
  buildForwardScript({ messageId: resolved.messageId, to: to.addresses, body: body.text, sendNow }),
@@ -813,7 +1046,7 @@ export function mailMark(args = {}) {
813
1046
  dryRun: isFlagTrue(args.dry_run),
814
1047
  confirm: isFlagTrue(args.confirm)
815
1048
  });
816
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1049
+ if (!plan.proceed) return plannedWriteResult(plan);
817
1050
 
818
1051
  const result = runAppleScript(buildMarkScript({ messageId: resolved.messageId, read: status === "read" }), { appName: "Mail" });
819
1052
  if (!result.ok) return { ok: false, message: failure(action, summary, result, []) };
@@ -858,7 +1091,7 @@ export function mailArchive(args = {}) {
858
1091
  dryRun: isFlagTrue(args.dry_run),
859
1092
  confirm: isFlagTrue(args.confirm)
860
1093
  });
861
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1094
+ if (!plan.proceed) return plannedWriteResult(plan);
862
1095
 
863
1096
  const result = runAppleScript(
864
1097
  buildMoveScript({
@@ -891,7 +1124,7 @@ export function mailTrash(args = {}) {
891
1124
  dryRun: isFlagTrue(args.dry_run),
892
1125
  confirm: isFlagTrue(args.confirm)
893
1126
  });
894
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1127
+ if (!plan.proceed) return plannedWriteResult(plan);
895
1128
 
896
1129
  const result = runAppleScript(
897
1130
  buildMoveScript({
@@ -17,6 +17,7 @@ import { escapeSQL } from "./validators.js";
17
17
  import { runAppleScript, asString, MESSAGES_TCC_GUIDANCE, MESSAGES_APP_NOT_RUNNING_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
18
18
  import {
19
19
  planWrite,
20
+ plannedWriteResult,
20
21
  normalizeList,
21
22
  isMessagesHandle,
22
23
  validateChatGuid,
@@ -261,7 +262,7 @@ export function messagesSend(args = {}, deps = {}) {
261
262
  dryRun,
262
263
  confirm
263
264
  });
264
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
265
+ if (!plan.proceed) return plannedWriteResult(plan);
265
266
 
266
267
  const result = runAppleScript(
267
268
  buildSendToChatScript({ chatGuid: guid, text: body.text, attachmentPath: attachment.filePath }),
@@ -293,7 +294,7 @@ export function messagesSend(args = {}, deps = {}) {
293
294
 
294
295
  const summary = `send a message to ${handles.join(", ")} over ${serviceRaw}${attachment.filePath ? " with an attachment" : ""}`;
295
296
  const plan = planWrite({ action, summary, recipientCount: handles.length, dryRun, confirm });
296
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
297
+ if (!plan.proceed) return plannedWriteResult(plan);
297
298
 
298
299
  const result = runAppleScript(
299
300
  buildSendToHandlesScript({
@@ -215,6 +215,24 @@ export function planWrite({
215
215
  return { decision: "execute", proceed: true, requiresConfirm, message: null };
216
216
  }
217
217
 
218
+ /**
219
+ * Planned / dry_run / confirm-blocked results are not deliveries.
220
+ *
221
+ * MCP CallTool only surfaces `message` plus optional `isError`. Clients that
222
+ * treat a missing `isError` as success would read `ok: true` planned results
223
+ * as delivered. Planned writes therefore return `ok: false` with
224
+ * `planned: true` and `delivered: false`.
225
+ */
226
+ export function plannedWriteResult(plan) {
227
+ return {
228
+ ok: false,
229
+ planned: true,
230
+ delivered: false,
231
+ mailbox: null,
232
+ message: plan && plan.message ? plan.message : "Nothing was changed."
233
+ };
234
+ }
235
+
218
236
  export function truncate(text, max = 200) {
219
237
  if (typeof text !== "string") return "";
220
238
  const oneLine = text.replace(/\s+/g, " ").trim();
package/lib/writeTools.js CHANGED
@@ -48,7 +48,7 @@ export const WRITE_TOOL_DEFINITIONS = [
48
48
  // ============ MAIL WRITES ============
49
49
  {
50
50
  name: "mail_send",
51
- description: "Send an email through Mail.app. Sending to more than one recipient in total (to + cc + bcc) requires confirm=true. Use dry_run=true to preview. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; the tool checks Sent before failing. Clients must Sent-check before retrying a timed-out send.",
51
+ description: "Send an email through Mail.app. Success means the message was verified in Sent or is still in Outbox (still sending) — AppleScript send returning is not enough. Sending to more than one recipient in total (to + cc + bcc) requires confirm=true. dry_run and unconfirmed multi-recipient calls are not deliveries (MCP isError). A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; hang recover matches To+subject (Message-ID when available), never subject alone. Clients must Sent-check before retrying a timed-out send.",
52
52
  inputSchema: {
53
53
  type: "object",
54
54
  properties: {
@@ -82,7 +82,7 @@ export const WRITE_TOOL_DEFINITIONS = [
82
82
  },
83
83
  {
84
84
  name: "mail_reply",
85
- description: "Reply to an existing email. reply_all=true fans out to every original recipient and requires confirm=true. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; the tool checks Sent before failing. Clients must Sent-check before retrying a timed-out send.",
85
+ description: "Reply to an existing email. reply_all=true fans out to every original recipient and requires confirm=true. A real send is verified in Sent/Outbox before success. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; hang recover uses In-Reply-To. Clients must Sent-check before retrying a timed-out send.",
86
86
  inputSchema: {
87
87
  type: "object",
88
88
  properties: {
@@ -97,7 +97,7 @@ export const WRITE_TOOL_DEFINITIONS = [
97
97
  },
98
98
  {
99
99
  name: "mail_forward",
100
- description: "Forward an existing email to new recipients. More than one recipient requires confirm=true. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; the tool checks Sent before failing. Clients must Sent-check before retrying a timed-out send.",
100
+ description: "Forward an existing email to new recipients. More than one recipient requires confirm=true. A real send is verified in Sent/Outbox before success. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; hang recover matches intended To plus Fwd: subject or original Message-ID. Clients must Sent-check before retrying a timed-out send.",
101
101
  inputSchema: {
102
102
  type: "object",
103
103
  properties: {
@@ -343,6 +343,23 @@ export function isWriteTool(name) {
343
343
  return Object.prototype.hasOwnProperty.call(WRITE_TOOL_HANDLERS, name);
344
344
  }
345
345
 
346
+ /**
347
+ * MCP CallTool payload for a write. Planned / dry_run / confirm-blocked
348
+ * results set isError so clients that only check isError cannot treat them
349
+ * as delivered. Verified sends (ok: true) do not set isError.
350
+ *
351
+ * @param {{ ok?: boolean, planned?: boolean, message?: string }} writeResult
352
+ */
353
+ export function mcpWriteResult(writeResult) {
354
+ const message = writeResult && writeResult.message ? writeResult.message : "write failed";
355
+ const planned = Boolean(writeResult && writeResult.planned);
356
+ const failed = !writeResult || writeResult.ok === false;
357
+ return {
358
+ content: [{ type: "text", text: message }],
359
+ ...((failed || planned) ? { isError: true } : {})
360
+ };
361
+ }
362
+
346
363
  /**
347
364
  * Run a write in this process.
348
365
  * @returns {{ ok: boolean, message: string }}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "2.0.5",
3
+ "version": "2.0.7",
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",
@@ -233,8 +233,9 @@ function line(label, value) {
233
233
  }
234
234
 
235
235
  function step(name, result, severity = "error") {
236
- const failed = result.ok === false;
237
- const status = failed ? (severity === "warning" ? "WARN" : "FAIL") : result.planned ? "PLANNED" : "OK";
236
+ const planned = result && result.planned === true;
237
+ const failed = result && result.ok === false && !planned;
238
+ const status = planned ? "PLANNED" : failed ? (severity === "warning" ? "WARN" : "FAIL") : "OK";
238
239
  console.log(`\n[${status}] ${name}`);
239
240
  console.log(` ${result.message}`);
240
241
  return result;
@@ -348,7 +349,7 @@ async function main() {
348
349
  }));
349
350
  results.push(created);
350
351
 
351
- if (created.ok !== false) {
352
+ if (created.ok !== false || created.planned) {
352
353
  const contactId = apply ? extractContactId(created.message) : "ABCD1234-SMOKE:ABPerson";
353
354
  if (apply && !contactId) {
354
355
  console.log(" contacts_add reported success but returned no contact id; skipping edit/delete.");
@@ -405,7 +406,7 @@ async function main() {
405
406
  }));
406
407
  results.push(eventCreated);
407
408
 
408
- if (eventCreated.ok !== false) {
409
+ if (eventCreated.ok !== false || eventCreated.planned) {
409
410
  const eventId = apply ? extractEventId(eventCreated.message) : "ATM-SMOKE-EVENT-UID";
410
411
  const extractedKitId = apply ? extractEventKitId(eventCreated.message) : "EK-SMOKE";
411
412
  const eventKitId = extractedKitId || (eventId && eventId.includes(":") ? eventId : null);
@@ -451,7 +452,7 @@ async function main() {
451
452
  }
452
453
  }
453
454
 
454
- const failed = results.some((r) => r && r.ok === false);
455
+ const failed = results.some((r) => r && r.ok === false && !r.planned);
455
456
  console.log("\n" + "=".repeat(60));
456
457
  if (failed) {
457
458
  console.log("Result: FAILED - see the messages above.");