apple-tools-mcp 3.0.6 → 3.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.
@@ -340,7 +340,7 @@ export const MAIL_TCC_GUIDANCE =
340
340
  */
341
341
  export const MAIL_ACCESSIBILITY_GUIDANCE =
342
342
  "macOS denied Accessibility (System Events could not drive Mail's compose window). " +
343
- "mail_send / mail_draft fill the body via AX hit-test so Mail does not cite-wrap it (FB11734014); Mail's mailto command does not return an outgoing message on current Mini/MacBook Mail (-2753). " +
343
+ "mail_send / mail_draft / mail_reply / mail_forward fill the body via System Events so Mail does not cite-wrap it (FB11734014); Mail's mailto command does not return an outgoing message on current Mini/MacBook Mail (-2753). " +
344
344
  "Allow node in System Settings → Privacy & Security → Accessibility, and keep Automation → Mail allowed. " +
345
345
  "This is not a Mail Automation deny (-1743 / -10004).";
346
346
 
@@ -355,7 +355,7 @@ export const SYSTEM_EVENTS_STICKY_DENY_GUIDANCE =
355
355
  /** System Events GUI scripting missing — not a Mail send hang. */
356
356
  export const MAIL_GUI_SCRIPTING_GUIDANCE =
357
357
  "System Events GUI scripting is unavailable for this process, so the body cannot be typed into Mail. " +
358
- "mail_send / mail_draft locate the compose body via System Events (title-marker + AX hit-test) so Mail does not cite-wrap the Sent body (FB11734014). " +
358
+ "mail_send / mail_draft / mail_reply / mail_forward locate the compose body via System Events (title-marker + AX hit-test) so Mail does not cite-wrap the Sent body (FB11734014). " +
359
359
  "Allow node → System Events in System Settings → Privacy & Security → Automation (run apple-tools-mcp permissions after install/upgrade) in addition to node → Mail, and keep Accessibility allowed. " +
360
360
  SYSTEM_EVENTS_STICKY_DENY_GUIDANCE + " " +
361
361
  "This is not a Mail send timeout and not a Mail Automation deny (-1743 / -10004).";
package/lib/mailWrite.js CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  runAppleScript,
23
23
  asString,
24
24
  asInteger,
25
+ asBoolean,
25
26
  MAIL_TCC_GUIDANCE,
26
27
  MAIL_SEND_TIMEOUT_GUIDANCE,
27
28
  MAIL_ACCESSIBILITY_GUIDANCE,
@@ -197,6 +198,8 @@ export const SYSTEM_EVENTS_AUTOMATION_PROBE_MARKER = "ATM_SYSTEM_EVENTS_AUTOMATI
197
198
  export const MAIL_BODY_MIN_AX_HEIGHT = 50;
198
199
  /** Historical Mini Tab count. 2.1.3 finds the body via title-marker + AX hit-test, not Tab. */
199
200
  export const MAIL_BODY_TAB_MAX = 6;
201
+ /** Command-Up in the compose body (start of document). Reply/forward prepend uses this instead of Cmd-A. */
202
+ export const MAIL_BODY_START_KEY_CODE = 126;
200
203
  /** Mail window titles may truncate; match this many leading subject characters. */
201
204
  export const MAIL_COMPOSE_TITLE_MARKER_MAX = 60;
202
205
  /** Top-level `UI elements` of process Mail only — never a deep tree walk. */
