apple-tools-mcp 2.0.0 → 2.0.1

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.
@@ -2,13 +2,21 @@
2
2
  /**
3
3
  * Write-tool smoke test for QA prove-out on a Node host (Mac Mini / LaunchAgent).
4
4
  *
5
- * Covers both privacy classes that gate this package's writes:
5
+ * Covers the Automation / privacy classes that gate this package's writes:
6
+ * - Mail compose (Automation → Mail.app). dry_run never talks to Mail, so
7
+ * this step runs a real `make new outgoing message` (then discards it).
8
+ * A hang or timeout is TCC / Automation denied, not "Mail.app missing".
9
+ * - Messages account/service lookup (Automation → Messages.app). Nothing
10
+ * is sent. A deny fails --apply the same way Mail does.
6
11
  * - Contacts CRUD (AddressBook class, via Contacts.app)
7
12
  * - Calendar CRUD (calendars class, via Calendar.app)
8
13
  *
9
- * Both must pass on the Node host, which is the ship gate. Both are expected
10
- * to fail under a host app that holds neither entitlement - that is a
11
- * documented host limitation, not a package failure.
14
+ * Mail, Messages, Contacts, and Calendar must all pass on the Node host —
15
+ * that is the ship gate. A missing grant for any one of those four apps
16
+ * fails --apply. Contacts and Calendar CRUD are expected to fail under a
17
+ * host app that holds neither entitlement - that is a documented host
18
+ * limitation, not a package failure. Mail and Messages are separate Apple
19
+ * Events targets: a Contacts/Calendar grant does not include them.
12
20
  *
13
21
  * Default is a dry run: no contact or event is created, edited, or deleted.
14
22
  * It is not a no-op, though: it reads contacts from the AddressBook database
@@ -32,6 +40,8 @@
32
40
  import { loadContacts, getContactStats } from "../contacts.js";
33
41
  import { probeSocket, defaultSocketPath } from "../lib/writeBridge.js";
34
42
  import { dispatchWriteTool } from "../lib/writeTools.js";
43
+ import { probeMailAutomation } from "../lib/mailWrite.js";
44
+ import { probeMessagesAutomation } from "../lib/messagesWrite.js";
35
45
  import { isIndexerMode } from "../lib/processMode.js";
36
46
 
37
47
  export function parseSmokeArgs(argv = []) {
@@ -92,6 +102,37 @@ export function extractEventId(message) {
92
102
  return match ? match[1] : null;
93
103
  }
94
104
 
105
+ export function extractEventKitId(message) {
106
+ const match = String(message || "").match(/eventkit_id:\s*(\S+)/);
107
+ if (!match) return null;
108
+ return match[1].replace(/[.,;]+$/, "");
109
+ }
110
+
111
+ export function createdViaEventKit(message) {
112
+ const text = String(message || "");
113
+ return /via:\s*EventKit\b/i.test(text) && Boolean(extractEventKitId(text));
114
+ }
115
+
116
+ /**
117
+ * Prefer an On My Mac calendar so Calendar.app delete is reliable if EventKit
118
+ * remove still misses. EventKit add/remove is the primary path on iCloud
119
+ * (writeOnly can delete events it created). An explicit --calendar= wins.
120
+ */
121
+ export function pickSmokeCalendar(calendars, explicitName) {
122
+ if (explicitName) {
123
+ return { name: explicitName, reason: `--calendar=${explicitName}` };
124
+ }
125
+ const writable = (calendars || []).filter((c) => c.writable);
126
+ const local = writable.filter((c) => c.local);
127
+ if (local[0]) {
128
+ return { name: local[0].name, reason: "On My Mac (EventKit sourceType local)" };
129
+ }
130
+ if (writable[0]) {
131
+ return { name: writable[0].name, reason: "first writable calendar (no local calendar listed)" };
132
+ }
133
+ return { name: "Calendar", reason: "fallback name Calendar" };
134
+ }
135
+
95
136
  /**
96
137
  * Listing calendars is a live Calendar.app call even during a dry run, so a
97
138
  * refusal there says something about this host - but it is not a failure of
@@ -104,6 +145,67 @@ export function calendarListSeverity(apply) {
104
145
  return apply ? "error" : "warning";
105
146
  }
106
147
 
148
+ /**
149
+ * Mail compose is a live Apple Events call (dry_run of mail_send never
150
+ * touches Mail). On --apply a deny fails the ship gate. On a dry run it is
151
+ * advisory, like calendar_list_calendars, so a refused prompt is WARN.
152
+ *
153
+ * @returns {"error"|"warning"}
154
+ */
155
+ export function mailProbeSeverity(apply) {
156
+ return apply ? "error" : "warning";
157
+ }
158
+
159
+ export function messagesProbeSeverity(apply) {
160
+ return mailProbeSeverity(apply);
161
+ }
162
+
163
+ /**
164
+ * How smoke exercises Mail Apple Events.
165
+ *
166
+ * The compose-and-discard helper is smoke-script-only — not an MCP write
167
+ * tool. On --apply with the write bridge up, also run public mail_draft so
168
+ * launchd-owned node is fail-closed for Mail (same make new outgoing message
169
+ * verb; nothing is sent).
170
+ *
171
+ * @returns {{ useLocalHelper: boolean, useMailDraft: boolean, reason: string }}
172
+ */
173
+ export function planMailSmokeTouch({ apply, daemonPath }) {
174
+ if (apply && daemonPath) {
175
+ return {
176
+ useLocalHelper: true,
177
+ useMailDraft: true,
178
+ reason: "local compose-and-discard helper plus mail_draft via the write bridge (live Mail Apple Events; nothing is sent)"
179
+ };
180
+ }
181
+ return {
182
+ useLocalHelper: true,
183
+ useMailDraft: false,
184
+ reason: "smoke-script compose-and-discard helper (live Mail Apple Events; nothing is sent). mail_send dry_run never touches Mail."
185
+ };
186
+ }
187
+
188
+ /**
189
+ * How smoke exercises Messages Apple Events.
190
+ * Smoke-script-only helper; this gate never sends and is not an MCP tool.
191
+ *
192
+ * @returns {{ useLocalHelper: boolean, reason: string }}
193
+ */
194
+ export function planMessagesSmokeTouch() {
195
+ return {
196
+ useLocalHelper: true,
197
+ reason: "smoke-script Messages account lookup (live Apple Events; nothing is sent). messages_send dry_run never touches Messages."
198
+ };
199
+ }
200
+
201
+ export function mailDraftSmokeArgs(stamp) {
202
+ return {
203
+ to: [`atm-mail-probe-${stamp}@example.com`],
204
+ subject: `ATM Mail Automation probe ${stamp}`,
205
+ body: "Created by apple-tools-mcp smoke test; safe to delete from Drafts."
206
+ };
207
+ }
208
+
107
209
  /**
108
210
  * A start/end pair well in the future, so a smoke event never collides with
109
211
  * anything real on the calendar.
@@ -146,7 +248,7 @@ async function main() {
146
248
  console.log("apple-tools-mcp write smoke test");
147
249
  console.log("=".repeat(60));
148
250
  line("Mode:", apply
149
- ? "APPLY (will change Contacts and Calendar)"
251
+ ? "APPLY (will change Contacts and Calendar; Mail compose is live, nothing is sent)"
150
252
  : "DRY RUN (no creates, edits, or deletes)");
151
253
  line("Process:", indexerMode ? "indexer daemon" : "plain node / stdio");
152
254
  line("Write bridge:", bridgeUp ? `listening at ${socketPath}` : `not listening (${socketPath})`);
@@ -162,13 +264,18 @@ async function main() {
162
264
  "\nTCC note: macOS attributes this work to the process responsible for it.\n" +
163
265
  "Contact reads use sqlite + Full Disk Access; the CRUD steps use Contacts.app\n" +
164
266
  "and Calendar.app, gated by the AddressBook and calendars privacy classes.\n" +
267
+ "Mail compose is a separate Automation target (node → Mail.app). A hang or\n" +
268
+ "timeout there is TCC / Automation denied, not Mail.app missing.\n" +
165
269
  `Writes take the same route production MCP clients take: ${route.reason}.`
166
270
  );
167
271
  if (!apply) {
168
272
  console.log(
169
- "Dry run: nothing is created, edited, or deleted. calendar_list_calendars\n" +
273
+ "Dry run: no creates, edits, or deletes for Contacts/Calendar. calendar_list_calendars\n" +
170
274
  "still runs for real - it is a live Calendar.app query and a TCC touch -\n" +
171
- "so a denial there is reported as a warning, not a failure."
275
+ "so a denial there is reported as a warning, not a failure.\n" +
276
+ "The Mail step also runs for real (make new outgoing message) because mail_send\n" +
277
+ "dry_run never touches Mail. A Mail deny is TCC / Automation denied; on a dry\n" +
278
+ "run it is a warning, on --apply it fails the ship gate."
172
279
  );
173
280
  }
174
281
 
@@ -192,6 +299,42 @@ async function main() {
192
299
  const common = apply ? { confirm: true } : { dry_run: true };
193
300
  const results = [];
194
301
 
302
+ // Mail first so first-run Allow includes Mail in the same pass as Contacts/Calendar.
303
+ // Helpers are smoke-script-only — not MCP write tools. mail_send dry_run never talks to Mail.
304
+ console.log("\n--- Mail Automation (Mail.app compose / Apple Events) ---");
305
+ if (!apply) {
306
+ console.log(" (live make new outgoing message even on a dry run; mail_send dry_run never touches Mail)");
307
+ }
308
+
309
+ const mailPlan = planMailSmokeTouch({ apply, daemonPath: route.path === "daemon" });
310
+ const mailSeverity = mailProbeSeverity(apply);
311
+ const mailHelperResult = probeMailAutomation();
312
+ if (mailHelperResult.ok === false && mailSeverity === "warning") {
313
+ step("mail Automation compose", mailHelperResult, "warning");
314
+ console.log(" Warning only: mail_send dry_run never touches Mail. --apply fails closed if Mail Automation is still denied.");
315
+ } else {
316
+ results.push(step("mail Automation compose", mailHelperResult, mailSeverity));
317
+ }
318
+ if (mailPlan.useMailDraft) {
319
+ console.log(` ${mailPlan.reason}`);
320
+ results.push(step("mail_draft (daemon Mail Automation)", await run("mail_draft", mailDraftSmokeArgs(stamp))));
321
+ }
322
+
323
+ console.log("\n--- Messages Automation (Messages.app accounts / Apple Events) ---");
324
+ if (!apply) {
325
+ console.log(" (live Messages account lookup even on a dry run; messages_send dry_run never touches Messages; nothing is sent)");
326
+ }
327
+
328
+ const messagesSeverity = messagesProbeSeverity(apply);
329
+ const messagesResult = probeMessagesAutomation();
330
+
331
+ if (messagesResult.ok === false && messagesSeverity === "warning") {
332
+ step("messages Automation lookup", messagesResult, "warning");
333
+ console.log(" Warning only: messages_send dry_run never touches Messages. --apply fails closed if Messages Automation is still denied.");
334
+ } else {
335
+ results.push(step("messages Automation lookup", messagesResult, messagesSeverity));
336
+ }
337
+
195
338
  console.log("\n--- Contacts CRUD (Contacts.app / AddressBook privacy class) ---");
196
339
  const created = step("contacts_add", await run("contacts_add", {
197
340
  first_name: "ATM Smoke",
@@ -240,11 +383,12 @@ async function main() {
240
383
  }
241
384
 
242
385
  const writable = (calendars.calendars || []).filter((c) => c.writable);
243
- const targetCalendar = calendar || (writable[0] && writable[0].name) || "Calendar";
386
+ const picked = pickSmokeCalendar(calendars.calendars || [], calendar);
387
+ const targetCalendar = picked.name;
244
388
  if (calendar && writable.length > 0 && !writable.some((c) => c.name === calendar)) {
245
389
  console.log(` Warning: --calendar=${calendar} is not in the writable list; trying it anyway.`);
246
390
  }
247
- line(" Target calendar:", targetCalendar);
391
+ line(" Target calendar:", `${targetCalendar} (${picked.reason})`);
248
392
 
249
393
  const window = smokeEventWindow();
250
394
  const eventCreated = step("calendar_add", await run("calendar_add", {
@@ -260,12 +404,19 @@ async function main() {
260
404
 
261
405
  if (eventCreated.ok !== false) {
262
406
  const eventId = apply ? extractEventId(eventCreated.message) : "ATM-SMOKE-EVENT-UID";
263
- if (apply && !eventId) {
407
+ const extractedKitId = apply ? extractEventKitId(eventCreated.message) : "EK-SMOKE";
408
+ const eventKitId = extractedKitId || (eventId && eventId.includes(":") ? eventId : null);
409
+ if (apply && !createdViaEventKit(eventCreated.message)) {
410
+ console.log(" calendar_add did not print via: EventKit and eventkit_id.");
411
+ console.log(" Ship-gate refuses a Calendar.app-only create: writeOnly EventKit cannot delete those events, and Calendar.app delete hangs.");
412
+ results.push({ ok: false, message: "calendar_add ship-gate requires via: EventKit and eventkit_id" });
413
+ } else if (apply && !eventId) {
264
414
  console.log(" calendar_add reported success but returned no event id; skipping edit/delete.");
265
415
  results.push({ ok: false });
266
416
  } else {
267
417
  results.push(step("calendar_edit", await run("calendar_edit", {
268
418
  event_id: eventId,
419
+ eventkit_id: eventKitId || undefined,
269
420
  title: `ATM smoke test ${stamp} (edited)`,
270
421
  ...common
271
422
  })));
@@ -273,10 +424,25 @@ async function main() {
273
424
  if (apply && keep) {
274
425
  console.log(` Left event ${eventId} in place (--keep). Delete it when you are done.`);
275
426
  } else {
276
- const removed = step("calendar_remove", await run("calendar_remove", { event_id: eventId, ...common }));
427
+ const removed = step("calendar_remove", await run("calendar_remove", {
428
+ event_id: eventId,
429
+ eventkit_id: eventKitId || undefined,
430
+ calendar_name: targetCalendar,
431
+ ...common
432
+ }));
277
433
  results.push(removed);
278
434
  if (apply && removed.ok === false) {
279
- console.log(` Created ${eventId} but could not delete it. Remove it in Calendar.app.`);
435
+ console.log(` Created ${eventId} on ${targetCalendar} but could not delete it.`);
436
+ console.log(" add/edit succeeded, so this is a delete-path failure, not missing Calendar Automation.");
437
+ console.log(" Same write-bridge RPC as add/edit; Calendar.app has no remove/move-to-trash (delete + EventKit fallback).");
438
+ if (removed.diagnostics) {
439
+ console.log(` ${removed.diagnostics}`);
440
+ } else {
441
+ const rawLine = String(removed.message || "").match(/osascript kind=\S+ error=.*/);
442
+ if (rawLine) console.log(` ${rawLine[0]}`);
443
+ }
444
+ console.log(" Paste the osascript kind=/error= line if asking for another tip.");
445
+ console.log(" Remove the leftover event in Calendar.app.");
280
446
  }
