apple-tools-mcp 2.0.6 → 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
 
@@ -467,11 +468,13 @@ Two arguments are available on **every** write tool:
467
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
470
 
470
- **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).
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,
@@ -336,9 +339,14 @@ on error errMsg number errNum
336
339
  end try
337
340
  error errMsg number errNum
338
341
  end try
342
+ set atmOutgoingId to ""
339
343
  tell application "Mail"
344
+ try
345
+ set atmOutgoingId to message id of newMessage as string
346
+ end try
340
347
  ${send ? "send newMessage" : "save newMessage"}
341
348
  end tell
349
+ if atmOutgoingId is not "" then return atmOutgoingId
342
350
  return "OK"`;
343
351
  }
344
352
 
@@ -350,8 +358,31 @@ function summarizeRecipients(to, cc, bcc) {
350
358
  return parts.join("; ");
351
359
  }
352
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. */
353
365
  export const SENT_VERIFY_FOUND = "FOUND";
354
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
+ }
355
386
 
356
387
  /**
357
388
  * Hard Mail Automation deny: -1743 / -10004 / "not authorized…", or
@@ -376,10 +407,10 @@ function mailBoxesPreamble() {
376
407
  return ` set cutoff to (current date) - (10 * minutes)
377
408
  set boxes to {}
378
409
  try
379
- set end of boxes to sent mailbox
410
+ set end of boxes to outgoing mailbox
380
411
  end try
381
412
  try
382
- set end of boxes to outgoing mailbox
413
+ set end of boxes to sent mailbox
383
414
  end try
384
415
  repeat with acct in accounts
385
416
  try
@@ -392,6 +423,48 @@ function appleScriptToList(addresses = []) {
392
423
  return `{${addresses.map((address) => asString(address)).join(", ")}}`;
393
424
  }
394
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
+
395
468
  /**
396
469
  * Look in Sent / Outbox for a reply of `messageId`.
397
470
  * Proper Mail replies set In-Reply-To. The fallback `make new outgoing
@@ -414,7 +487,7 @@ tell application "Mail"
414
487
  ${mailBoxesPreamble()}
415
488
  repeat with boxRef in boxes
416
489
  try
417
- set recentMsgs to (messages of boxRef whose date sent > cutoff)
490
+ ${loadMailboxMessagesSnippet()}
418
491
  repeat with msg in recentMsgs
419
492
  try
420
493
  set candId to message id of msg
@@ -423,12 +496,18 @@ ${mailBoxesPreamble()}
423
496
  else
424
497
  try
425
498
  set src to source of msg
426
- if src contains ("In-Reply-To: " & ${needleAngle}) then return "${SENT_VERIFY_FOUND}"
427
- 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
428
505
  end try
429
506
  if origSubject is not "" then
430
507
  set subj to subject of msg
431
- if subj is ("Re: " & origSubject) then return "${SENT_VERIFY_FOUND}"
508
+ if subj is ("Re: " & origSubject) then
509
+ ${verifyHitReturn()}
510
+ end if
432
511
  end if
433
512
  end if
434
513
  end try
@@ -436,7 +515,7 @@ ${mailBoxesPreamble()}
436
515
  end try
437
516
  end repeat
438
517
  end tell
439
- return "NOT_FOUND"`;
518
+ return "${SENT_VERIFY_NOT_FOUND}"`;
440
519
  }
441
520
 
442
521
  /**
@@ -463,7 +542,7 @@ ${mailBoxesPreamble()}
463
542
  set wantedTos to ${toList}
464
543
  repeat with boxRef in boxes
465
544
  try
466
- set recentMsgs to (messages of boxRef whose date sent > cutoff)
545
+ ${loadMailboxMessagesSnippet()}
467
546
  repeat with msg in recentMsgs
468
547
  try
469
548
  set candId to message id of msg
@@ -474,15 +553,7 @@ ${mailBoxesPreamble()}
474
553
  if src contains ("In-Reply-To: " & ${needleAngle}) or src contains ("In-Reply-To: " & ${needleBare}) then
475
554
  -- A reply to the original is not this forward.
476
555
  else
477
- set hitTo to false
478
- try
479
- repeat with recip in (to recipients of msg)
480
- set recipAddr to address of recip as string
481
- repeat with wanted in wantedTos
482
- if recipAddr is (wanted as string) then set hitTo to true
483
- end repeat
484
- end repeat
485
- end try
556
+ ${recipientHitSnippet()}
486
557
  if hitTo then
487
558
  set subj to subject of msg
488
559
  set exactFwd to false
@@ -498,8 +569,12 @@ ${mailBoxesPreamble()}
498
569
  set mentionsOrigId to false
499
570
  if src contains ${needleAngle} then set mentionsOrigId to true
500
571
  if src contains ${needleBare} then set mentionsOrigId to true
501
- if exactFwd then return "${SENT_VERIFY_FOUND}"
502
- 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
503
578
  end if
504
579
  end if
505
580
  end if
@@ -508,45 +583,125 @@ ${mailBoxesPreamble()}
508
583
  end try
509
584
  end repeat
510
585
  end tell
511
- return "NOT_FOUND"`;
586
+ return "${SENT_VERIFY_NOT_FOUND}"`;
512
587
  }
513
588
 
514
589
  /**
515
- * Look in Sent / Outbox for a compose whose subject matches exactly and
516
- * 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.
517
593
  */