@@ -507,7 +510,9 @@ function systemEventsGrantKind(result) {
507
510
  * — not `set value`, not `AXUIElementSetAttributeValue`. Re-read process
508
511
  * `AXFocusedUIElement` and require role `AXWebArea` plus the same position
509
512
  * and size. Mismatch is `BODY_FOCUS_UNPROVEN` (draft deleted, nothing sent).
510
- * Deliver with Cmd-A and clipboard paste. No coordinate click. No Mail
513
+ * Deliver with clipboard paste. Compose passes replaceBody=true (Cmd-A then
514
+ * paste). Reply/forward pass false: Command-Up to the top of the body, then
515
+ * paste, so the quoted original stays. No coordinate click. No Mail
511
516
  * `content` / `html content`. AXTitle reads in the title helpers stay inside
512
517
  * `tell application "System Events"` (2.1.4 osacompile -2741).
513
518
  */
@@ -515,6 +520,7 @@ export function buildMailBodyPasteHandler() {
515
520
  const seSecs = asInteger(MAIL_SE_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 30, field: "seTimeout" });
516
521
  const topMax = asInteger(MAIL_COMPOSE_TOP_UI_MAX, { min: 1, max: 64, field: "topUiMax" });
517
522
  const tokenId = asInteger(MAIL_RECIPIENT_TOKEN_CHAR_ID, { min: 1, max: 1114111, field: "tokenCharId" });
523
+ const startKey = asInteger(MAIL_BODY_START_KEY_CODE, { min: 1, max: 127, field: "bodyStartKey" });
518
524
  return `property atmProvenBx : -1
519
525
  property atmProvenBy : -1
520
526
  property atmProvenBw : -1
@@ -561,6 +567,13 @@ on atmTitleMatches(atmTitle, atmMarker)
561
567
  return false
562
568
  end atmTitleMatches
563
569
 
570
+ on atmComposeTitleMarker(atmSubject)
571
+ set atmText to atmSubject as string
572
+ if atmText is "" then return "New Message"
573
+ if (length of atmText) > ${MAIL_COMPOSE_TITLE_MARKER_MAX} then return text 1 thru ${MAIL_COMPOSE_TITLE_MARKER_MAX} of atmText
574
+ return atmText
575
+ end atmComposeTitleMarker
576
+
564
577
  on atmTopElemExactTitle(atmEl, atmMarker)
565
578
  try
566
579
  if my atmTitleMatches(name of atmEl as string, atmMarker) then return true
@@ -655,7 +668,7 @@ on atmStopScreenSaver()
655
668
  end try
656
669
  end atmStopScreenSaver
657
670
 
658
- on atmFillMailBody(bodyText, needle, titleMarker, msg)
671
+ on atmFillMailBody(bodyText, needle, titleMarker, msg, replaceBody)
659
672
  try
660
673
  tell application "Mail" to activate
661
674
  delay 0.25
@@ -767,8 +780,13 @@ on atmFillMailBody(bodyText, needle, titleMarker, msg)
767
780
  set hadClip to true
768
781
  end try
769
782
  try
770
- keystroke "a" using command down
771
- delay 0.2
783
+ if replaceBody then
784
+ keystroke "a" using command down
785
+ delay 0.2
786
+ else
787
+ key code ${startKey} using command down
788
+ delay 0.2
789
+ end if
772
790
  set the clipboard to bodyText
773
791
  delay 0.2
774
792
  keystroke "v" using command down
@@ -845,8 +863,9 @@ ${bodyCheck}`;
845
863
  * is filled by Probe 3: exact subject title, first AXWebArea under that
846
864
  * window (`entire contents`), `set focused of` that element, prove focus
847
865
  * (role + position + size) via a fresh AXFocusedUIElement read, then Cmd-A
848
- * and clipboard paste. No coordinate click. No AXValue write. No Mail
849
- * `content of` oracle (Mail 16 / macOS 26 stays empty). Missing window or
866
+ * (`replaceBody` true) and clipboard paste. No coordinate click. No AXValue
867
+ * write. No Mail `content of` oracle (Mail 16 / macOS 26 stays empty). Missing
868
+ * window or
850
869
  * body is BODY_FOCUS_FAILED; a focus-proof mismatch is BODY_FOCUS_UNPROVEN.
851
870
  * Either deletes the draft and sends nothing. Header checks stay. The body
852
871
  * needle runs only when the focused element is still that proven body, so a
@@ -865,7 +884,7 @@ export function buildComposeScript({ to, cc, bcc, subject, body, send, html = fa
865
884
  const needle = composeBodyNeedle(plainBody);
866
885
  const titleMarker = composeTitleMarker(subject);
867
886
  const pasteAndVerify = plainBody
868
- ? ` atmFillMailBody(${asString(plainBody)}, ${asString(needle)}, ${asString(titleMarker)}, newMessage)
887
+ ? ` atmFillMailBody(${asString(plainBody)}, ${asString(needle)}, ${asString(titleMarker)}, newMessage, ${asBoolean(true)})
869
888
  tell application "Mail"
870
889
  ${composeMisdirectChecks({ to, cc, bcc, subject, body: plainBody })}
871
890
  end tell`
@@ -992,7 +1011,7 @@ function mailBoxesPreamble() {
992
1011
  return ` set cutoff to (current date) - (10 * minutes)
993
1012
  set boxes to {}
994
1013
  try
995
- set end of boxes to outgoing mailbox
1014
+ set end of boxes to outbox
996
1015
  end try
997
1016
  try
998
1017
  set end of boxes to sent mailbox
@@ -1012,7 +1031,7 @@ function loadMailboxMessagesSnippet() {
1012
1031
  return ` set recentMsgs to {}
1013
1032
  set scanAll to false
1014
1033
  try
1015
- if boxRef is outgoing mailbox then set scanAll to true
1034
+ if boxRef is outbox then set scanAll to true
1016
1035
  end try
1017
1036
  try
1018
1037
  set nm to name of boxRef as string
@@ -1028,7 +1047,7 @@ function loadMailboxMessagesSnippet() {
1028
1047
 
1029
1048
  function verifyHitReturn() {
1030
1049
  return ` try
1031
- if boxRef is outgoing mailbox then return "${SENT_VERIFY_OUTBOX}"
1050
+ if boxRef is outbox then return "${SENT_VERIFY_OUTBOX}"
1032
1051
  end try
1033
1052
  try
1034
1053
  set nm to name of boxRef as string
@@ -1438,7 +1457,7 @@ function finalizeMailWrite({
1438
1457
  }
1439
1458
  const verified = verifyQueuedMessage(match, { deadlineMs: deadline });
1440
1459
  if (!verified) {
1441
- return { ok: false, delivered: false, mailbox: null, message: verifyMissMessage(action, summary) };
1460
+ return { ok: false, delivered: false, mailbox: null, unconfirmed: true, message: verifyMissMessage(action, summary) };
1442
1461
  }
1443
1462
  return sendVerifiedSuccess({ action, successSummary, details, verified, recovered: false });
1444
1463
  }
@@ -1558,35 +1577,110 @@ export function mailCompose(args = {}, { draft = false, indexerMode = isIndexerM
1558
1577
  });
1559
1578
  }
1560
1579
 
1580
+ /**
1581
+ * Intro text pasted above a reply/forward quote. Trailing newline keeps the
1582
+ * note from gluing to "On … wrote:". Needle still comes from composeBodyNeedle.
1583
+ */
1584
+ export function quoteIntroPasteText(body) {
1585
+ const text = composeNativeBody(body);
1586
+ if (!text) return "";
1587
+ return text.endsWith("\n") ? text : `${text}\n`;
1588
+ }
1589
+
1590
+ function quoteBodyNeedleCheck(body) {
1591
+ const needle = composeBodyNeedle(body);
1592
+ if (!needle) return "";
1593
+ return ` if my atmFocusedIsProvenBody() then
1594
+ set atmAxVal to my atmAxFocusedValue()
1595
+ if atmAxVal is not "" then
1596
+ if my atmAxValueIsRecipientToken(atmAxVal) is false then
1597
+ if atmAxVal does not contain ${asString(needle)} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
1598
+ end if
1599
+ end if
1600
+ end if`;
1601
+ }
1602
+
1603
+ /**
1604
+ * Probe 3 prepend: visible window title from the live subject, no Cmd-A, no
1605
+ * AppleScript `content`. Mail 16 / macOS 26 leaves `set content` empty, which
1606
+ * is why reply/forward used to send a blank intro.
1607
+ */
1608
+ function quoteBodyPasteAndVerify(outgoingVar, body) {
1609
+ const intro = quoteIntroPasteText(body);
1610
+ if (!intro) return "";
1611
+ const bodyCheck = quoteBodyNeedleCheck(intro);
1612
+ return ` tell application "Mail"
1613
+ set atmTitleMarker to my atmComposeTitleMarker(subject of ${outgoingVar} as string)
1614
+ end tell
1615
+ atmFillMailBody(${asString(intro)}, ${asString(composeBodyNeedle(intro))}, atmTitleMarker, ${outgoingVar}, ${asBoolean(false)})
1616
+ tell application "Mail"
1617
+ ${bodyCheck}
1618
+ end tell`;
1619
+ }
1620
+
1621
+ function quotePasteGuard(outgoingVar, fillBlock) {
1622
+ if (!fillBlock) return "";
1623
+ return `try
1624
+ ${fillBlock}
1625
+ on error errMsg number errNum
1626
+ try
1627
+ tell application "Mail" to delete ${outgoingVar}
1628
+ end try
1629
+ error errMsg number errNum
1630
+ end try
1631
+ `;
1632
+ }
1633
+
1634
+ function finishMailGuiWrite({ action, summary, result, indexerMode, finalize }) {
1635
+ if (
1636
+ !result.ok &&
1637
+ indexerMode &&
1638
+ (isMailGuiScriptingUnavailable(result) || isMailAccessibilityDenial(result))
1639
+ ) {
1640
+ return guiScriptingUnavailableResult(action, summary, result, { indexerMode });
1641
+ }
1642
+ return finalize();
1643
+ }
1644
+
1561
1645
  export function buildReplyScript({ messageId, body, replyAll, sendNow }) {
1562
- return `${findMessageHandler()}
1646
+ const mailSecs = asInteger(MAIL_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 60, field: "mailTimeout" });
1647
+ const fillBlock = quoteBodyPasteAndVerify("theReply", body);
1648
+ const pasteAndVerify = quotePasteGuard("theReply", fillBlock);
1649
+ const replyAllClause = replyAll ? "with reply to all" : "without reply to all";
1650
+
1651
+ return `${buildMailBodyPasteHandler()}
1652
+ ${findMessageHandler()}
1563
1653
 
1564
1654
  set theMessage to atmFindMessage(${asString(messageId)})
1565
1655
  tell application "Mail"
1566
- set theReply to missing value
1567
- try
1568
- set theReply to reply theMessage without opening window ${replyAll ? "with reply to all" : "without reply to all"}
1569
- end try
1570
- if theReply is missing value then
1571
- -- Some Mail versions do not return the outgoing message from a reply.
1572
- -- Fall back to a new message addressed to the original sender.
1573
- set origSubject to subject of theMessage
1574
- set origSender to extract address from (sender of theMessage)
1575
- set theReply to make new outgoing message with properties {subject:("Re: " & origSubject), content:${asString(body)}, visible:false}
1576
- tell theReply
1577
- make new to recipient at end of to recipients with properties {address:origSender}
1578
- end tell
1579
- else
1580
- tell theReply
1581
- set content to ${asString(body)} & return & content
1582
- end tell
1583
- end if
1584
- ${sendNow ? "send theReply" : "save theReply"}
1656
+ with timeout of ${mailSecs} seconds
1657
+ set theReply to missing value
1658
+ try
1659
+ set theReply to reply theMessage with opening window ${replyAllClause}
1660
+ end try
1661
+ if theReply is missing value then
1662
+ -- Some Mail versions do not return the outgoing message from a reply.
1663
+ -- Fall back to a new visible message addressed to the original sender.
1664
+ -- Never set AppleScript content (Mail 16 / macOS 26 stays empty).
1665
+ set origSubject to subject of theMessage
1666
+ set origSender to extract address from (sender of theMessage)
1667
+ set theReply to make new outgoing message with properties {subject:("Re: " & origSubject), visible:true}
1668
+ tell theReply
1669
+ make new to recipient at end of to recipients with properties {address:origSender}
1670
+ end tell
1671
+ end if
1672
+ activate
1673
+ end timeout
1674
+ end tell
1675
+ ${pasteAndVerify}tell application "Mail"
1676
+ with timeout of ${mailSecs} seconds
1677
+ ${sendNow ? "send theReply" : "save theReply"}
1678
+ end timeout
1585
1679
  end tell
1586
1680
  return "OK"`;
1587
1681
  }
1588
1682
 
1589
- export function mailReply(args = {}) {
1683
+ export function mailReply(args = {}, { indexerMode = isIndexerMode() } = {}) {
1590
1684
  const action = "mail_reply";
1591
1685
  const resolved = resolveMailMessageId({ messageId: args.message_id, filePath: args.file_path });
1592
1686
  if (resolved.error) return { ok: false, message: `${action} refused: ${resolved.error}` };
@@ -1609,47 +1703,72 @@ export function mailReply(args = {}) {
1609
1703
  });
1610
1704
  if (!plan.proceed) return plannedWriteResult(plan);
1611
1705
 
1706
+ if (quoteIntroPasteText(body.text)) {
1707
+ const se = probeSystemEventsGui();
1708
+ if (!se.ok) {
1709
+ return guiScriptingUnavailableResult(action, summary, se, { indexerMode });
1710
+ }
1711
+ }
1712
+
1612
1713
  const deadlineMs = sentVerifyClock.now() + MCP_CALL_DEADLINE_MS;
1714
+ wakeDisplay();
1613
1715
  const result = runAppleScript(
1614
1716
  buildReplyScript({ messageId: resolved.messageId, body: body.text, replyAll, sendNow }),
1615
1717
  { timeout: MAIL_FIND_AND_SEND_TIMEOUT_MS, appName: "Mail" }
1616
1718
  );
1617
- return finalizeMailWrite({
1719
+ return finishMailGuiWrite({
1618
1720
  action,
1619
1721
  summary,
1620
1722
  result,
1621
- secrets: [body.text],
1622
- sendNow,
1623
- inReplyTo: sendNow ? resolved.messageId : null,
1624
- successSummary: sendNow ? "reply sent" : "reply saved to Drafts",
1625
- details: {
1626
- message_id: resolved.messageId,
1627
- reply_all: String(replyAll)
1628
- },
1629
- deadlineMs
1723
+ indexerMode,
1724
+ finalize: () => finalizeMailWrite({
1725
+ action,
1726
+ summary,
1727
+ result,
1728
+ secrets: [body.text],
1729
+ sendNow,
1730
+ inReplyTo: sendNow ? resolved.messageId : null,
1731
+ successSummary: sendNow ? "reply sent" : "reply saved to Drafts",
1732
+ details: {
1733
+ message_id: resolved.messageId,
1734
+ reply_all: String(replyAll)
1735
+ },
1736
+ deadlineMs
1737
+ })
1630
1738
  });
1631
1739
  }
1632
1740
 
1633
1741
  export function buildForwardScript({ messageId, to, body, sendNow }) {
1634
- return `${findMessageHandler()}
1742
+ const mailSecs = asInteger(MAIL_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 60, field: "mailTimeout" });
1743
+ const fillBlock = quoteBodyPasteAndVerify("theForward", body);
1744
+ const pasteAndVerify = quotePasteGuard("theForward", fillBlock);
1745
+ const handlers = fillBlock ? `${buildMailBodyPasteHandler()}\n` : "";
1746
+
1747
+ return `${handlers}${findMessageHandler()}
1635
1748
 
1636
1749
  set theMessage to atmFindMessage(${asString(messageId)})
1637
1750
  tell application "Mail"
1638
- set theForward to missing value
1639
- try
1640
- set theForward to forward theMessage without opening window
1641
- end try
1642
- if theForward is missing value then error "FORWARD_UNSUPPORTED"
1643
- tell theForward
1644
- set content to ${asString(body)} & return & content
1751
+ with timeout of ${mailSecs} seconds
1752
+ set theForward to missing value
1753
+ try
1754
+ set theForward to forward theMessage with opening window
1755
+ end try
1756
+ if theForward is missing value then error "FORWARD_UNSUPPORTED"
1757
+ tell theForward
1645
1758
  ${to.map((address) => ` make new to recipient at end of to recipients with properties {address:${asString(address)}}`).join("\n")}
1646
- end tell
1647
- ${sendNow ? "send theForward" : "save theForward"}
1759
+ end tell
1760
+ activate
1761
+ end timeout
1762
+ end tell
1763
+ ${pasteAndVerify}tell application "Mail"
1764
+ with timeout of ${mailSecs} seconds
1765
+ ${sendNow ? "send theForward" : "save theForward"}
1766
+ end timeout
1648
1767
  end tell
1649
1768
  return "OK"`;
1650
1769
  }
1651
1770
 
1652
- export function mailForward(args = {}) {
1771
+ export function mailForward(args = {}, { indexerMode = isIndexerMode() } = {}) {
1653
1772
  const action = "mail_forward";
1654
1773
  const resolved = resolveMailMessageId({ messageId: args.message_id, filePath: args.file_path });
1655
1774
  if (resolved.error) return { ok: false, message: `${action} refused: ${resolved.error}` };
@@ -1674,25 +1793,39 @@ export function mailForward(args = {}) {
1674
1793
  });
1675
1794
  if (!plan.proceed) return plannedWriteResult(plan);
1676
1795
 
1796
+ if (quoteIntroPasteText(body.text)) {
1797
+ const se = probeSystemEventsGui();
1798
+ if (!se.ok) {
1799
+ return guiScriptingUnavailableResult(action, summary, se, { indexerMode });
1800
+ }
1801
+ }
1802
+
1677
1803
  const deadlineMs = sentVerifyClock.now() + MCP_CALL_DEADLINE_MS;
1804
+ wakeDisplay();
1678
1805
  const result = runAppleScript(
1679
1806
  buildForwardScript({ messageId: resolved.messageId, to: to.addresses, body: body.text, sendNow }),
1680
1807
  { timeout: MAIL_FIND_AND_SEND_TIMEOUT_MS, appName: "Mail" }
1681
1808
  );
1682
- return finalizeMailWrite({
1809
+ return finishMailGuiWrite({
1683
1810
  action,
1684
1811
  summary,
1685
1812
  result,
1686
- secrets: [body.text],
1687
- sendNow,
1688
- inReplyTo: sendNow ? resolved.messageId : null,
1689
- forwardTo: sendNow ? to.addresses : null,
1690
- successSummary: sendNow ? "forwarded" : "forward saved to Drafts",
1691
- details: {
1692
- message_id: resolved.messageId,
1693
- to: to.addresses.join(", ")
1694
- },
1695
- deadlineMs
1813
+ indexerMode,
1814
+ finalize: () => finalizeMailWrite({
1815
+ action,
1816
+ summary,
1817
+ result,
1818
+ secrets: [body.text],
1819
+ sendNow,
1820
+ inReplyTo: sendNow ? resolved.messageId : null,
1821
+ forwardTo: sendNow ? to.addresses : null,
1822
+ successSummary: sendNow ? "forwarded" : "forward saved to Drafts",
1823
+ details: {
1824
+ message_id: resolved.messageId,
1825
+ to: to.addresses.join(", ")
1826
+ },
1827
+ deadlineMs
1828
+ })
1696
1829
  });
1697
1830
  }
1698
1831
 
package/lib/writeTools.js CHANGED
@@ -82,12 +82,12 @@ 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 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.",
85
+ description: "Reply to an existing email. The reply text is pasted above the quoted original in a visible Mail window (System Events focus on the message body, then Command-Up and clipboard paste — not Cmd-A, which would replace the quote; AppleScript content is not set because Mail 16 leaves it empty). 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: {
89
89
  ...MESSAGE_TARGET_PROPS,
90
- body: { type: "string", description: "Reply text, prepended above the quoted original (required)" },
90
+ body: { type: "string", description: "Reply text, prepended above the quoted original via System Events paste (required). AppleScript content is not set." },
91
91
  reply_all: { type: "boolean", description: "Reply to all original recipients (default false)" },
92
92
  save_as_draft: { type: "boolean", description: "Save the reply to Drafts instead of sending (default false)" },
93
93
  ...CONFIRM_PROPS
@@ -97,13 +97,13 @@ 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 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.",
100
+ description: "Forward an existing email to new recipients. An optional note is pasted above the forwarded original in a visible Mail window (same System Events prepend as mail_reply; AppleScript content is not set). 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: {
104
104
  ...MESSAGE_TARGET_PROPS,
105
105
  to: { type: "array", items: { type: "string" }, description: "Forward recipients (required, at least one)" },
106
- body: { type: "string", description: "Optional note added above the forwarded message" },
106
+ body: { type: "string", description: "Optional note pasted above the forwarded message (System Events prepend; omitted note skips paste)" },
107
107
  save_as_draft: { type: "boolean", description: "Save the forward to Drafts instead of sending (default false)" },
108
108
  ...CONFIRM_PROPS
109
109
  },
@@ -420,8 +420,11 @@ export function loggedBridgeHandler(run, log, now = () => Date.now()) {
420
420
  const ok = Boolean(result && result.ok);
421
421
  // Dry runs and confirm-gated previews come back ok:false with planned:true — nothing failed.
422
422
  const planned = !ok && Boolean(result && result.planned);
423
- const why = ok || planned ? "" : ` (${classifyWriteFailure(result && result.message)}${result && result.unsupported ? ", unsupported" : ""})`;
424
- log(`${new Date(started).toISOString()} write bridge: ${tool} ${ok ? "ok" : planned ? "planned (dry run / needs confirm)" : "failed"} in ${now() - started}ms${why}`);
423
+ // Mail accepted the send but Sent/Outbox didn't show it within budget — not a failure.
424
+ const unconfirmed = !ok && !planned && Boolean(result && result.unconfirmed);
425
+ const outcome = ok ? "ok" : planned ? "planned (dry run / needs confirm)" : unconfirmed ? "unconfirmed (sent; not yet seen in Sent/Outbox)" : "failed";
426
+ const why = ok || planned || unconfirmed ? "" : ` (${classifyWriteFailure(result && result.message)}${result && result.unsupported ? ", unsupported" : ""})`;
427
+ log(`${new Date(started).toISOString()} write bridge: ${tool} ${outcome} in ${now() - started}ms${why}`);
425
428
  return result;
426
429
  };
427
430
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "3.0.6",
3
+ "version": "3.0.8",
4
4
  "description": "MCP server for semantic search and write actions across Apple Mail, Messages, Calendar, and Contacts. Speaks stdio (local) or bearer-token-authenticated Streamable HTTP (LAN / Tailscale).",
5
5
  "type": "module",
6
6
  "main": "index.js",