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 +6 -2
- package/indexer.js +49 -28
- package/lib/httpAuth.js +6 -14
- package/lib/keychainWrite.js +17 -0
- package/lib/mailIdempotency.js +70 -0
- package/lib/mailWrite.js +17 -9
- package/lib/shell.js +141 -36
- package/lib/writeBridge.js +11 -4
- package/lib/writeTools.js +19 -3
- package/package.json +1 -1
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
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
722
|
-
const rows =
|
|
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 =
|
|
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
|
|
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
|
|
1798
|
-
const
|
|
1799
|
-
|
|
1800
|
-
|
|
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(
|
|
1808
|
-
fs.copyFileSync(
|
|
1813
|
+
if (fs.existsSync(`${CALENDAR_DB}-wal`)) {
|
|
1814
|
+
fs.copyFileSync(`${CALENDAR_DB}-wal`, files[1]);
|
|
1809
1815
|
}
|
|
1810
|
-
if (fs.existsSync(
|
|
1811
|
-
fs.copyFileSync(
|
|
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
|
-
|
|
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
|
|
12
|
-
*
|
|
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("
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
if (!output || output.trim() === '') {
|
|
95
|
-
return [];
|
|
96
|
-
}
|
|
110
|
+
return parseSqlite3Json(safeSqlite3(dbPath, query, { ...options, json: true }));
|
|
111
|
+
}
|
|
97
112
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/lib/writeBridge.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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",
|