apple-tools-mcp 3.2.1 → 3.2.3

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
@@ -66,7 +66,7 @@ Then open **System Settings → Privacy & Security → Accessibility** and turn
66
66
 
67
67
  The command does real work so the prompts can appear. It opens a Mail compose window and throws it away (nothing is sent), lists Messages accounts (nothing is sent), creates and deletes a throwaway contact, lists calendars without creating events, and checks System Events. It exits with an error until Mail, Messages, Contacts, Calendar, and System Events are all allowed.
68
68
 
69
- `dry_run` on `mail_send` or `messages_send` does not talk to those apps, so a dry run will not pop the prompts and will not tell you whether Automation is allowed. A real send that hangs after you denied Mail is an Automation denial, not "Mail could not be reached."
69
+ `dry_run` on `mail_send` or `messages_send` does not talk to those apps, so a dry run will not pop the prompts and will not tell you whether Automation is allowed. A timeout alone does not prove an Automation denial; only an explicit permission error does.
70
70
 
71
71
  If you clicked **Don't Allow**, macOS will not ask again. Open **System Settings → Privacy & Security → Automation**, turn the switch on for that node and that app, then run `apple-tools-mcp permissions` again.
72
72
 
@@ -308,6 +308,10 @@ Every write tool accepts:
308
308
 
309
309
  Deletes need `confirm: true`: `mail_trash`, `calendar_remove`, `contacts_remove`, and `contacts_edit` when it clears every email or phone. These take one id per call, except `mail_trash`, which also takes a list (`message_ids`).
310
310
 
311
+ `mail_send` supports a stable `idempotency_key` per intended delivery. Reuse it after a disconnect or timeout. An accepted send is replayed without sending again; changed content with the same key is refused. Without a key, identical accepted content is suppressed without expiry. Use a new key for an intentional repeat. Uncertain sends remain blocked without expiry: check Sent and Outbox before making any new delivery. A missing Sent result alone cannot prove a queued send will not complete. Claims persist across processes/restarts in the private `~/.apple-tools-mcp/mail-send-claims` directory, containing only SHA-256 hashes and outcome metadata. Dry runs create no claims. Only explicit pre-send failures allow a retry.
312
+
313
+ A bridge request that expires before execution is rejected. After a handoff with an unknown outcome, local fallback is suppressed. The System Events preflight allows 10 seconds (12-second process cap); timeout reports `probe_timeout` and unknown permission status, separately from an explicit permission denial.
314
+
311
315
  Sends to more than one person need `confirm: true`: `mail_send` and `mail_forward` when To, Cc, and Bcc add up to more than one address, `mail_reply` with `reply_all: true`, and `messages_send` to several people or to a group chat. A single recipient sends on the first call. `mail_draft` never sends, so it does not need that confirm.
312
316
 
313
317
  A dry run or a call that still needs confirm does not deliver anything. The result is `ok: false`, `planned: true`, `delivered: false`, and the client may mark it as an error. The text says `DRY RUN` or `CONFIRMATION REQUIRED`. Run it again with `dry_run` false, and with `confirm: true` when the tool asked for it.
@@ -320,7 +324,7 @@ A missing or malformed id or recipient is refused. Calendar times are local date
320
324
 
321
325
  | Tool | Arguments | Confirm |
322
326
  |------|-----------|---------|
323
- | `mail_send` | `to[]` (required), `cc[]`, `bcc[]`, `subject` (required), `body` (required), `body_format` (`plain` or `html`) | when recipients > 1 |
327
+ | `mail_send` | `to[]` (required), `cc[]`, `bcc[]`, `subject` (required), `body` (required), `body_format` (`plain` or `html`), `idempotency_key` | when recipients > 1 |
324
328
  | `mail_draft` | `to[]` (required), `cc[]`, `bcc[]`, `subject`, `body`, `body_format` | no (saved to Drafts) |
325
329
  | `mail_reply` | `message_id` or `file_path`, `body` (required), `reply_all`, `save_as_draft` | when `reply_all` is true |
326
330
  | `mail_forward` | `message_id` or `file_path`, `to[]` (required), `body`, `save_as_draft` | when recipients > 1 |
package/indexer.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  unfoldRfc822Headers,
14
14
  stripSubjectPrefixes
15
15
  } from "./lib/validators.js";
16
- import { safeSqlite3Json, safeOsascript, safeFind } from "./lib/shell.js";
16
+ import { safeSqlite3Json, safeSqlite3JsonAsync, safeOsascript, safeFindAsync } from "./lib/shell.js";
17
17
  import { indexUnavailableMessage } from "./lib/indexGate.js";
18
18
  import { createLanceTableCache, LANCE_CONNECT_OPTIONS } from "./lib/lancedbTables.js";
19
19
  import { createMessageContactMatcher } from "./lib/messageContact.js";
@@ -362,7 +362,8 @@ async function findAllEmlxFiles() {
362
362
  // "*.emlx" also matches "*.partial.emlx"
363
363
  // Let permission/IO errors propagate so indexEmails does not persist a
364
364
  // timestamp for an empty scan and skip real mail forever.
365
- return safeFind(MAIL_DIR, { name: "*.emlx", type: "f" });
365
+ // Async: the daemon keeps answering writer.sock while a slow scan runs.
366
+ return safeFindAsync(MAIL_DIR, { name: "*.emlx", type: "f" });
366
367
  }
367
368
 
368
369
  // Fast incremental scan - uses find with -mtime filter (more reliable than mdfind/Spotlight)
