apple-tools-mcp 1.2.0 → 2.0.0

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.
@@ -0,0 +1,396 @@
1
+ /**
2
+ * MCP write tool definitions and dispatch.
3
+ *
4
+ * Every tool here mutates user data, so each one:
5
+ * - validates its arguments (no invented recipients, ids, or dates)
6
+ * - honours `dry_run` (preview, never execute)
7
+ * - requires `confirm: true` for deletes and multi-recipient sends
8
+ * - reports what happened by id/recipient/title, never by echoing bodies
9
+ *
10
+ * Execution is routed by lib/writeRouting.js: the indexer daemon runs writes
11
+ * itself, an MCP stdio process hands privacy-gated writes to the daemon.
12
+ */
13
+
14
+ import {
15
+ mailCompose,
16
+ mailReply,
17
+ mailForward,
18
+ mailMark,
19
+ mailArchive,
20
+ mailTrash
21
+ } from "./mailWrite.js";
22
+ import { messagesSend } from "./messagesWrite.js";
23
+ import {
24
+ calendarListCalendars,
25
+ calendarAdd,
26
+ calendarEdit,
27
+ calendarRemove,
28
+ calendarRsvp,
29
+ RECURRENCE_FREQUENCIES,
30
+ RSVP_RESPONSES
31
+ } from "./calendarWrite.js";
32
+ import { contactsAdd, contactsEdit, contactsRemove } from "./contactsWrite.js";
33
+ import { planWriteRoute, planAfterDelegation, tccFallbackAdvice } from "./writeRouting.js";
34
+ import { requestWriteViaBridge, probeSocket, defaultSocketPath } from "./writeBridge.js";
35
+ import { isTccDenial } from "./appleScript.js";
36
+
37
+ const CONFIRM_PROPS = {
38
+ dry_run: { type: "boolean", description: "Preview only: report what would happen and change nothing (default false)" },
39
+ confirm: { type: "boolean", description: "Required for deletes and multi-recipient sends; without it the call only previews" }
40
+ };
41
+
42
+ const MESSAGE_TARGET_PROPS = {
43
+ message_id: { type: "string", description: "RFC822 Message-ID of the email (from mail_search / mail_read results)" },
44
+ file_path: { type: "string", description: "Alternative to message_id: the .emlx file_path from mail_search, resolved to its Message-ID" }
45
+ };
46
+
47
+ export const WRITE_TOOL_DEFINITIONS = [
48
+ // ============ MAIL WRITES ============
49
+ {
50
+ name: "mail_send",
51
+ description: "Send an email through Mail.app. Sending to more than one recipient in total (to + cc + bcc) requires confirm=true. Use dry_run=true to preview.",
52
+ inputSchema: {
53
+ type: "object",
54
+ properties: {
55
+ to: { type: "array", items: { type: "string" }, description: "Recipient email addresses (required, at least one)" },
56
+ cc: { type: "array", items: { type: "string" }, description: "CC email addresses" },
57
+ bcc: { type: "array", items: { type: "string" }, description: "BCC email addresses" },
58
+ subject: { type: "string", description: "Subject line (required)" },
59
+ body: { type: "string", description: "Message body (required)" },
60
+ body_format: { type: "string", enum: ["plain", "html"], description: "Send body as plain text (default) or HTML, where Mail supports it" },
61
+ ...CONFIRM_PROPS
62
+ },
63
+ required: ["to", "subject", "body"]
64
+ }
65
+ },
66
+ {
67
+ name: "mail_draft",
68
+ description: "Save an email to Drafts without sending it. Drafts are never delivered, so no recipient confirmation is required.",
69
+ inputSchema: {
70
+ type: "object",
71
+ properties: {
72
+ to: { type: "array", items: { type: "string" }, description: "Recipient email addresses (required, at least one)" },
73
+ cc: { type: "array", items: { type: "string" }, description: "CC email addresses" },
74
+ bcc: { type: "array", items: { type: "string" }, description: "BCC email addresses" },
75
+ subject: { type: "string", description: "Subject line" },
76
+ body: { type: "string", description: "Message body" },
77
+ body_format: { type: "string", enum: ["plain", "html"], description: "Save body as plain text (default) or HTML, where Mail supports it" },
78
+ ...CONFIRM_PROPS
79
+ },
80
+ required: ["to"]
81
+ }
82
+ },
83
+ {
84
+ name: "mail_reply",
85
+ description: "Reply to an existing email. reply_all=true fans out to every original recipient and requires confirm=true.",
86
+ inputSchema: {
87
+ type: "object",
88
+ properties: {
89
+ ...MESSAGE_TARGET_PROPS,
90
+ body: { type: "string", description: "Reply text, prepended above the quoted original (required)" },
91
+ reply_all: { type: "boolean", description: "Reply to all original recipients (default false)" },
92
+ save_as_draft: { type: "boolean", description: "Save the reply to Drafts instead of sending (default false)" },
93
+ ...CONFIRM_PROPS
94
+ },
95
+ required: ["body"]
96
+ }
97
+ },
98
+ {
99
+ name: "mail_forward",
100
+ description: "Forward an existing email to new recipients. More than one recipient requires confirm=true.",
101
+ inputSchema: {
102
+ type: "object",
103
+ properties: {
104
+ ...MESSAGE_TARGET_PROPS,
105
+ to: { type: "array", items: { type: "string" }, description: "Forward recipients (required, at least one)" },
106
+ body: { type: "string", description: "Optional note added above the forwarded message" },
107
+ save_as_draft: { type: "boolean", description: "Save the forward to Drafts instead of sending (default false)" },
108
+ ...CONFIRM_PROPS
109
+ },
110
+ required: ["to"]
111
+ }
112
+ },
113
+ {
114
+ name: "mail_mark",
115
+ description: "Mark an email as read or unread.",
116
+ inputSchema: {
117
+ type: "object",
118
+ properties: {
119
+ ...MESSAGE_TARGET_PROPS,
120
+ status: { type: "string", enum: ["read", "unread"], description: "New read state (default read)" },
121
+ ...CONFIRM_PROPS
122
+ },
123
+ required: []
124
+ }
125
+ },
126
+ {
127
+ name: "mail_archive",
128
+ description: "Move an email to its account's Archive mailbox.",
129
+ inputSchema: {
130
+ type: "object",
131
+ properties: {
132
+ ...MESSAGE_TARGET_PROPS,
133
+ ...CONFIRM_PROPS
134
+ },
135
+ required: []
136
+ }
137
+ },
138
+ {
139
+ name: "mail_trash",
140
+ description: "Move an email to Trash. Destructive: requires confirm=true, otherwise the call only reports what would be trashed.",
141
+ inputSchema: {
142
+ type: "object",
143
+ properties: {
144
+ ...MESSAGE_TARGET_PROPS,
145
+ ...CONFIRM_PROPS
146
+ },
147
+ required: []
148
+ }
149
+ },
150
+
151
+ // ============ MESSAGES WRITES ============
152
+ {
153
+ name: "messages_send",
154
+ description: "Send an iMessage or SMS. Address it with 'to' (E.164 phone numbers like +15551234567, or Apple ID emails) or with 'chat_id' (a chat GUID from chat.db, e.g. 'iMessage;-;+15551234567' or 'iMessage;+;chat123456'). Group chats and multiple recipients require confirm=true.",
155
+ inputSchema: {
156
+ type: "object",
157
+ properties: {
158
+ to: { type: "array", items: { type: "string" }, description: "Phone numbers in E.164 form or Apple ID emails" },
159
+ chat_id: { type: "string", description: "Existing chat GUID; verified against the Messages database before sending" },
160
+ text: { type: "string", description: "Message text (required unless attachment_path is given)" },
161
+ attachment_path: { type: "string", description: "Absolute path to an existing file on this Mac to attach" },
162
+ service: { type: "string", enum: ["auto", "imessage", "sms"], description: "Delivery service (default auto: iMessage, then SMS relay)" },
163
+ ...CONFIRM_PROPS
164
+ },
165
+ required: []
166
+ }
167
+ },
168
+
169
+ // ============ CALENDAR WRITES ============
170
+ {
171
+ name: "calendar_list_calendars",
172
+ description: "List the calendars in Calendar.app with their writability, so events land on the intended calendar instead of the default.",
173
+ inputSchema: {
174
+ type: "object",
175
+ properties: {}
176
+ }
177
+ },
178
+ {
179
+ name: "calendar_add",
180
+ description: "Create a calendar event. Times must be explicit local datetimes (YYYY-MM-DD HH:MM). Supports recurrence (FREQ=DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL, COUNT or UNTIL, BYDAY) and alerts in minutes before the start. Returns the new event id.",
181
+ inputSchema: {
182
+ type: "object",
183
+ properties: {
184
+ calendar_name: { type: "string", description: "Target calendar name from calendar_list_calendars (required)" },
185
+ title: { type: "string", description: "Event title (required)" },
186
+ start: { type: "string", description: "Start as YYYY-MM-DD HH:MM local time, or YYYY-MM-DD for all-day (required)" },
187
+ end: { type: "string", description: "End as YYYY-MM-DD HH:MM local time (default: start + 1 hour)" },
188
+ all_day: { type: "boolean", description: "Create an all-day event" },
189
+ location: { type: "string", description: "Event location" },
190
+ notes: { type: "string", description: "Event notes / description" },
191
+ frequency: { type: "string", enum: RECURRENCE_FREQUENCIES, description: "Recurrence frequency" },
192
+ interval: { type: "number", description: "Recurrence interval, e.g. 2 for every other week (1-366)" },
193
+ count: { type: "number", description: "Number of occurrences (1-1000); cannot be combined with until" },
194
+ until: { type: "string", description: "Repeat until this local datetime (YYYY-MM-DD HH:MM)" },
195
+ by_day: { type: "array", items: { type: "string" }, description: "Weekly by-day list: MO TU WE TH FR SA SU" },
196
+ recurrence: { type: "string", description: "Raw RRULE instead of the structured fields, e.g. FREQ=WEEKLY;INTERVAL=1;COUNT=10" },
197
+ alerts_minutes_before: { type: "array", items: { type: "number" }, description: "Display alerts in minutes before the start (max 5, up to 40320)" },
198
+ ...CONFIRM_PROPS
199
+ },
200
+ required: ["calendar_name", "title", "start"]
201
+ }
202
+ },
203
+ {
204
+ name: "calendar_edit",
205
+ description: "Update an existing event by id. Pass only the fields to change. Setting alerts_minutes_before replaces the event's existing alerts.",
206
+ inputSchema: {
207
+ type: "object",
208
+ properties: {
209
+ event_id: { type: "string", description: "Event ID from calendar_date or calendar_add (required)" },
210
+ title: { type: "string", description: "New title" },
211
+ start: { type: "string", description: "New start as YYYY-MM-DD HH:MM local time" },
212
+ end: { type: "string", description: "New end as YYYY-MM-DD HH:MM local time" },
213
+ location: { type: "string", description: "New location" },
214
+ notes: { type: "string", description: "New notes / description" },
215
+ frequency: { type: "string", enum: RECURRENCE_FREQUENCIES, description: "Recurrence frequency" },
216
+ interval: { type: "number", description: "Recurrence interval (1-366)" },
217
+ count: { type: "number", description: "Number of occurrences (1-1000)" },
218
+ until: { type: "string", description: "Repeat until this local datetime" },
219
+ by_day: { type: "array", items: { type: "string" }, description: "Weekly by-day list: MO TU WE TH FR SA SU" },
220
+ recurrence: { type: "string", description: "Raw RRULE" },
221
+ alerts_minutes_before: { type: "array", items: { type: "number" }, description: "Replacement alerts in minutes before the start" },
222
+ replace_alerts: { type: "boolean", description: "Remove existing alerts even when no replacements are given" },
223
+ ...CONFIRM_PROPS
224
+ },
225
+ required: ["event_id"]
226
+ }
227
+ },
228
+ {
229
+ name: "calendar_remove",
230
+ description: "Delete a calendar event by id. Destructive: requires confirm=true, otherwise the call only reports what would be deleted.",
231
+ inputSchema: {
232
+ type: "object",
233
+ properties: {
234
+ event_id: { type: "string", description: "Event ID from calendar_date (required)" },
235
+ ...CONFIRM_PROPS
236
+ },
237
+ required: ["event_id"]
238
+ }
239
+ },
240
+ {
241
+ name: "calendar_rsvp",
242
+ description: "Respond to a calendar invitation: accept, decline, or tentative.",
243
+ inputSchema: {
244
+ type: "object",
245
+ properties: {
246
+ event_id: { type: "string", description: "Event ID of the invitation (required)" },
247
+ response: { type: "string", enum: RSVP_RESPONSES, description: "accept, decline, or tentative (required)" },
248
+ attendee_email: { type: "string", description: "Your invited address, when the event lists several attendees" },
249
+ ...CONFIRM_PROPS
250
+ },
251
+ required: ["event_id", "response"]
252
+ }
253
+ },
254
+
255
+ // ============ CONTACTS WRITES ============
256
+ {
257
+ name: "contacts_add",
258
+ description: "Create a contact in Contacts.app. Returns the new contact id.",
259
+ inputSchema: {
260
+ type: "object",
261
+ properties: {
262
+ first_name: { type: "string", description: "First name" },
263
+ last_name: { type: "string", description: "Last name" },
264
+ organization: { type: "string", description: "Company / organization" },
265
+ job_title: { type: "string", description: "Job title" },
266
+ emails: { type: "array", items: { type: "string" }, description: "Email addresses (max 10)" },
267
+ email_label: { type: "string", description: "Label for the emails, e.g. work or home (default work)" },
268
+ phones: { type: "array", items: { type: "string" }, description: "Phone numbers (max 10)" },
269
+ phone_label: { type: "string", description: "Label for the phones, e.g. mobile or home (default mobile)" },
270
+ ...CONFIRM_PROPS
271
+ },
272
+ required: []
273
+ }
274
+ },
275
+ {
276
+ name: "contacts_edit",
277
+ description: "Update a contact by id. Emails and phones are added unless replace_emails / replace_phones is set; replacing with an empty list removes them and requires confirm=true.",
278
+ inputSchema: {
279
+ type: "object",
280
+ properties: {
281
+ contact_id: { type: "string", description: "Contact ID from contacts_search or contacts_lookup (required)" },
282
+ first_name: { type: "string", description: "New first name" },
283
+ last_name: { type: "string", description: "New last name" },
284
+ organization: { type: "string", description: "New organization" },
285
+ job_title: { type: "string", description: "New job title" },
286
+ emails: { type: "array", items: { type: "string" }, description: "Email addresses to add (or to replace with)" },
287
+ email_label: { type: "string", description: "Label for those emails (default work)" },
288
+ phones: { type: "array", items: { type: "string" }, description: "Phone numbers to add (or to replace with)" },
289
+ phone_label: { type: "string", description: "Label for those phones (default mobile)" },
290
+ replace_emails: { type: "boolean", description: "Remove existing emails before adding" },
291
+ replace_phones: { type: "boolean", description: "Remove existing phones before adding" },
292
+ ...CONFIRM_PROPS
293
+ },
294
+ required: ["contact_id"]
295
+ }
296
+ },
297
+ {
298
+ name: "contacts_remove",
299
+ description: "Delete a contact by id. Destructive: requires confirm=true, otherwise the call only reports what would be deleted.",
300
+ inputSchema: {
301
+ type: "object",
302
+ properties: {
303
+ contact_id: { type: "string", description: "Contact ID from contacts_search or contacts_lookup (required)" },
304
+ ...CONFIRM_PROPS
305
+ },
306
+ required: ["contact_id"]
307
+ }
308
+ }
309
+ ];
310
+
311
+ export const WRITE_TOOL_HANDLERS = {
312
+ mail_send: (args) => mailCompose(args, { draft: false }),
313
+ mail_draft: (args) => mailCompose(args, { draft: true }),
314
+ mail_reply: mailReply,
315
+ mail_forward: mailForward,
316
+ mail_mark: mailMark,
317
+ mail_archive: mailArchive,
318
+ mail_trash: mailTrash,
319
+ messages_send: messagesSend,
320
+ calendar_list_calendars: () => calendarListCalendars(),
321
+ calendar_add: calendarAdd,
322
+ calendar_edit: calendarEdit,
323
+ calendar_remove: calendarRemove,
324
+ calendar_rsvp: calendarRsvp,
325
+ contacts_add: contactsAdd,
326
+ contacts_edit: contactsEdit,
327
+ contacts_remove: contactsRemove
328
+ };
329
+
330
+ export const WRITE_TOOL_NAMES = Object.keys(WRITE_TOOL_HANDLERS);
331
+
332
+ export function isWriteTool(name) {
333
+ return Object.prototype.hasOwnProperty.call(WRITE_TOOL_HANDLERS, name);
334
+ }
335
+
336
+ /**
337
+ * Run a write in this process.
338
+ * @returns {{ ok: boolean, message: string }}
339
+ */
340
+ export function executeWriteToolLocally(name, args = {}) {
341
+ const handler = WRITE_TOOL_HANDLERS[name];
342
+ if (!handler) {
343
+ return { ok: false, message: `Unknown write tool: ${name}`, unsupported: true };
344
+ }
345
+ try {
346
+ return handler(args || {});
347
+ } catch (e) {
348
+ return { ok: false, message: `${name} failed: ${e.message}` };
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Run a write, delegating privacy-gated work to the indexer daemon when it is
354
+ * listening. Falls back to local execution whenever the bridge is unusable.
355
+ *
356
+ * @param {string} name
357
+ * @param {object} args
358
+ * @param {object} [deps] - injected for tests
359
+ * @returns {Promise<{ ok: boolean, message: string }>}
360
+ */
361
+ export async function dispatchWriteTool(name, args = {}, deps = {}) {
362
+ const {
363
+ indexerMode = false,
364
+ socketPath = defaultSocketPath(),
365
+ probe = probeSocket,
366
+ request = requestWriteViaBridge,
367
+ runLocally = executeWriteToolLocally,
368
+ log = () => {}
369
+ } = deps;
370
+
371
+ if (!isWriteTool(name)) {
372
+ return { ok: false, message: `Unknown write tool: ${name}` };
373
+ }
374
+
375
+ let bridgeAvailable = false;
376
+ if (!indexerMode) {
377
+ bridgeAvailable = await probe(socketPath);
378
+ }
379
+
380
+ const route = planWriteRoute({ indexerMode, bridgeAvailable, toolName: name });
381
+
382
+ if (route.target === "daemon") {
383
+ const { delivered, response, error } = await request({ socketPath, tool: name, args });
384
+ const { fallbackLocal } = planAfterDelegation({ delivered, response });
385
+ if (!fallbackLocal) {
386
+ return response;
387
+ }
388
+ log(`Write bridge unavailable for ${name} (${error || "no response"}); running locally.`);
389
+ }
390
+
391
+ const result = runLocally(name, args);
392
+ if (result && result.ok === false && isTccDenial(result.message)) {
393
+ return { ...result, message: `${result.message} ${tccFallbackAdvice({ bridgeAvailable })}` };
394
+ }
395
+ return result;
396
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "1.2.0",
4
- "description": "MCP server for semantic search across Apple Mail, Messages, and Calendar",
3
+ "version": "2.0.0",
4
+ "description": "MCP server for semantic search and write actions across Apple Mail, Messages, Calendar, and Contacts",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "bin": {
@@ -46,6 +46,7 @@
46
46
  "contacts.js",
47
47
  "lib/",
48
48
  "scripts/audit-index.js",
49
+ "scripts/smoke-writes.js",
49
50
  "README.md",
50
51
  "LICENSE"
51
52
  ],
@@ -54,6 +55,7 @@
54
55
  "indexer": "node index.js --mode=indexer",
55
56
  "build-index": "node -e \"import('./indexer.js').then(i=>i.rebuildIndex()).catch(e=>{console.error(e.message);process.exit(1)})\"",
56
57
  "audit": "node scripts/audit-index.js",
58
+ "smoke:writes": "node scripts/smoke-writes.js",
57
59
  "test": "vitest run",
58
60
  "test:watch": "vitest",
59
61
  "test:unit": "vitest run tests/unit",