apple-tools-mcp 3.0.5 → 3.0.6

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
@@ -103,6 +103,16 @@ Claude Desktop keeps this in `~/Library/Application Support/Claude/claude_deskto
103
103
 
104
104
  The MCP process is short-lived: it exits when the client disconnects. Ongoing indexing belongs on the indexer, not on a wrapper that holds this process open.
105
105
 
106
+ When stdin closes, requests already received still get their replies before the process exits (up to two minutes). A script can pipe a whole session in one go:
107
+
108
+ ```bash
109
+ printf '%s\n' \
110
+ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"script","version":"0"}}}' \
111
+ '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
112
+ '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"mail_search","arguments":{"query":"invoice","limit":5}}}' \
113
+ | apple-tools-mcp
114
+ ```
115
+
106
116
  ### 4. Indexer
107
117
 
108
118
  On first use the server builds a search index of your mail, messages, and calendar events. That can take a while if you have a lot of mail. The index is stored in `~/.apple-tools-mcp/vector-index/`.
@@ -248,6 +258,7 @@ Your client can also call `audit_index`.
248
258
  | `mail_read` | Full email content by file path. |
249
259
  | `mail_senders` | Most frequent senders. |
250
260
  | `mail_thread` | Every email in a conversation. |
261
+ | `mail_find` | Exact lookup in Mail's own database by subject prefix or text, sender, recipient, mailbox, and date. Returns every copy with its Message-ID. Does not wait on the index. |
251
262
 
252
263
  ### Messages
253
264
 
@@ -294,7 +305,7 @@ Every write tool accepts:
294
305
  | `dry_run` | Preview only. Nothing is sent or changed. This wins even if `confirm` is also set. |
295
306
  | `confirm` | Approves an action that is otherwise blocked. |
296
307
 
297
- Deletes need `confirm: true`: `mail_trash`, `calendar_remove`, `contacts_remove`, and `contacts_edit` when it clears every email or phone. One id per call. There is no bulk delete.
308
+ 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`).
298
309
 
299
310
  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.
300
311
 
@@ -314,10 +325,12 @@ A missing or malformed id or recipient is refused. Calendar times are local date
314
325
  | `mail_forward` | `message_id` or `file_path`, `to[]` (required), `body`, `save_as_draft` | when recipients > 1 |
315
326
  | `mail_mark` | `message_id` or `file_path`, `status` (`read` or `unread`, default `read`) | no |
316
327
  | `mail_archive` | `message_id` or `file_path` | no |
317
- | `mail_trash` | `message_id` or `file_path` | yes |
328
+ | `mail_trash` | `message_id` or `file_path`, or `message_ids[]` (up to 500) | yes |
318
329
 
319
330
  Pass the RFC822 Message-ID, or the `file_path` from `mail_search` or `mail_recent` and the server reads the Message-ID from the file. `mail_archive` moves the message to that account's Archive (or All Mail). `mail_trash` moves it to Trash.
320
331
 
332
+ `mail_trash` with `message_ids` moves every copy of each message, for example the INBOX copy and the Sent copy of a mail you sent yourself, to that account's Trash. It finds each copy through Mail's database (`mail_find` uses the same lookup) and checks its Message-ID again before it moves it. One call stops starting new moves after about 35 seconds and reports `remaining`. Run it again for the rest. Copies that are already in Trash are left alone. Nothing is deleted for good. Mail empties Trash on its own schedule.
333
+
321
334
  `body_format: "html"` pastes the text into Mail's compose window. Mail may still create its own HTML version. New mail is a new message, not a quoted reply. Reply and forward still include the original.
322
335
 
323
336
  A send is successful only after the message is in **Sent** or still in **Outbox**. If the call times out, check Sent before you try again. Retrying a message that already went out sends a second copy. A timeout is not the same thing as a denied Automation prompt.