281
447
  }
282
448
  }
@@ -293,14 +459,16 @@ async function main() {
293
459
  } else {
294
460
  console.log("These writes ran inside the indexer daemon, so this is a real gate failure:");
295
461
  console.log("grant the daemon's node binary Full Disk Access (reads) and Allow node in");
296
- console.log("System Settings > Privacy & Security > Automation for Contacts.app and");
297
- console.log("Calendar.app. Do not add node via + in the Contacts or Calendars privacy lists.");
462
+ console.log("System Settings > Privacy & Security > Automation for Mail.app, Messages.app,");
463
+ console.log("Contacts.app, and Calendar.app. A hang or timeout on Mail compose is TCC /");
464
+ console.log("Automation denied, not Mail.app missing. Do not add node via + in the");
465
+ console.log("Contacts or Calendars privacy lists.");
298
466
  }
299
467
  process.exitCode = 1;
300
468
  } else if (apply) {
301
- console.log(`Result: PASS - Contacts and Calendar CRUD both work (${route.path === "daemon" ? "via the write bridge" : "in this process"}).`);
469
+ console.log(`Result: PASS - Mail, Messages, Contacts, and Calendar Automation all work (${route.path === "daemon" ? "via the write bridge" : "in this process"}).`);
302
470
  } else {
303
- console.log("Result: PASS - dry run only. Re-run with --apply to prove real CRUD.");
471
+ console.log("Result: PASS - dry run only. Re-run with --apply to prove real CRUD and fail-closed Mail/Messages/Contacts/Calendar Automation.");
304
472
  }
305
473
  }
306
474