518
- export function buildFindSentBySubjectScript(subject) {
519
- 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
+
520
622
  return `tell application "Mail"
521
623
  ${mailBoxesPreamble()}
624
+ set wantedTos to ${toList}
522
625
  repeat with boxRef in boxes
523
626
  try
524
- set hits to (messages of boxRef whose subject is ${subj} and date sent > cutoff)
525
- 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
526
633
  end try
527
634
  end repeat
528
635
  end tell
529
- return "NOT_FOUND"`;
636
+ return "${SENT_VERIFY_NOT_FOUND}"`;
530
637
  }
531
638
 
532
639
  /**
533
- * After a send hang, ask Mail whether the message is already in Sent.
534
- * 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.
535
642
  */
536
- export function recoverIfInSent({ inReplyTo = null, subject = null, forwardTo = null } = {}) {
537
- const script = forwardTo && inReplyTo
538
- ? buildFindSentForwardScript(inReplyTo, forwardTo)
539
- : inReplyTo
540
- ? buildFindSentByInReplyToScript(inReplyTo)
541
- : subject
542
- ? buildFindSentBySubjectScript(subject)
543
- : 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);
544
687
  if (!script) return null;
545
688
  const result = runAppleScript(script, { timeout: SENT_VERIFY_TIMEOUT_MS, appName: "Mail" });
546
689
  if (!result.ok) return null;
547
- const out = String(result.output || "").trim();
548
- if (out === SENT_VERIFY_FOUND) return { found: true };
549
- 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;
550
705
  }
551
706
 
552
707
  export function isMailAccessibilityDenial(result) {
@@ -586,9 +741,41 @@ function failure(action, summary, result, secrets) {
586
741
  }
587
742
 
588
743
  /**
589
- * Finish a compose/reply/forward. A send hang is not labeled TCC; if Mail
590
- * 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.
591
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
+
592
779
  function finalizeMailWrite({
593
780
  action,
594
781
  summary,
@@ -598,30 +785,33 @@ function finalizeMailWrite({
598
785
  inReplyTo = null,
599
786
  subject = null,
600
787
  forwardTo = null,
788
+ to = null,
789
+ messageId = null,
601
790
  successSummary,
602
791
  details
603
792
  }) {
793
+ const match = { inReplyTo, subject, forwardTo, to, messageId };
794
+
604
795
  if (result.ok) {
605
- 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 });
606
804
  }
607
805
 
608
806
  if (sendNow && isMailSendTimeout(result)) {
609
- const recovered = recoverIfInSent({ inReplyTo, subject, forwardTo });
807
+ const recovered = recoverIfInSent(match);
610
808
  if (recovered) {
611
- return {
612
- ok: true,
613
- recovered: true,
614
- message: writeSuccessMessage(
615
- action,
616
- `${successSummary} (verified in Sent after AppleScript hang)`,
617
- details
618
- )
619
- };
809
+ return sendVerifiedSuccess({ action, successSummary, details, verified: recovered, recovered: true });
620
810
  }
621
- return { ok: false, message: failure(action, summary, result, secrets) };
811
+ return { ok: false, delivered: false, mailbox: null, message: failure(action, summary, result, secrets) };
622
812
  }
623
813
 
624
- return { ok: false, message: failure(action, summary, result, secrets) };
814
+ return { ok: false, delivered: false, mailbox: null, message: failure(action, summary, result, secrets) };
625
815
  }
626
816
 
627
817
  /**
@@ -664,7 +854,7 @@ export function mailCompose(args = {}, { draft = false } = {}) {
664
854
  dryRun: isFlagTrue(args.dry_run),
665
855
  confirm: isFlagTrue(args.confirm)
666
856
  });
667
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
857
+ if (!plan.proceed) return plannedWriteResult(plan);
668
858
 
669
859
  const script = buildComposeScript({
670
860
  to: to.addresses,
@@ -684,6 +874,8 @@ export function mailCompose(args = {}, { draft = false } = {}) {
684
874
  secrets: [body.text, subject.text],
685
875
  sendNow: !draft,
686
876
  subject: draft ? null : subject.text,
877
+ to: draft ? null : to.addresses,
878
+ messageId: draft ? null : parseOutgoingMessageId(result.output),
687
879
  successSummary: draft ? "draft saved to Drafts" : "sent",
688
880
  details: {
689
881
  to: to.addresses.join(", "),
@@ -743,7 +935,7 @@ export function mailReply(args = {}) {
743
935
  dryRun: isFlagTrue(args.dry_run),
744
936
  confirm: isFlagTrue(args.confirm)
745
937
  });
746
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
938
+ if (!plan.proceed) return plannedWriteResult(plan);
747
939
 
748
940
  const result = runAppleScript(
749
941
  buildReplyScript({ messageId: resolved.messageId, body: body.text, replyAll, sendNow }),
@@ -806,7 +998,7 @@ export function mailForward(args = {}) {
806
998
  dryRun: isFlagTrue(args.dry_run),
807
999
  confirm: isFlagTrue(args.confirm)
808
1000
  });
809
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1001
+ if (!plan.proceed) return plannedWriteResult(plan);
810
1002
 
811
1003
  const result = runAppleScript(
812
1004
  buildForwardScript({ messageId: resolved.messageId, to: to.addresses, body: body.text, sendNow }),
@@ -854,7 +1046,7 @@ export function mailMark(args = {}) {
854
1046
  dryRun: isFlagTrue(args.dry_run),
855
1047
  confirm: isFlagTrue(args.confirm)
856
1048
  });
857
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1049
+ if (!plan.proceed) return plannedWriteResult(plan);
858
1050
 
859
1051
  const result = runAppleScript(buildMarkScript({ messageId: resolved.messageId, read: status === "read" }), { appName: "Mail" });
860
1052
  if (!result.ok) return { ok: false, message: failure(action, summary, result, []) };
@@ -899,7 +1091,7 @@ export function mailArchive(args = {}) {
899
1091
  dryRun: isFlagTrue(args.dry_run),
900
1092
  confirm: isFlagTrue(args.confirm)
901
1093
  });
902
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1094
+ if (!plan.proceed) return plannedWriteResult(plan);
903
1095
 
904
1096
  const result = runAppleScript(
905
1097
  buildMoveScript({
@@ -932,7 +1124,7 @@ export function mailTrash(args = {}) {
932
1124
  dryRun: isFlagTrue(args.dry_run),
933
1125
  confirm: isFlagTrue(args.confirm)
934
1126
  });
935
- if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
1127
+ if (!plan.proceed) return plannedWriteResult(plan);
936
1128
 
937
1129
  const result = runAppleScript(
938
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.6",
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.");