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.
- package/README.md +31 -14
- package/index.js +4 -0
- package/lib/appleScript.js +129 -5
- package/lib/calendarWrite.js +796 -23
- package/lib/contactsWrite.js +1 -1
- package/lib/eventKitSession.js +369 -0
- package/lib/mailWrite.js +39 -3
- package/lib/messagesWrite.js +35 -3
- package/lib/shell.js +23 -5
- package/lib/writeGuards.js +8 -0
- package/lib/writeRouting.js +2 -2
- package/lib/writeTools.js +18 -3
- package/package.json +1 -1
- package/scripts/smoke-writes.js +184 -16
package/scripts/smoke-writes.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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:
|
|
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
|
|
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
|
-
|
|
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", {
|
|
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
|
|
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
|
|
297
|
-
console.log("Calendar.app.
|
|
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
|
|
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
|
|