@@ -380,7 +381,7 @@ async function findNewEmlxFiles(sinceTimestamp) {
380
381
 
381
382
  // Use find with -mtime instead of mdfind for reliability
382
383
  // find is more reliable than Spotlight which can have stale/incomplete indexes
383
- const files = safeFind(MAIL_DIR, { name: "*.emlx", type: "f", mtime: `-${daysAgo}` });
384
+ const files = await safeFindAsync(MAIL_DIR, { name: "*.emlx", type: "f", mtime: `-${daysAgo}` });
384
385
  console.error(`find found ${files.length} new/modified emails in last ${daysAgo} days`);
385
386
  return files;
386
387
  } catch (e) {
@@ -563,7 +564,7 @@ function extractTextFromAttributedBody(buffer) {
563
564
  }
564
565
  }
565
566
 
566
- function getMessages(sinceTimestamp = null) {
567
+ async function getMessages(sinceTimestamp = null) {
567
568
  try {
568
569
  // Enhanced query to get chat info, group chat detection, and attachments
569
570
  // Include attributedBody for messages where text is NULL (newer macOS format)
@@ -599,7 +600,7 @@ function getMessages(sinceTimestamp = null) {
599
600
  ${dateFilter}
600
601
  ORDER BY m.date DESC
601
602
  `;
602
- const results = safeSqlite3Json(MESSAGES_DB, query, { timeout: 60000 });
603
+ const results = await safeSqlite3JsonAsync(MESSAGES_DB, query, { timeout: 60000 });
603
604
  if (!Array.isArray(results)) {
604
605
  throw new Error("Unexpected sqlite3 result for messages");
605
606
  }
@@ -668,7 +669,7 @@ function getParticipantStatus(status) {
668
669
  return statusMap[status] || "unknown";
669
670
  }
670
671
 
671
- function getCalendarEvents() {
672
+ async function getCalendarEvents() {
672
673
  // NOTE: We index ALL calendar events, not filtered by date
673
674
  // Calendar events don't have a "file modification time" like emails do,
674
675
  // so we can't use the mdfind + DAYS_BACK approach.
@@ -718,12 +719,12 @@ function getCalendarEvents() {
718
719
  WHERE p.entity_type = 0
719
720
  `;
720
721
 
721
- return withCalendarCopy((dbPath) => {
722
- const rows = safeSqlite3Json(dbPath, query, { timeout: 30000 });
722
+ return withCalendarCopyAsync(async (dbPath) => {
723
+ const rows = await safeSqlite3JsonAsync(dbPath, query, { timeout: 30000 });
723
724
 
724
725
  let attendeesMap = new Map();
725
726
  try {
726
- const attendeesRows = safeSqlite3Json(dbPath, attendeesQuery, { timeout: 10000 });
727
+ const attendeesRows = await safeSqlite3JsonAsync(dbPath, attendeesQuery, { timeout: 10000 });
727
728
 
728
729
  for (const att of attendeesRows) {
729
730
  if (!attendeesMap.has(att.owner_id)) {
@@ -1165,7 +1166,7 @@ export async function indexMessages(forceFullScan = false) {
1165
1166
  // Use incremental scan if we have a previous timestamp
1166
1167
  let messages;
1167
1168
  try {
1168
- messages = getMessages(lastMessageIndexTime);
1169
+ messages = await getMessages(lastMessageIndexTime);
1169
1170
  } catch (e) {
1170
1171
  console.error(`Messages source read failed; skipping this cycle so the lookback timestamp is not advanced: ${e.message}`);
1171
1172
  return { indexed: 0, added: 0, error: e.message };
@@ -1311,7 +1312,7 @@ export async function indexCalendar() {
1311
1312
 
1312
1313
  let events;
1313
1314
  try {
1314
- events = getCalendarEvents();
1315
+ events = await getCalendarEvents();
1315
1316
  } catch (e) {
1316
1317
  // Do not treat a source-read failure as "zero events" — that would mark
1317
1318
  // every indexed row stale and delete the calendar index.
@@ -1791,34 +1792,54 @@ export function buildEventsOnDateQuery(startMac, endMac, { includeUid = true } =
1791
1792
 
1792
1793
  // Copy Calendar.sqlitedb (+ wal/shm) to /tmp for one query, then delete the copies.
1793
1794
  // Calendar.app holds a lock on the live db; sqlite can read a snapshot copy.
1794
- function withCalendarCopy(fn) {
1795
+ function copyCalendarDb() {
1795
1796
  const id = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1796
1797
  const tmpDb = path.join("/tmp", `${CALENDAR_TMP_PREFIX}${id}.sqlitedb`);
1797
- const tmpWal = `${tmpDb}-wal`;
1798
- const tmpShm = `${tmpDb}-shm`;
1799
- const srcWal = `${CALENDAR_DB}-wal`;
1800
- const srcShm = `${CALENDAR_DB}-shm`;
1801
-
1798
+ const files = [tmpDb, `${tmpDb}-wal`, `${tmpDb}-shm`];
1799
+ const cleanup = () => {
1800
+ for (const file of files) {
1801
+ try {
1802
+ fs.unlinkSync(file);
1803
+ } catch {
1804
+ // already gone or never created
1805
+ }
1806
+ }
1807
+ };
1802
1808
  try {
1803
1809
  if (!fs.existsSync(CALENDAR_DB)) {
1804
1810
  throw new Error("Calendar database not found");
1805
1811
  }
1806
1812
  fs.copyFileSync(CALENDAR_DB, tmpDb);
1807
- if (fs.existsSync(srcWal)) {
1808
- fs.copyFileSync(srcWal, tmpWal);
1813
+ if (fs.existsSync(`${CALENDAR_DB}-wal`)) {
1814
+ fs.copyFileSync(`${CALENDAR_DB}-wal`, files[1]);
1809
1815
  }
1810
- if (fs.existsSync(srcShm)) {
1811
- fs.copyFileSync(srcShm, tmpShm);
1816
+ if (fs.existsSync(`${CALENDAR_DB}-shm`)) {
1817
+ fs.copyFileSync(`${CALENDAR_DB}-shm`, files[2]);
1812
1818
  }
1819
+ } catch (e) {
1820
+ cleanup();
1821
+ throw e;
1822
+ }
1823
+ return { tmpDb, cleanup };
1824
+ }
1825
+
1826
+ // Index-cycle variant: the sqlite3 reads inside fn are awaited, so the
1827
+ // daemon's event loop (and writer.sock) stays free while they run.
1828
+ async function withCalendarCopyAsync(fn) {
1829
+ const { tmpDb, cleanup } = copyCalendarDb();
1830
+ try {
1831
+ return await fn(tmpDb);
1832
+ } finally {
1833
+ cleanup();
1834
+ }
1835
+ }
1836
+
1837
+ function withCalendarCopy(fn) {
1838
+ const { tmpDb, cleanup } = copyCalendarDb();
1839
+ try {
1813
1840
  return fn(tmpDb);
1814
1841
  } finally {
1815
- for (const file of [tmpDb, tmpWal, tmpShm]) {
1816
- try {
1817
- fs.unlinkSync(file);
1818
- } catch {
1819
- // already gone or never created
1820
- }
1821
- }
1842
+ cleanup();
1822
1843
  }
1823
1844
  }
1824
1845
 
package/lib/httpAuth.js CHANGED
@@ -8,12 +8,13 @@
8
8
  * a bearer token.
9
9
  *
10
10
  * The token is a random secret generated once and stored in the macOS
11
- * Keychain — never in a config file, the repo, or logs after the first
12
- * (one-time, clearly labeled) print. There is no external account, no
11
+ * Keychain — never in a config file, the repo, argv, or logs.
12
+ * Print it only with the explicit http-token command. There is no external account, no
13
13
  * OAuth flow, and no cost: it's a self-issued password, like an SSH key.
14
14
  */
15
15
 
16
16
  import crypto from "crypto";
17
+ import { writeKeychainSecret } from "./keychainWrite.js";
17
18
  import { execFileSync as defaultExecFileSync } from "child_process";
18
19
 
19
20
  export const KEYCHAIN_SERVICE = "apple-tools-mcp-http";
@@ -34,11 +35,7 @@ function readFromKeychain(exec) {
34
35
  }
35
36
 
36
37
  function writeToKeychain(exec, token) {
37
- exec(
38
- "/usr/bin/security",
39
- ["add-generic-password", "-s", KEYCHAIN_SERVICE, "-a", KEYCHAIN_ACCOUNT, "-w", token, "-U"],
40
- { stdio: ["ignore", "ignore", "pipe"] }
41
- );
38
+ writeKeychainSecret(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, token, exec);
42
39
  }
43
40
 
44
41
  function generateToken(randomBytes) {
@@ -71,13 +68,8 @@ export function loadOrCreateHttpAuthToken(options = {}) {
71
68
 
72
69
  const token = generateToken(randomBytes);
73
70
  writeToKeychain(exec, token);
74
- log("============================================================");
75
- log("Generated a new HTTP auth token (shown once, stored in Keychain):");
76
- log(token);
77
- log("Use this as a Bearer token when configuring remote MCP clients,");
78
- log("e.g. claude mcp add ... -H \"Authorization: Bearer " + token + "\"");
79
- log("Retrieve it again later with: apple-tools-mcp http-token");
80
- log("============================================================");
71
+ log("Generated a new HTTP auth token and stored it in Keychain.");
72
+ log("Retrieve it with: apple-tools-mcp http-token");
81
73
  return { token, generated: true };
82
74
  }
83
75
 
@@ -0,0 +1,17 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ /** The security utility parses one stdin line; credential bytes never enter process argv. */
4
+ export function writeKeychainSecret(service, account, value, exec = execFileSync) {
5
+ try {
6
+ const quote = (text) => {
7
+ if (!text || /[\r\n\0]/.test(text)) throw new Error("Invalid Keychain field");
8
+ return '"' + text.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
9
+ };
10
+ const command = "add-generic-password -U -s " + quote(service) + " -a " + quote(account) + " -w " + quote(value) + "\n";
11
+ // Apple's interactive command buffer is 4096 bytes. Reject before any truncated write.
12
+ if (Buffer.byteLength(command, "utf8") >= 4095) throw new Error("Keychain field too large");
13
+ exec("/usr/bin/security", ["-i"], { input: command, stdio: ["pipe", "ignore", "ignore"], timeout: 15000 });
14
+ } catch {
15
+ throw new Error("Keychain write failed; credential values omitted.");
16
+ }
17
+ }
@@ -0,0 +1,70 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { createHash } from "crypto";
4
+ import { getAppleToolsDir } from "./config.js";
5
+
6
+ const digest = (value) => createHash("sha256").update(value).digest("hex");
7
+ const uncertain = () => ({
8
+ ok: false, unconfirmed: true, sendStarted: true,
9
+ message: "mail_send: unconfirmed. An earlier identical send may still complete. Check Sent and Outbox; do not resend."
10
+ });
11
+
12
+ /** Cross-process, restart-safe send claims. Only hashes and outcome metadata
13
+ * are stored. A crash or corrupt record fails closed, including an empty file
14
+ * left between exclusive creation and its first write. No uncertain claim
15
+ * expires: a missing Sent row cannot prove a queued send will not run later.
16
+ */
17
+ export function idempotentMailSend(args, run, {
18
+ directory = path.join(getAppleToolsDir(), "mail-send-claims"), now = Date.now
19
+ } = {}) {
20
+ if (args.dry_run === true) return run();
21
+ const key = args.idempotency_key;
22
+ if (key !== undefined && (typeof key !== "string" || !key.trim() || key.length > 256)) {
23
+ return { ok: false, sendStarted: false, message: "mail_send refused: idempotency_key must be a nonempty string of at most 256 characters." };
24
+ }
25
+ const addresses = (values) => (Array.isArray(values) ? values.map(v => String(v).trim().toLowerCase()).sort() : values);
26
+ const fingerprint = digest(JSON.stringify([
27
+ addresses(args.to), addresses(args.cc || []), addresses(args.bcc || []),
28
+ args.subject, args.body, args.body_format || "plain"
29
+ ]));
30
+ const dir = path.join(directory, digest(key === undefined ? fingerprint : `key:${key}`));
31
+ try {
32
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
33
+ fs.chmodSync(directory, 0o700);
34
+ fs.chmodSync(dir, 0o700);
35
+ const attempts = fs.readdirSync(dir).filter(n => /^\d+\.json$/.test(n)).map(n => Number(n.slice(0, -5)));
36
+ const last = attempts.length ? Math.max(...attempts) : 0;
37
+ if (last) {
38
+ const previous = JSON.parse(fs.readFileSync(path.join(dir, `${last}.json`), "utf8"));
39
+ if (previous.fingerprint !== fingerprint) {
40
+ return { ok: false, sendStarted: false, message: "mail_send refused: idempotency_key was already used with different message content." };
41
+ }
42
+ if (previous.state === "done") {
43
+ if (previous.result?.ok !== true) return uncertain();
44
+ return { ...previous.result, deduplicated: true };
45
+ }
46
+ if (previous.state !== "not_started") return uncertain();
47
+ }
48
+ // Atomic exclusive create arbitrates concurrent retries, even in different processes.
49
+ const file = path.join(dir, `${last + 1}.json`);
50
+ const fd = fs.openSync(file, "wx", 0o600);
51
+ const claim = { fingerprint, createdAt: now(), state: "pending" };
52
+ try {
53
+ fs.writeFileSync(fd, JSON.stringify(claim));
54
+ fs.fsyncSync(fd);
55
+ } finally { fs.closeSync(fd); }
56
+ let result;
57
+ try { result = run(); } catch { return uncertain(); }
58
+ const safe = result?.sendStarted === false || result?.planned === true || /^mail_send refused:/.test(result?.message || "");
59
+ const state = result?.ok === true ? "done" : safe ? "not_started" : "pending";
60
+ const cached = result?.ok === true ? {
61
+ ok: true, delivered: result.delivered === true, mailbox: result.mailbox,
62
+ message: "mail_send: previous send already accepted; duplicate suppressed."
63
+ } : undefined;
64
+ // Torn writes remain blocked on the next call. Never persist message content.
65
+ fs.writeFileSync(file, JSON.stringify({ ...claim, state, result: cached }), { mode: 0o600 });
66
+ return result;
67
+ } catch {
68
+ return uncertain();
69
+ }
70
+ }
package/lib/mailWrite.js CHANGED
@@ -207,7 +207,7 @@ export const MAIL_COMPOSE_TOP_UI_MAX = 48;
207
207
  /** Marker inside the JXA hit-test so tests can pin the proven Mini path. */
208
208
  export const ATM_AX_HIT_TEST_MARKER = "ATM_AX_HIT_TEST";
209
209
  /** Cheap pre-compose System Events check; SIGKILL if it still hangs. Not the permissions grant. */
210
- export const MAIL_SE_PROBE_TIMEOUT_MS = 4000;
210
+ export const MAIL_SE_PROBE_TIMEOUT_MS = 12000;
211
211
  /** First-run / upgrade permissions probe: long enough for the Allow click. */
212
212
  export const SYSTEM_EVENTS_AUTOMATION_PROBE_TIMEOUT_MS = 30000;
213
213
  /** Compose osascript budget (make + keystroke + send). Far below the ~60s SE wedge. */
@@ -235,7 +235,7 @@ export const MCP_CALL_DEADLINE_MS = 55000;
235
235
  export const MAIL_FIND_AND_SEND_TIMEOUT_MS = MAIL_FIND_TIMEOUT_MS;
236
236
  export const MAIL_SE_APPLEEVENT_TIMEOUT_SEC = 8;
237
237
  export const MAIL_APPLEEVENT_TIMEOUT_SEC = 15;
238
- export const MAIL_SE_PROBE_APPLEEVENT_TIMEOUT_SEC = 3;
238
+ export const MAIL_SE_PROBE_APPLEEVENT_TIMEOUT_SEC = 10;
239
239
 
240
240
  /**
241
241
  * AppleScript ordinal/keyword tokens that fail compile in `set {a, b}`
@@ -470,7 +470,9 @@ export function probeSystemEventsAutomation() {
470
470
  });
471
471
  if (!result.ok) {
472
472
  const kind = systemEventsGrantKind(result);
473
- const guidance = isMailAccessibilityDenial(result)
473
+ const guidance = result.kind === "timeout"
474
+ ? "System Events probe timed out; permission status is unknown. Nothing was typed. Retry when the host is responsive."
475
+ : isMailAccessibilityDenial(result)
474
476
  ? MAIL_ACCESSIBILITY_GUIDANCE
475
477
  : MAIL_GUI_SCRIPTING_GUIDANCE;
476
478
  return {
@@ -1326,7 +1328,6 @@ export function isMailGuiScriptingUnavailable(result) {
1326
1328
  if (!result) return false;
1327
1329
  const text = String(result.error || "").toLowerCase();
1328
1330
  if (text.includes("gui_scripting_unavailable")) return true;
1329
- if (result.kind === "timeout" && text.includes("system events")) return true;
1330
1331
  return false;
1331
1332
  }
1332
1333
 
@@ -1474,6 +1475,12 @@ function finalizeMailWrite({
1474
1475
  }
1475
1476
 
1476
1477
  function guiScriptingUnavailableResult(action, summary, result, { indexerMode } = {}) {
1478
+ if (result.kind === "timeout") {
1479
+ return {
1480
+ ok: false, delivered: false, mailbox: null, kind: "probe_timeout", sendStarted: false,
1481
+ message: `${action} failed: System Events probe timed out; permission status is unknown. Nothing was composed or sent. Retry when the host is responsive.`
1482
+ };
1483
+ }
1477
1484
  const unsupported = Boolean(indexerMode);
1478
1485
  const message = isMailAccessibilityDenial(result)
1479
1486
  ? `${action} failed — attempted to ${summary}. ${MAIL_ACCESSIBILITY_GUIDANCE}`
@@ -1482,6 +1489,7 @@ function guiScriptingUnavailableResult(action, summary, result, { indexerMode }
1482
1489
  ok: false,
1483
1490
  delivered: false,
1484
1491
  mailbox: null,
1492
+ sendStarted: false,
1485
1493
  ...(unsupported ? { unsupported: true } : {}),
1486
1494
  message
1487
1495
  };
@@ -1530,6 +1538,7 @@ export function mailCompose(args = {}, { draft = false, indexerMode = isIndexerM
1530
1538
  if (!plan.proceed) return plannedWriteResult(plan);
1531
1539
 
1532
1540
  const plainBody = composeNativeBody(body.text, { html: bodyFormat === "html" });
1541
+ const deadlineMs = sentVerifyClock.now() + MCP_CALL_DEADLINE_MS;
1533
1542
  if (plainBody) {
1534
1543
  const se = probeSystemEventsGui();
1535
1544
  if (!se.ok) {
@@ -1547,7 +1556,6 @@ export function mailCompose(args = {}, { draft = false, indexerMode = isIndexerM
1547
1556
  html: bodyFormat === "html"
1548
1557
  });
1549
1558
 
1550
- const deadlineMs = sentVerifyClock.now() + MCP_CALL_DEADLINE_MS;
1551
1559
  wakeDisplay();
1552
1560
  const result = runAppleScript(script, { timeout: MAIL_COMPOSE_TIMEOUT_MS, appName: "Mail" });
1553
1561
  if (
@@ -1703,6 +1711,7 @@ export function mailReply(args = {}, { indexerMode = isIndexerMode() } = {}) {
1703
1711
  });
1704
1712
  if (!plan.proceed) return plannedWriteResult(plan);
1705
1713
 
1714
+ const deadlineMs = sentVerifyClock.now() + MCP_CALL_DEADLINE_MS;
1706
1715
  if (quoteIntroPasteText(body.text)) {
1707
1716
  const se = probeSystemEventsGui();
1708
1717
  if (!se.ok) {
@@ -1710,11 +1719,10 @@ export function mailReply(args = {}, { indexerMode = isIndexerMode() } = {}) {
1710
1719
  }
1711
1720
  }
1712
1721
 
1713
- const deadlineMs = sentVerifyClock.now() + MCP_CALL_DEADLINE_MS;
1714
1722
  wakeDisplay();
1715
1723
  const result = runAppleScript(
1716
1724
  buildReplyScript({ messageId: resolved.messageId, body: body.text, replyAll, sendNow }),
1717
- { timeout: MAIL_FIND_AND_SEND_TIMEOUT_MS, appName: "Mail" }
1725
+ { timeout: Math.min(MAIL_FIND_AND_SEND_TIMEOUT_MS, Math.max(1, deadlineMs - sentVerifyClock.now())), appName: "Mail" }
1718
1726
  );
1719
1727
  return finishMailGuiWrite({
1720
1728
  action,
@@ -1793,6 +1801,7 @@ export function mailForward(args = {}, { indexerMode = isIndexerMode() } = {}) {
1793
1801
  });
1794
1802
  if (!plan.proceed) return plannedWriteResult(plan);
1795
1803
 
1804
+ const deadlineMs = sentVerifyClock.now() + MCP_CALL_DEADLINE_MS;
1796
1805
  if (quoteIntroPasteText(body.text)) {
1797
1806
  const se = probeSystemEventsGui();
1798
1807
  if (!se.ok) {
@@ -1800,11 +1809,10 @@ export function mailForward(args = {}, { indexerMode = isIndexerMode() } = {}) {
1800
1809
  }
1801
1810
  }
1802
1811
 
1803
- const deadlineMs = sentVerifyClock.now() + MCP_CALL_DEADLINE_MS;
1804
1812
  wakeDisplay();
1805
1813
  const result = runAppleScript(
1806
1814
  buildForwardScript({ messageId: resolved.messageId, to: to.addresses, body: body.text, sendNow }),
1807
- { timeout: MAIL_FIND_AND_SEND_TIMEOUT_MS, appName: "Mail" }
1815
+ { timeout: Math.min(MAIL_FIND_AND_SEND_TIMEOUT_MS, Math.max(1, deadlineMs - sentVerifyClock.now())), appName: "Mail" }
1808
1816
  );
1809
1817
  return finishMailGuiWrite({
1810
1818
  action,
package/lib/shell.js CHANGED
@@ -28,14 +28,7 @@ import { escapeSQL } from "./validators.js";
28
28
  * @returns {string} Raw output from sqlite3
29
29
  * @throws {Error} If execution fails
30
30
  */
31
- export function safeSqlite3(dbPath, query, options = {}) {
32
- const {
33
- json = true,
34
- readonly = false,
35
- timeout = 30000,
36
- maxBuffer = 50 * 1024 * 1024
37
- } = options;
38
-
31
+ function buildSqlite3Args(dbPath, query, { json = true, readonly = false } = {}) {
39
32
  // Validate database path exists and is absolute
40
33
  if (!dbPath || typeof dbPath !== 'string') {
41
34
  throw new Error('Database path is required');
@@ -59,15 +52,10 @@ export function safeSqlite3(dbPath, query, options = {}) {
59
52
  }
60
53
  args.push(dbPath);
61
54
  args.push(query.replace(/\n/g, ' ')); // Normalize whitespace
55
+ return args;
56
+ }
62
57
 
63
- const result = spawnSync('sqlite3', args, {
64
- encoding: 'utf-8',
65
- timeout,
66
- maxBuffer,
67
- // Don't use shell - prevents injection
68
- shell: false
69
- });
70
-
58
+ function sqlite3Output(result) {
71
59
  if (result.error) {
72
60
  throw result.error;
73
61
  }
@@ -80,6 +68,36 @@ export function safeSqlite3(dbPath, query, options = {}) {
80
68
  return result.stdout;
81
69
  }
82
70
 
71
+ function parseSqlite3Json(output) {
72
+ if (!output || output.trim() === '') {
73
+ return [];
74
+ }
75
+
76
+ try {
77
+ return JSON.parse(output);
78
+ } catch (e) {
79
+ throw new Error(`Failed to parse sqlite3 JSON output: ${e.message}`);
80
+ }
81
+ }
82
+
83
+ export function safeSqlite3(dbPath, query, options = {}) {
84
+ const {
85
+ timeout = 30000,
86
+ maxBuffer = 50 * 1024 * 1024
87
+ } = options;
88
+ const args = buildSqlite3Args(dbPath, query, options);
89
+
90
+ const result = spawnSync('sqlite3', args, {
91
+ encoding: 'utf-8',
92
+ timeout,
93
+ maxBuffer,
94
+ // Don't use shell - prevents injection
95
+ shell: false
96
+ });
97
+
98
+ return sqlite3Output(result);
99
+ }
100
+
83
101
  /**
84
102
  * Execute sqlite3 and parse JSON result
85
103
  *
@@ -89,17 +107,23 @@ export function safeSqlite3(dbPath, query, options = {}) {
89
107
  * @returns {Array} Parsed JSON array of results
90
108
  */
91
109
  export function safeSqlite3Json(dbPath, query, options = {}) {
92
- const output = safeSqlite3(dbPath, query, { ...options, json: true });
93
-
94
- if (!output || output.trim() === '') {
95
- return [];
96
- }
110
+ return parseSqlite3Json(safeSqlite3(dbPath, query, { ...options, json: true }));
111
+ }
97
112
 
98
- try {
99
- return JSON.parse(output);
100
- } catch (e) {
101
- throw new Error(`Failed to parse sqlite3 JSON output: ${e.message}`);
102
- }
113
+ /**
114
+ * Async safeSqlite3Json for long-lived processes (the indexer daemon):
115
+ * a slow query under load waits without blocking the event loop, so the
116
+ * write bridge (writer.sock) keeps answering while an index cycle reads.
117
+ * Same validation, argv and error semantics as the sync version.
118
+ */
119
+ export async function safeSqlite3JsonAsync(dbPath, query, options = {}) {
120
+ const {
121
+ timeout = 30000,
122
+ maxBuffer = 50 * 1024 * 1024
123
+ } = options;
124
+ const args = buildSqlite3Args(dbPath, query, { ...options, json: true });
125
+ const result = await spawnCollect('sqlite3', args, { timeout, maxBuffer });
126
+ return parseSqlite3Json(sqlite3Output(result));
103
127
  }
104
128
 
105
129
  // ============ OSASCRIPT EXECUTION ============
@@ -235,13 +259,12 @@ export function safeMdfind(query, options = {}) {
235
259
  * @param {number} options.timeout - Timeout in ms (default: 120000)
236
260
  * @returns {string[]} Array of file paths
237
261
  */
238
- export function safeFind(searchPath, options = {}) {
262
+ function buildFindArgs(searchPath, options = {}) {
239
263
  const {
240
264
  name,
241
265
  type,
242
266
  mtime,
243
- maxdepth,
244
- timeout = 120000
267
+ maxdepth
245
268
  } = options;
246
269
 
247
270
  if (!searchPath || typeof searchPath !== 'string') {
@@ -280,14 +303,10 @@ export function safeFind(searchPath, options = {}) {
280
303
  }
281
304
  args.push('-mtime', mtime);
282
305
  }
306
+ return args;
307
+ }
283
308
 
284
- const result = spawnSync('find', args, {
285
- encoding: 'utf-8',
286
- timeout,
287
- maxBuffer: 100 * 1024 * 1024,
288
- shell: false
289
- });
290
-
309
+ function findPaths(result) {
291
310
  if (result.error) {
292
311
  throw result.error;
293
312
  }
@@ -308,6 +327,92 @@ export function safeFind(searchPath, options = {}) {
308
327
  return paths;
309
328
  }
310
329
 
330
+ export function safeFind(searchPath, options = {}) {
331
+ const { timeout = 120000 } = options;
332
+ const args = buildFindArgs(searchPath, options);
333
+ const result = spawnSync('find', args, {
334
+ encoding: 'utf-8',
335
+ timeout,
336
+ maxBuffer: 100 * 1024 * 1024,
337
+ shell: false
338
+ });
339
+ return findPaths(result);
340
+ }
341
+
342
+ /**
343
+ * Async safeFind for the indexer daemon: a 2-minute find under heavy load
344
+ * no longer freezes the daemon's event loop (and with it writer.sock).
345
+ */
346
+ export async function safeFindAsync(searchPath, options = {}) {
347
+ const { timeout = 120000 } = options;
348
+ const args = buildFindArgs(searchPath, options);
349
+ const result = await spawnCollect('find', args, { timeout, maxBuffer: 100 * 1024 * 1024 });
350
+ return findPaths(result);
351
+ }
352
+
353
+ /**
354
+ * spawn() with spawnSync-shaped results: { stdout, stderr, status, error }.
355
+ * Never a shell. On timeout the child is SIGKILLed and `error` is an
356
+ * ETIMEDOUT Error like spawnSync's; output past maxBuffer kills the child
357
+ * with ENOBUFS. Resolves (never rejects) so callers keep sync semantics.
358
+ */
359
+ export function spawnCollect(command, args, { timeout = 30000, maxBuffer = 10 * 1024 * 1024, spawnImpl = spawn } = {}) {
360
+ return new Promise((resolve) => {
361
+ let child;
362
+ try {
363
+ child = spawnImpl(command, args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
364
+ } catch (e) {
365
+ resolve({ stdout: '', stderr: '', status: null, error: e });
366
+ return;
367
+ }
368
+ const out = [];
369
+ const err = [];
370
+ let size = 0;
371
+ let error = null;
372
+ let settled = false;
373
+ const kill = (e) => {
374
+ if (!error) error = e;
375
+ try {
376
+ child.kill('SIGKILL');
377
+ } catch {
378
+ // already exited
379
+ }
380
+ };
381
+ const timer = timeout > 0
382
+ ? setTimeout(() => {
383
+ const e = new Error(`spawn ${command} ETIMEDOUT`);
384
+ e.code = 'ETIMEDOUT';
385
+ kill(e);
386
+ }, timeout)
387
+ : null;
388
+ const collect = (chunks) => (chunk) => {
389
+ size += chunk.length;
390
+ if (size > maxBuffer) {
391
+ const e = new Error(`spawn ${command} ENOBUFS`);
392
+ e.code = 'ENOBUFS';
393
+ kill(e);
394
+ return;
395
+ }
396
+ chunks.push(chunk);
397
+ };
398
+ child.stdout.on('data', collect(out));
399
+ child.stderr.on('data', collect(err));
400
+ const finish = (status, spawnError) => {
401
+ if (settled) return;
402
+ settled = true;
403
+ if (timer) clearTimeout(timer);
404
+ resolve({
405
+ stdout: Buffer.concat(out).toString('utf-8'),
406
+ stderr: Buffer.concat(err).toString('utf-8'),
407
+ status,
408
+ error: error || spawnError || null
409
+ });
410
+ };
411
+ child.on('error', (e) => finish(null, e));
412
+ child.on('close', (code) => finish(code, null));
413
+ });
414
+ }
415
+
311
416
  // ============ GENERIC SAFE SPAWN ============
312
417
 
313
418
  /**
@@ -65,7 +65,11 @@ export function startWriteBridgeServer({ socketPath, handler, log = () => {} })
65
65
  let response;
66
66
  try {
67
67
  const request = JSON.parse(buffer.slice(0, newline));
68
- response = await handler(request.tool, request.args || {});
68
+ if (Number.isFinite(request.expiresAt) && Date.now() >= request.expiresAt) {
69
+ response = { ok: false, sendStarted: false, message: "Write bridge request expired before execution; nothing was started." };
70
+ } else {
71
+ response = await handler(request.tool, request.args || {});
72
+ }
69
73
  } catch (e) {
70
74
  response = { ok: false, message: `Write bridge error: ${e.message}` };
71
75
  }
@@ -157,13 +161,15 @@ export function probeSocket(socketPath, timeoutMs = 1000) {
157
161
  * @returns {Promise<{ delivered: boolean, response: object|null, error: string|null }>}
158
162
  */
159
163
  export function requestWriteViaBridge({ socketPath, tool, args, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS }) {
164
+ const expiresAt = Date.now() + timeoutMs;
160
165
  return new Promise((resolve) => {
161
166
  if (!socketPath) {
162
- resolve({ delivered: false, response: null, error: "no socket path" });
167
+ resolve({ delivered: false, response: null, error: "no socket path", mayHaveStarted: false });
163
168
  return;
164
169
  }
165
170
 
166
171
  let settled = false;
172
+ let mayHaveStarted = false;
167
173
  let buffer = "";
168
174
  const finish = (value) => {
169
175
  if (settled) return;
@@ -173,7 +179,7 @@ export function requestWriteViaBridge({ socketPath, tool, args, timeoutMs = DEFA
173
179
  } catch {
174
180
  // ignore
175
181
  }
176
- resolve(value);
182
+ resolve({ ...value, mayHaveStarted });
177
183
  };
178
184
 
179
185
  const client = net.connect(socketPath);
@@ -181,7 +187,8 @@ export function requestWriteViaBridge({ socketPath, tool, args, timeoutMs = DEFA
181
187
  client.setTimeout(timeoutMs);
182
188
 
183
189
  client.on("connect", () => {
184
- client.write(JSON.stringify({ tool, args: args || {} }) + "\n");
190
+ mayHaveStarted = true;
191
+ client.write(JSON.stringify({ tool, args: args || {}, expiresAt }) + "\n");
185
192
  });
186
193
 
187
194
  client.on("data", (chunk) => {
package/lib/writeTools.js CHANGED
@@ -33,6 +33,7 @@ import { contactsAdd, contactsEdit, contactsRemove } from "./contactsWrite.js";
33
33
  import { planWriteRoute, planAfterDelegation, tccFallbackAdvice } from "./writeRouting.js";
34
34
  import { requestWriteViaBridge, probeSocket, defaultSocketPath } from "./writeBridge.js";
35
35
  import { needsHostTccAdvice } from "./appleScript.js";
36
+ import { idempotentMailSend } from "./mailIdempotency.js";
36
37
 
37
38
  const CONFIRM_PROPS = {
38
39
  dry_run: { type: "boolean", description: "Preview only: report what would happen and change nothing (default false)" },
@@ -57,6 +58,7 @@ export const WRITE_TOOL_DEFINITIONS = [
57
58
  bcc: { type: "array", items: { type: "string" }, description: "BCC email addresses" },
58
59
  subject: { type: "string", description: "Subject line (required)" },
59
60
  body: { type: "string", description: "Message body (required)" },
61
+ idempotency_key: { type: "string", description: "Stable unique delivery key. Reuse for retries of the same message; a new key represents a new intended delivery. Without a key identical accepted mail is suppressed without expiry; uncertain sends stay blocked." },
60
62
  body_format: { type: "string", enum: ["plain", "html"], description: "Plain text (default) or HTML. HTML is tag-stripped; the body is pasted into Mail's compose window (System Events focus on the message body, then Cmd-A and clipboard paste). AppleScript content/html content is not set because it quote-wraps the Sent body" },
61
63
  ...CONFIRM_PROPS
62
64
  },
@@ -371,7 +373,9 @@ export function executeWriteToolLocally(name, args = {}) {
371
373
  return { ok: false, message: `Unknown write tool: ${name}`, unsupported: true };
372
374
  }
373
375
  try {
374
- return handler(args || {});
376
+ return name === "mail_send"
377
+ ? idempotentMailSend(args || {}, () => handler(args || {}))
378
+ : handler(args || {});
375
379
  } catch (e) {
376
380
  return { ok: false, message: `${name} failed: ${e.message}` };
377
381
  }
@@ -379,7 +383,8 @@ export function executeWriteToolLocally(name, args = {}) {
379
383
 
380
384
  /**
381
385
  * Run a write, delegating privacy-gated work to the indexer daemon when it is
382
- * listening. Falls back to local execution whenever the bridge is unusable.
386
+ * listening. Sending tools fall back only when the handoff provably did not
387
+ * start a write; an ambiguous outcome retains daemon ownership.
383
388
  *
384
389
  * @param {string} name
385
390
  * @param {object} args
@@ -392,6 +397,7 @@ export function executeWriteToolLocally(name, args = {}) {
392
397
  */
393
398
  export function classifyWriteFailure(message) {
394
399
  const t = String(message || "");
400
+ if (/System Events probe timed out/i.test(t)) return "probe_timeout";
395
401
  if (/GUI scripting is unavailable/i.test(t)) return "gui_scripting_unavailable";
396
402
  if (/denied Accessibility|assistive access|-25211/i.test(t)) return "accessibility_denied";
397
403
  if (/display is asleep|screen saver/i.test(t)) return "display_asleep_or_screen_saver";
@@ -452,7 +458,17 @@ export async function dispatchWriteTool(name, args = {}, deps = {}) {
452
458
 
453
459
  let declined = null;
454
460
  if (route.target === "daemon") {
455
- const { delivered, response, error } = await request({ socketPath, tool: name, args });
461
+ const { delivered, response, error, mayHaveStarted } = await request({ socketPath, tool: name, args });
462
+ // A lost response is not evidence of a failed send. Preserve the daemon's
463
+ // ownership after any handoff that could have reached it.
464
+ if ((name === "mail_send" || name === "mail_reply" || name === "mail_forward" || name === "messages_send") &&
465
+ ((!delivered && mayHaveStarted !== false) ||
466
+ (delivered && (!response || typeof response !== "object" ||
467
+ (response.unsupported && response.sendStarted !== false))))) {
468
+ log(`Write bridge outcome unknown for ${name}; local fallback suppressed.`);
469
+ return { ok: false, unconfirmed: true, sendStarted: true,
470
+ message: `${name}: unconfirmed. The daemon may still complete the write. Check Sent/Outbox or the destination before retrying; no local resend was started.` };
471
+ }
456
472
  const { fallbackLocal } = planAfterDelegation({ delivered, response });
457
473
  if (!fallbackLocal) {
458
474
  return response;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "3.2.1",
3
+ "version": "3.2.3",
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",