package/index.js CHANGED
@@ -21,6 +21,8 @@ import { createIndexerLock, DEFAULT_LOCK_HEARTBEAT_MS } from "./lib/indexerLock.
21
21
  import {
22
22
  shouldConnectMcpStdio,
23
23
  bindStdinCloseExit,
24
+ trackInFlightRequests,
25
+ drainInFlightThenExit,
24
26
  beginIndexCycle,
25
27
  applyIndexerCycleEnd,
26
28
  mcpIndexingStartup,
@@ -38,6 +40,7 @@ import {
38
40
  import { startWriteBridgeServer, defaultSocketPath } from "./lib/writeBridge.js";
39
41
  import { closeEventKitSession, ensureEventKitSession } from "./lib/eventKitSession.js";
40
42
  import { EVENTKIT_JXA_HELPERS } from "./lib/calendarWrite.js";
43
+ import { mailFind, MAILBOX_KINDS, MAIL_FIND_MAX_LIMIT } from "./lib/mailFind.js";
41
44
 
42
45
  const PACKAGE_VERSION = JSON.parse(
43
46
  fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")
@@ -166,9 +169,17 @@ process.on("unhandledRejection", (reason, promise) => {
166
169
  // MCP stdio clients exit when the host closes stdin. The indexer daemon,
167
170
  // HTTP server, and short-lived CLIs must not — LaunchAgent / KeepAlive
168
171
  // often attaches stdin to /dev/null, and the CLIs exit on their own.
172
+ // A caller that pipes a batch and closes stdin still gets every reply:
173
+ // in-flight requests finish (up to STDIN_CLOSE_DRAIN_MS) before exit.
174
+ const STDIN_CLOSE_DRAIN_MS = 120000;
175
+ let stdioInFlight = null;
169
176
  bindStdinCloseExit(process.stdin, INDEXER_MODE || PERMISSIONS_MODE || HTTP_MODE || HTTP_TOKEN_MODE, () => {
170
177
  console.error("Client disconnected. Exiting.");
171
- shutdownIndexing(0);
178
+ drainInFlightThenExit({
179
+ inFlight: stdioInFlight,
180
+ timeoutMs: STDIN_CLOSE_DRAIN_MS,
181
+ exit: () => shutdownIndexing(0)
182
+ });
172
183
  });
173
184
 
174
185
  // Vector search imports
@@ -1141,6 +1152,28 @@ function createServer() {
1141
1152
  required: ["file_path"],
1142
1153
  },
1143
1154
  },
1155
+ {
1156
+ name: "mail_find",
1157
+ description: "Exact email lookup in Mail's own database (not the semantic index, so it never waits on indexing and matches filters exactly). Returns JSON: one row per copy (the same Message-ID in INBOX and Sent is two rows) with message_id, subject, from, to, received, mailbox, account_id. Needs at least one of subject_prefix, subject_contains, from, to, message_ids. Trash is excluded unless include_trash or mailboxes asks for it. Pair with mail_trash message_ids to clear what it finds.",
1158
+ inputSchema: {
1159
+ type: "object",
1160
+ properties: {
1161
+ subject_prefix: { type: "string", description: "Subject starts with this text (case-insensitive), e.g. '[Network] '" },
1162
+ subject_contains: { type: "string", description: "Subject contains this text (case-insensitive)" },
1163
+ from: { type: "string", description: "Exact sender address" },
1164
+ from_me: { type: "boolean", description: "Only messages sent from one of your own addresses (any address that appears as a sender in a Sent mailbox)" },
1165
+ to: { type: "string", description: "Exact recipient address (To or Cc)" },
1166
+ to_only: { type: "boolean", description: "With to: that address is the only recipient" },
1167
+ message_ids: { type: "array", items: { type: "string" }, description: "RFC822 Message-IDs (finds every copy)" },
1168
+ mailboxes: { type: "array", items: { type: "string", enum: MAILBOX_KINDS }, description: "Only these mailbox kinds (default: every mailbox except trash)" },
1169
+ include_trash: { type: "boolean", description: "Also return copies already in Trash (default false)" },
1170
+ older_than_hours: { type: "number", description: "Only messages received more than this many hours ago" },
1171
+ received_after: { type: "string", description: "ISO 8601 date/time; only messages received at or after it" },
1172
+ received_before: { type: "string", description: "ISO 8601 date/time; only messages received before it" },
1173
+ limit: { type: "number", description: `Maximum rows (default 50, max ${MAIL_FIND_MAX_LIMIT}); 'truncated' says when more matched` }
1174
+ },
1175
+ },
1176
+ },
1144
1177
 
1145
1178
  // ============ MESSAGES TOOLS ============
1146
1179
  {
@@ -1443,6 +1476,10 @@ function createServer() {
1443
1476
  result = readFullEmail(args.file_path);
1444
1477
  break;
1445
1478
 
1479
+ case "mail_find":
1480
+ result = mailFind(args || {});
1481
+ break;
1482
+
1446
1483
  // Messages tools
1447
1484
  case "messages_search":
1448
1485
  result = await messagesSearch(args.query, {
@@ -1624,6 +1661,7 @@ const server = createServer();
1624
1661
  async function main() {
1625
1662
  const transport = new StdioServerTransport();
1626
1663
  await server.connect(transport);
1664
+ stdioInFlight = trackInFlightRequests(transport);
1627
1665
  console.error(`Apple Tools MCP server running (v${PACKAGE_VERSION})`);
1628
1666
  // Background indexing: indexer daemon when --mode=indexer; otherwise local
1629
1667
  // fallback on this stdio process only if indexer.lock is free.
@@ -37,6 +37,95 @@ export function bindStdinCloseExit(stdin, indexerMode, onClose) {
37
37
  return { bound: true };
38
38
  }
39
39
 
40
+ /**
41
+ * Track JSON-RPC requests the server has received but not yet answered.
42
+ * A caller that pipes a batch and closes stdin (`printf ... | apple-tools-mcp`)
43
+ * fires stdin `close` right after the last line is read, while tools/call is
44
+ * still running; without this the close handler exits mid-call.
45
+ * Call after server.connect(transport) — Protocol sets onmessage there.
46
+ *
47
+ * @param {{ onmessage?: (message: any, extra?: any) => void, send: (message: any, options?: any) => Promise<void> }} transport
48
+ * @returns {{ readonly size: number, whenIdle: () => Promise<void> }}
49
+ */
50
+ export function trackInFlightRequests(transport) {
51
+ const pending = new Set();
52
+ let idleWaiters = [];
53
+ const settle = () => {
54
+ if (pending.size > 0) return;
55
+ const waiters = idleWaiters;
56
+ idleWaiters = [];
57
+ for (const resolve of waiters) resolve();
58
+ };
59
+
60
+ const onmessage = transport.onmessage;
61
+ transport.onmessage = (message, extra) => {
62
+ if (message?.method === "notifications/cancelled") {
63
+ // The SDK sends no response for a cancelled request.
64
+ pending.delete(message.params?.requestId);
65
+ settle();
66
+ } else if (message?.method && message.id !== undefined) {
67
+ pending.add(message.id);
68
+ }
69
+ onmessage?.(message, extra);
70
+ };
71
+
72
+ const send = transport.send.bind(transport);
73
+ transport.send = async (message, options) => {
74
+ try {
75
+ return await send(message, options);
76
+ } finally {
77
+ if (!message?.method && message?.id !== undefined && pending.delete(message.id)) {
78
+ settle();
79
+ }
80
+ }
81
+ };
82
+
83
+ return {
84
+ get size() {
85
+ return pending.size;
86
+ },
87
+ whenIdle() {
88
+ if (pending.size === 0) return Promise.resolve();
89
+ return new Promise((resolve) => idleWaiters.push(resolve));
90
+ }
91
+ };
92
+ }
93
+
94
+ /**
95
+ * On stdin close: let in-flight requests answer (up to timeoutMs), then exit.
96
+ * Exiting synchronously drops the last tools/call. The SDK's send() resolves
97
+ * only once stdout has drained, so an answered request is fully written.
98
+ *
99
+ * @param {{
100
+ * inFlight: { size: number, whenIdle: () => Promise<void> } | null,
101
+ * timeoutMs: number,
102
+ * exit: () => void,
103
+ * log?: (msg: string) => void
104
+ * }} options
105
+ * @returns {Promise<{ drained: boolean, pending: number }>}
106
+ */
107
+ export async function drainInFlightThenExit(options) {
108
+ const { inFlight, timeoutMs, exit } = options;
109
+ const log = options.log || ((msg) => console.error(msg));
110
+
111
+ const pending = inFlight ? inFlight.size : 0;
112
+ let drained = true;
113
+ if (pending > 0) {
114
+ log(`Waiting for ${pending} in-flight request(s) before exit...`);
115
+ let timer;
116
+ const timedOut = new Promise((resolve) => {
117
+ timer = setTimeout(() => resolve(true), timeoutMs);
118
+ });
119
+ drained = !(await Promise.race([inFlight.whenIdle().then(() => false), timedOut]));
120
+ clearTimeout(timer);
121
+ if (!drained) {
122
+ log(`Gave up on ${inFlight.size} in-flight request(s) after ${timeoutMs}ms.`);
123
+ }
124
+ }
125
+ exit();
126
+ return { drained, pending };
127
+ }
128
+
40
129
  /**
41
130
  * Start one index cycle, or skip if a cycle is already running.
42
131
  *
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Exact mail lookup against Mail's own Envelope Index (no vector index).
3
+ *
4
+ * mail_find answers "which messages match these exact facts" (subject
5
+ * prefix, sender, recipient, mailbox, date) and bulk mail_trash uses
6
+ * locateMessageCopies() to address every copy of a Message-ID by Mail's
7
+ * internal id. A message's AppleScript `id` is its Envelope Index ROWID, so
8
+ * `messages of <one mailbox> whose id is N` resolves in about 2s even on a
9
+ * 60k-message INBOX, where `whose message id is X` across every mailbox
10
+ * takes 30-38s (see MAIL_FIND_TIMEOUT_MS in mailWrite.js).
11
+ *
12
+ * Read-only: sqlite3 opens the database with -readonly and Mail stays its
13
+ * only writer. Needs the same Full Disk Access as the .emlx indexer.
14
+ */
15
+
16
+ import fs from "fs";
17
+ import path from "path";
18
+ import { safeSqlite3Json } from "./shell.js";
19
+ import { normalizeList, validateMessageId } from "./writeGuards.js";
20
+
21
+ const MAIL_DIR = path.join(process.env.HOME || "", "Library", "Mail");
22
+
23
+ export const MAIL_FIND_DEFAULT_LIMIT = 50;
24
+ export const MAIL_FIND_MAX_LIMIT = 500;
25
+ export const MAIL_FIND_MAX_IDS = 500;
26
+ const MAX_TEXT_FILTER = 300;
27
+ const MAX_COPIES = 5000;
28
+
29
+ /** Mailbox leaf names per kind, as Mail names them across iCloud, IMAP, Exchange and Gmail. */
30
+ export const MAILBOX_KIND_LEAVES = {
31
+ inbox: ["INBOX"],
32
+ sent: ["Sent Messages", "Sent", "Sent Items", "Sent Mail"],
33
+ trash: ["Deleted Messages", "Trash", "Bin", "Deleted Items"],
34
+ junk: ["Junk", "Junk E-mail", "Spam"],
35
+ drafts: ["Drafts"],
36
+ archive: ["Archive", "All Mail"]
37
+ };
38
+ export const MAILBOX_KINDS = Object.keys(MAILBOX_KIND_LEAVES);
39
+ export const TRASH_MAILBOX_NAMES = MAILBOX_KIND_LEAVES.trash;
40
+
41
+ const ACCOUNT_ID_RE = /^[0-9A-Fa-f-]{8,64}$/;
42
+ const ADDRESS_RE = /^[^\s@'"]{1,200}@[^\s@'"]{1,200}$/;
43
+
44
+ export const ENVELOPE_INDEX_ACCESS_GUIDANCE =
45
+ "Mail's Envelope Index could not be read. Give the node binary that runs Apple Tools Full Disk Access " +
46
+ "(System Settings → Privacy & Security → Full Disk Access), and make sure Mail has been set up on this Mac.";
47
+
48
+ /**
49
+ * Newest `~/Library/Mail/V<n>/MailData/Envelope Index`, or null.
50
+ */
51
+ export function envelopeIndexPath(mailDir = MAIL_DIR) {
52
+ let entries;
53
+ try {
54
+ entries = fs.readdirSync(mailDir);
55
+ } catch {
56
+ return null;
57
+ }
58
+ const versions = entries
59
+ .map((name) => /^V(\d+)$/.exec(name))
60
+ .filter(Boolean)
61
+ .map((m) => Number(m[1]))
62
+ .sort((a, b) => b - a);
63
+ for (const v of versions) {
64
+ const candidate = path.join(mailDir, `V${v}`, "MailData", "Envelope Index");
65
+ if (fs.existsSync(candidate)) return candidate;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * `imap://<account uuid>/Sent%20Messages` → account id + decoded mailbox path.
72
+ * Local ("On My Mac") mailboxes have no account id and cannot be addressed
73
+ * by `account id` in AppleScript.
74
+ */
75
+ export function parseMailboxUrl(url) {
76
+ const m = /^([a-z][a-z0-9+.-]*):\/\/([^/]*)\/(.+)$/i.exec(String(url || ""));
77
+ if (!m) return null;
78
+ let mailboxPath;
79
+ try {
80
+ mailboxPath = m[3].split("/").map((part) => decodeURIComponent(part)).join("/");
81
+ } catch {
82
+ return null;
83
+ }
84
+ const accountId = ACCOUNT_ID_RE.test(m[2]) ? m[2] : null;
85
+ return { scheme: m[1].toLowerCase(), accountId, mailboxPath };
86
+ }
87
+
88
+ export function mailboxKind(mailboxPath) {
89
+ const leaf = String(mailboxPath || "").split("/").pop().toLowerCase();
90
+ for (const [kind, leaves] of Object.entries(MAILBOX_KIND_LEAVES)) {
91
+ if (leaves.some((name) => name.toLowerCase() === leaf)) return kind;
92
+ }
93
+ return "other";
94
+ }
95
+
96
+ function sqlLiteral(value) {
97
+ return `'${String(value).replace(/'/g, "''")}'`;
98
+ }
99
+
100
+ function likeEscape(value) {
101
+ return String(value).replace(/[\\%_]/g, (c) => `\\${c}`);
102
+ }
103
+
104
+ function likeClause(expr, pattern) {
105
+ return `${expr} LIKE ${sqlLiteral(pattern)} ESCAPE '\\'`;
106
+ }
107
+
108
+ /** SQL condition: mailbox URL `column` ends in one of `kind`'s leaf names. */
109
+ export function mailboxKindCondition(kind, column = "mb.url") {
110
+ const leaves = MAILBOX_KIND_LEAVES[kind] || [];
111
+ return `(${leaves.map((leaf) => likeClause(column, `%/${likeEscape(encodeURIComponent(leaf))}`)).join(" OR ")})`;
112
+ }
113
+
114
+ function cleanText(value, field) {
115
+ if (value === undefined || value === null || value === "") return { value: null };
116
+ if (typeof value !== "string") return { error: `${field} must be a string` };
117
+ const trimmed = value.replace(/\s+/g, " ").trim();
118
+ if (!trimmed) return { value: null };
119
+ if (trimmed.length > MAX_TEXT_FILTER) return { error: `${field} is longer than ${MAX_TEXT_FILTER} characters` };
120
+ return { value: trimmed };
121
+ }
122
+
123
+ function cleanAddress(value, field) {
124
+ const text = cleanText(value, field);
125
+ if (text.error || !text.value) return text;
126
+ if (!ADDRESS_RE.test(text.value)) return { error: `${field} must be one email address` };
127
+ return { value: text.value.toLowerCase() };
128
+ }
129
+
130
+ function parseTime(value, field) {
131
+ if (value === undefined || value === null || value === "") return { value: null };
132
+ const ms = typeof value === "number" ? value * 1000 : Date.parse(String(value));
133
+ if (!Number.isFinite(ms)) return { error: `${field} must be an ISO 8601 date/time` };
134
+ return { value: Math.floor(ms / 1000) };
135
+ }
136
+
137
+ /**
138
+ * Validate mail_find arguments into SQL-ready filters.
139
+ * @returns {{ filters?: object, error?: string }}
140
+ */
141
+ export function validateFindArgs(args = {}, { now = Date.now() } = {}) {
142
+ const f = {};
143
+ for (const [key, field] of [["subjectPrefix", "subject_prefix"], ["subjectContains", "subject_contains"]]) {
144
+ const r = cleanText(args[field], field);
145
+ if (r.error) return { error: r.error };
146
+ f[key] = r.value;
147
+ }
148
+ for (const [key, field] of [["from", "from"], ["to", "to"]]) {
149
+ const r = cleanAddress(args[field], field);
150
+ if (r.error) return { error: r.error };
151
+ f[key] = r.value;
152
+ }
153
+ f.toOnly = args.to_only === true || args.to_only === "true";
154
+ if (f.toOnly && !f.to) return { error: "to_only needs to" };
155
+ f.fromMe = args.from_me === true || args.from_me === "true";
156
+
157
+ const ids = normalizeList(args.message_ids);
158
+ if (ids.length > MAIL_FIND_MAX_IDS) return { error: `message_ids accepts at most ${MAIL_FIND_MAX_IDS} ids` };
159
+ f.messageIds = [];
160
+ for (const raw of ids) {
161
+ const id = validateMessageId(raw);
162
+ if (!id) return { error: "message_ids contains an invalid RFC822 Message-ID" };
163
+ f.messageIds.push(id);
164
+ }
165
+
166
+ const mailboxes = normalizeList(args.mailboxes).map((m) => m.toLowerCase());
167
+ const unknown = mailboxes.filter((m) => !MAILBOX_KINDS.includes(m));
168
+ if (unknown.length) return { error: `mailboxes must be from: ${MAILBOX_KINDS.join(", ")}` };
169
+ f.mailboxes = mailboxes;
170
+ f.includeTrash = args.include_trash === true || args.include_trash === "true";
171
+
172
+ const after = parseTime(args.received_after, "received_after");
173
+ if (after.error) return { error: after.error };
174
+ const before = parseTime(args.received_before, "received_before");
175
+ if (before.error) return { error: before.error };
176
+ f.receivedAfter = after.value;
177
+ f.receivedBefore = before.value;
178
+ if (args.older_than_hours !== undefined && args.older_than_hours !== null && args.older_than_hours !== "") {
179
+ const hours = Number(args.older_than_hours);
180
+ if (!Number.isFinite(hours) || hours < 0 || hours > 24 * 365 * 20) {
181
+ return { error: "older_than_hours must be a number of hours (0 or more)" };
182
+ }
183
+ const cutoff = Math.floor(now / 1000 - hours * 3600);
184
+ f.receivedBefore = f.receivedBefore === null ? cutoff : Math.min(f.receivedBefore, cutoff);
185
+ }
186
+
187
+ if (!f.subjectPrefix && !f.subjectContains && !f.from && !f.to && f.messageIds.length === 0) {
188
+ return { error: "add at least one of subject_prefix, subject_contains, from, to, or message_ids" };
189
+ }
190
+
191
+ const limit = args.limit === undefined || args.limit === null ? MAIL_FIND_DEFAULT_LIMIT : Number(args.limit);
192
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAIL_FIND_MAX_LIMIT) {
193
+ return { error: `limit must be an integer from 1 to ${MAIL_FIND_MAX_LIMIT}` };
194
+ }
195
+ f.limit = limit;
196
+ return { filters: f };
197
+ }
198
+
199
+ const SUBJECT_EXPR = "(coalesce(m.subject_prefix, '') || s.subject)";
200
+
201
+ /** Build the Envelope Index query for validated filters. Selects limit+1 rows to detect truncation. */
202
+ export function buildFindQuery(f) {
203
+ const where = ["m.deleted = 0"];
204
+ if (f.subjectPrefix) where.push(likeClause(SUBJECT_EXPR, `${likeEscape(f.subjectPrefix)}%`));
205
+ if (f.subjectContains) where.push(likeClause(SUBJECT_EXPR, `%${likeEscape(f.subjectContains)}%`));
206
+ if (f.from) where.push(`lower(a.address) = ${sqlLiteral(f.from)}`);
207
+ if (f.to) {
208
+ where.push(
209
+ `EXISTS (SELECT 1 FROM recipients r JOIN addresses ra ON ra.ROWID = r.address WHERE r.message = m.ROWID AND lower(ra.address) = ${sqlLiteral(f.to)})`
210
+ );
211
+ if (f.toOnly) where.push("(SELECT count(*) FROM recipients r WHERE r.message = m.ROWID) = 1");
212
+ }
213
+ if (f.fromMe) {
214
+ // "Me" = any address that appears as the sender of a message in a Sent mailbox.
215
+ where.push(
216
+ `m.sender IN (SELECT DISTINCT m2.sender FROM messages m2 JOIN mailboxes mb2 ON mb2.ROWID = m2.mailbox WHERE m2.sender IS NOT NULL AND ${mailboxKindCondition("sent", "mb2.url")})`
217
+ );
218
+ }
219
+ if (f.messageIds.length) {
220
+ where.push(`g.message_id_header IN (${f.messageIds.map((id) => sqlLiteral(`<${id}>`)).join(", ")})`);
221
+ }
222
+ if (f.receivedAfter !== null) where.push(`m.date_received >= ${Math.floor(f.receivedAfter)}`);
223
+ if (f.receivedBefore !== null) where.push(`m.date_received < ${Math.floor(f.receivedBefore)}`);
224
+ if (f.mailboxes.length) {
225
+ where.push(`(${f.mailboxes.map((kind) => mailboxKindCondition(kind)).join(" OR ")})`);
226
+ } else if (!f.includeTrash) {
227
+ where.push(`NOT ${mailboxKindCondition("trash")}`);
228
+ }
229
+
230
+ return `SELECT m.ROWID AS id, g.message_id_header AS message_id, ${SUBJECT_EXPR} AS subject,
231
+ a.address AS sender, m.date_received AS received, mb.url AS mailbox_url,
232
+ (SELECT group_concat(ra.address, ',') FROM recipients r JOIN addresses ra ON ra.ROWID = r.address WHERE r.message = m.ROWID) AS recipients
233
+ FROM messages m
234
+ JOIN subjects s ON s.ROWID = m.subject
235
+ JOIN mailboxes mb ON mb.ROWID = m.mailbox
236
+ LEFT JOIN addresses a ON a.ROWID = m.sender
237
+ LEFT JOIN message_global_data g ON g.ROWID = m.global_message_id
238
+ WHERE ${where.join("\n AND ")}
239
+ ORDER BY m.date_received DESC, m.ROWID DESC
240
+ LIMIT ${f.limit + 1}`;
241
+ }
242
+
243
+ function mapRow(row) {
244
+ const box = parseMailboxUrl(row.mailbox_url) || { accountId: null, mailboxPath: String(row.mailbox_url || "") };
245
+ return {
246
+ id: row.id,
247
+ message_id: row.message_id ? String(row.message_id).replace(/^</, "").replace(/>$/, "") : null,
248
+ subject: row.subject || "",
249
+ from: row.sender || null,
250
+ to: row.recipients ? String(row.recipients).split(",") : [],
251
+ received: Number.isFinite(row.received) ? new Date(row.received * 1000).toISOString() : null,
252
+ mailbox: mailboxKind(box.mailboxPath),
253
+ mailbox_path: box.mailboxPath,
254
+ account_id: box.accountId
255
+ };
256
+ }
257
+
258
+ function runQuery(sql, { dbPath, query = safeSqlite3Json } = {}) {
259
+ const db = dbPath || envelopeIndexPath();
260
+ if (!db) throw new Error(ENVELOPE_INDEX_ACCESS_GUIDANCE);
261
+ try {
262
+ return query(db, sql, { readonly: true, timeout: 20000 });
263
+ } catch (e) {
264
+ const msg = String((e && e.message) || e);
265
+ if (/unable to open|authoriz|not permitted|denied/i.test(msg)) throw new Error(ENVELOPE_INDEX_ACCESS_GUIDANCE);
266
+ throw e;
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Exact lookup. Every copy is its own row (the same Message-ID in INBOX and
272
+ * Sent shows twice).
273
+ * @returns {{ messages: object[], truncated: boolean }}
274
+ */
275
+ export function findMail(filters, options = {}) {
276
+ const rows = runQuery(buildFindQuery(filters), options);
277
+ const truncated = rows.length > filters.limit;
278
+ return { messages: rows.slice(0, filters.limit).map(mapRow), truncated };
279
+ }
280
+
281
+ /**
282
+ * Every live copy of each Message-ID, split into copies outside Trash (to
283
+ * move) and the ids that have no such copy (already trashed or not in Mail).
284
+ */
285
+ export function locateMessageCopies(messageIds, options = {}) {
286
+ const filters = {
287
+ subjectPrefix: null,
288
+ subjectContains: null,
289
+ from: null,
290
+ to: null,
291
+ toOnly: false,
292
+ fromMe: false,
293
+ messageIds,
294
+ mailboxes: [],
295
+ includeTrash: false,
296
+ receivedAfter: null,
297
+ receivedBefore: null,
298
+ limit: MAX_COPIES
299
+ };
300
+ const { messages } = findMail(filters, options);
301
+ const found = new Set(messages.map((m) => m.message_id));
302
+ return { copies: messages, missingIds: messageIds.filter((id) => !found.has(id)) };
303
+ }
304
+
305
+ export function formatFindResult({ messages, truncated }) {
306
+ return JSON.stringify(
307
+ {
308
+ count: messages.length,
309
+ truncated,
310
+ note: truncated ? "More messages match; raise limit or narrow the filters." : undefined,
311
+ messages
312
+ },
313
+ null,
314
+ 2
315
+ );
316
+ }
317
+
318
+ /** mail_find tool entry point. Returns text; throws on invalid args or an unreadable index. */
319
+ export function mailFind(args = {}, options = {}) {
320
+ const { filters, error } = validateFindArgs(args, options);
321
+ if (error) throw new Error(`mail_find: ${error}`);
322
+ return formatFindResult(findMail(filters, options));
323
+ }
package/lib/mailWrite.js CHANGED
@@ -42,8 +42,10 @@ import {
42
42
  writeErrorMessage,
43
43
  writeSuccessMessage,
44
44
  isFlagTrue,
45
+ normalizeList,
45
46
  truncate
46
47
  } from "./writeGuards.js";
48
+ import { locateMessageCopies, TRASH_MAILBOX_NAMES, MAIL_FIND_MAX_IDS } from "./mailFind.js";
47
49
 
48
50
  const MAIL_DIR = path.join(process.env.HOME || "", "Library", "Mail");
49
51
 
@@ -1785,8 +1787,174 @@ export function mailArchive(args = {}) {
1785
1787
  return { ok: true, message: writeSuccessMessage(action, "moved to Archive", { message_id: resolved.messageId }) };
1786
1788
  }
1787
1789
 
1790
+ // Bulk mail_trash stops starting new moves after this many seconds so the
1791
+ // call answers inside MCP_CALL_DEADLINE_MS. Each copy takes ~2s (one
1792
+ // `whose id is` lookup in its own mailbox, then the move); callers re-run
1793
+ // for whatever is left.
1794
+ export const MAIL_BULK_TRASH_BUDGET_SEC = 35;
1795
+ export const MAIL_TRASH_MAX_IDS = MAIL_FIND_MAX_IDS;
1796
+
1797
+ /**
1798
+ * One osascript for every copy. Each copy is found by Mail's internal id in
1799
+ * its own mailbox (fast) and moved only if its Message-ID still matches, so
1800
+ * a stale Envelope Index row can never move the wrong message.
1801
+ * Output: one `<id>\t<status>` line per copy; status is moved, missing,
1802
+ * mismatch, no_account, no_trash, skipped (time budget) or `error <n>`.
1803
+ */
1804
+ export function buildBulkTrashScript(copies, { budgetSec = MAIL_BULK_TRASH_BUDGET_SEC } = {}) {
1805
+ const items = copies
1806
+ .map((c) => `{${asString(c.account_id)}, ${asString(c.mailbox_path)}, ${asInteger(c.id, { min: 1, field: "id" })}, ${asString(c.message_id)}}`)
1807
+ .join(", ");
1808
+ const trashNames = TRASH_MAILBOX_NAMES.map((name) => asString(name)).join(", ");
1809
+ return `on atmTrashBox(acct)
1810
+ tell application "Mail"
1811
+ repeat with nm in {${trashNames}}
1812
+ if (exists mailbox (contents of nm) of acct) then return mailbox (contents of nm) of acct
1813
+ end repeat
1814
+ end tell
1815
+ return missing value
1816
+ end atmTrashBox
1817
+
1818
+ on atmTrashCopy(acctId, boxPath, rid, mid)
1819
+ tell application "Mail"
1820
+ try
1821
+ set acct to account id acctId
1822
+ -- An account specifier resolves lazily; reading a property proves it exists.
1823
+ set acctName to name of acct
1824
+ on error
1825
+ return "no_account"
1826
+ end try
1827
+ try
1828
+ set hits to (messages of (mailbox boxPath of acct) whose id is rid)
1829
+ on error errMsg number errNum
1830
+ return "error " & errNum
1831
+ end try
1832
+ if (count of hits) is 0 then return "missing"
1833
+ set msg to item 1 of hits
1834
+ if (message id of msg) is not mid then return "mismatch"
1835
+ set trashBox to my atmTrashBox(acct)
1836
+ if trashBox is missing value then return "no_trash"
1837
+ try
1838
+ set mailbox of msg to trashBox
1839
+ on error errMsg number errNum
1840
+ return "error " & errNum
1841
+ end try
1842
+ return "moved"
1843
+ end tell
1844
+ end atmTrashCopy
1845
+
1846
+ set atmStart to current date
1847
+ set atmOut to ""
1848
+ repeat with atmItem in {${items}}
1849
+ if ((current date) - atmStart) > ${asInteger(budgetSec, { min: 1, max: 600, field: "budgetSec" })} then
1850
+ set atmStatus to "skipped"
1851
+ else
1852
+ set atmStatus to atmTrashCopy(item 1 of atmItem, item 2 of atmItem, item 3 of atmItem, item 4 of atmItem)
1853
+ end if
1854
+ set atmOut to atmOut & (item 3 of atmItem) & tab & atmStatus & linefeed
1855
+ end repeat
1856
+ return atmOut`;
1857
+ }
1858
+
1859
+ export function parseBulkTrashOutput(output) {
1860
+ const statuses = new Map();
1861
+ for (const line of String(output || "").split(/\r?\n|\r/)) {
1862
+ const m = /^(\d+)\t(.+)$/.exec(line.trim());
1863
+ if (m) statuses.set(Number(m[1]), m[2].trim());
1864
+ }
1865
+ return statuses;
1866
+ }
1867
+
1868
+ function plural(n, word, many = `${word}s`) {
1869
+ return `${n} ${n === 1 ? word : many}`;
1870
+ }
1871
+
1872
+ /**
1873
+ * mail_trash with message_ids: move every copy (INBOX, Sent, Archive, ...)
1874
+ * of each message to its account's Trash.
1875
+ */
1876
+ export function mailTrashMany(args = {}, { locate = locateMessageCopies, budgetSec = MAIL_BULK_TRASH_BUDGET_SEC } = {}) {
1877
+ const action = "mail_trash";
1878
+ if (args.message_id || args.file_path) {
1879
+ return { ok: false, message: `${action} refused: pass message_ids, or message_id / file_path — not both.` };
1880
+ }
1881
+ const raw = normalizeList(args.message_ids);
1882
+ if (raw.length === 0) return { ok: false, message: `${action} refused: message_ids is empty.` };
1883
+ if (raw.length > MAIL_TRASH_MAX_IDS) {
1884
+ return { ok: false, message: `${action} refused: message_ids accepts at most ${MAIL_TRASH_MAX_IDS} ids per call.` };
1885
+ }
1886
+ const ids = [];
1887
+ for (const value of raw) {
1888
+ const id = validateMessageId(value);
1889
+ if (!id) return { ok: false, message: `${action} refused: message_ids contains an invalid RFC822 Message-ID.` };
1890
+ if (!ids.includes(id)) ids.push(id);
1891
+ }
1892
+
1893
+ let located;
1894
+ try {
1895
+ located = locate(ids);
1896
+ } catch (e) {
1897
+ return { ok: false, message: `${action} failed — could not look the messages up. ${e.message}` };
1898
+ }
1899
+ const live = located.copies.filter((c) => c.mailbox !== "trash");
1900
+ const movable = live.filter((c) => c.account_id);
1901
+ const unaddressable = live.length - movable.length;
1902
+ const summary = `move ${plural(movable.length, "copy", "copies")} of ${plural(ids.length - located.missingIds.length, "message")} to Trash` +
1903
+ (located.missingIds.length ? ` (${located.missingIds.length} not found outside Trash)` : "");
1904
+
1905
+ const plan = planWrite({
1906
+ action,
1907
+ summary,
1908
+ destructive: true,
1909
+ dryRun: isFlagTrue(args.dry_run),
1910
+ confirm: isFlagTrue(args.confirm)
1911
+ });
1912
+ if (!plan.proceed) return plannedWriteResult(plan);
1913
+
1914
+ if (movable.length === 0) {
1915
+ return {
1916
+ ok: true,
1917
+ message: writeSuccessMessage(action, "nothing to move", { not_found: located.missingIds.length, unaddressable: unaddressable || undefined })
1918
+ };
1919
+ }
1920
+
1921
+ const result = runAppleScript(buildBulkTrashScript(movable, { budgetSec }), {
1922
+ timeout: MCP_CALL_DEADLINE_MS,
1923
+ appName: "Mail"
1924
+ });
1925
+ if (!result.ok) return { ok: false, message: failure(action, summary, result, []) };
1926
+
1927
+ const statuses = parseBulkTrashOutput(result.output);
1928
+ const counts = { moved: 0, stale: 0, remaining: 0, failed: 0 };
1929
+ const failures = new Set();
1930
+ for (const copy of movable) {
1931
+ const status = statuses.get(copy.id) || "skipped";
1932
+ if (status === "moved") counts.moved += 1;
1933
+ else if (status === "missing" || status === "mismatch") counts.stale += 1;
1934
+ else if (status === "skipped") counts.remaining += 1;
1935
+ else {
1936
+ counts.failed += 1;
1937
+ failures.add(status);
1938
+ }
1939
+ }
1940
+ const details = {
1941
+ moved: counts.moved,
1942
+ copies: movable.length,
1943
+ not_found: located.missingIds.length,
1944
+ stale: counts.stale || undefined,
1945
+ remaining: counts.remaining || undefined,
1946
+ failed: counts.failed || undefined,
1947
+ failure_reasons: failures.size ? [...failures].join(", ") : undefined,
1948
+ unaddressable: unaddressable || undefined
1949
+ };
1950
+ const tail = counts.remaining ? " Time budget reached; run again for the rest." : "";
1951
+ const message = writeSuccessMessage(action, `moved ${counts.moved} of ${plural(movable.length, "copy", "copies")} to Trash`, details) + tail;
1952
+ return { ok: counts.failed === 0, message };
1953
+ }
1954
+
1788
1955
  export function mailTrash(args = {}) {
1789
1956
  const action = "mail_trash";
1957
+ if (args.message_ids !== undefined && args.message_ids !== null) return mailTrashMany(args);
1790
1958
  const resolved = resolveMailMessageId({ messageId: args.message_id, filePath: args.file_path });
1791
1959
  if (resolved.error) return { ok: false, message: `${action} refused: ${resolved.error}` };
1792
1960
 
package/lib/shell.js CHANGED
@@ -22,6 +22,7 @@ import { escapeSQL } from "./validators.js";
22
22
  * @param {string} query - SQL query to execute
23
23
  * @param {object} options - Additional options
24
24
  * @param {boolean} options.json - Return JSON output (default: true)
25
+ * @param {boolean} options.readonly - Open the database read-only (default: false)
25
26
  * @param {number} options.timeout - Timeout in ms (default: 30000)
26
27
  * @param {number} options.maxBuffer - Max buffer size (default: 50MB)
27
28
  * @returns {string} Raw output from sqlite3
@@ -30,6 +31,7 @@ import { escapeSQL } from "./validators.js";
30
31
  export function safeSqlite3(dbPath, query, options = {}) {
31
32
  const {
32
33
  json = true,
34
+ readonly = false,
33
35
  timeout = 30000,
34
36
  maxBuffer = 50 * 1024 * 1024
35
37
  } = options;
@@ -52,6 +54,9 @@ export function safeSqlite3(dbPath, query, options = {}) {
52
54
  if (json) {
53
55
  args.push('-json');
54
56
  }
57
+ if (readonly) {
58
+ args.push('-readonly');
59
+ }
55
60
  args.push(dbPath);
56
61
  args.push(query.replace(/\n/g, ' ')); // Normalize whitespace
57
62
 
package/lib/writeTools.js CHANGED
@@ -137,11 +137,12 @@ export const WRITE_TOOL_DEFINITIONS = [
137
137
  },
138
138
  {
139
139
  name: "mail_trash",
140
- description: "Move an email to Trash. Destructive: requires confirm=true, otherwise the call only reports what would be trashed.",
140
+ description: "Move email to Trash. Destructive: requires confirm=true, otherwise the call only reports what would be trashed. One message: message_id or file_path. Many: message_ids (up to 500, e.g. from mail_find) moves every copy of each (INBOX, Sent, Archive...) to that account's Trash; each copy's Message-ID is re-checked before it moves. A bulk call stops starting new moves after ~35s and reports 'remaining'; run it again for the rest.",
141
141
  inputSchema: {
142
142
  type: "object",
143
143
  properties: {
144
144
  ...MESSAGE_TARGET_PROPS,
145
+ message_ids: { type: "array", items: { type: "string" }, description: "Bulk: RFC822 Message-IDs (from mail_find). Every copy outside Trash is moved. Do not combine with message_id / file_path." },
145
146
  ...CONFIRM_PROPS
146
147
  },
147
148
  required: []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "3.0.5",
3
+ "version": "3.0.6",
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",