apple-tools-mcp 1.2.1 → 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.
- package/README.md +261 -7
- package/contacts.js +71 -19
- package/index.js +80 -4
- package/indexer.js +7 -2
- package/lib/appleScript.js +292 -0
- package/lib/calendarWrite.js +594 -0
- package/lib/contactsWrite.js +300 -0
- package/lib/mailWrite.js +464 -0
- package/lib/messagesWrite.js +281 -0
- package/lib/writeBridge.js +214 -0
- package/lib/writeGuards.js +250 -0
- package/lib/writeRouting.js +69 -0
- package/lib/writeTools.js +396 -0
- package/package.json +4 -2
- package/scripts/smoke-writes.js +313 -0
- package/search.js +3 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Write-tool smoke test for QA prove-out on a Node host (Mac Mini / LaunchAgent).
|
|
4
|
+
*
|
|
5
|
+
* Covers both privacy classes that gate this package's writes:
|
|
6
|
+
* - Contacts CRUD (AddressBook class, via Contacts.app)
|
|
7
|
+
* - Calendar CRUD (calendars class, via Calendar.app)
|
|
8
|
+
*
|
|
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.
|
|
12
|
+
*
|
|
13
|
+
* Default is a dry run: no contact or event is created, edited, or deleted.
|
|
14
|
+
* It is not a no-op, though: it reads contacts from the AddressBook database
|
|
15
|
+
* and calls calendar_list_calendars, which is a live Calendar.app query and
|
|
16
|
+
* therefore a real TCC touch that can prompt or be denied. A dry run treats
|
|
17
|
+
* that listing as advisory, so a denial does not report a failure for a run
|
|
18
|
+
* that changed nothing.
|
|
19
|
+
*
|
|
20
|
+
* Pass --apply to perform real create -> edit -> delete round trips.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* node scripts/smoke-writes.js # dry run, no changes
|
|
24
|
+
* node scripts/smoke-writes.js --apply # real CRUD, cleans up
|
|
25
|
+
* node scripts/smoke-writes.js --apply --keep # leaves the test items
|
|
26
|
+
* node scripts/smoke-writes.js --apply --calendar=Work
|
|
27
|
+
*
|
|
28
|
+
* Test items are clearly named and removed again unless --keep is set.
|
|
29
|
+
* No credentials, tokens, or personal data are involved.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { loadContacts, getContactStats } from "../contacts.js";
|
|
33
|
+
import { probeSocket, defaultSocketPath } from "../lib/writeBridge.js";
|
|
34
|
+
import { dispatchWriteTool } from "../lib/writeTools.js";
|
|
35
|
+
import { isIndexerMode } from "../lib/processMode.js";
|
|
36
|
+
|
|
37
|
+
export function parseSmokeArgs(argv = []) {
|
|
38
|
+
const calendarArg = argv.find((a) => a.startsWith("--calendar="));
|
|
39
|
+
return {
|
|
40
|
+
apply: argv.includes("--apply"),
|
|
41
|
+
keep: argv.includes("--keep"),
|
|
42
|
+
allowLocal: argv.includes("--allow-local"),
|
|
43
|
+
calendar: calendarArg ? calendarArg.slice("--calendar=".length) : null
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Ship-gate precondition.
|
|
49
|
+
*
|
|
50
|
+
* The smoke test must exercise the same path production MCP clients use, so
|
|
51
|
+
* a real run needs either the write bridge (writes execute inside the
|
|
52
|
+
* launchd-started daemon, where node is the responsible process) or an
|
|
53
|
+
* explicit acknowledgement that this process itself is the responsible one
|
|
54
|
+
* - which is only true when a parent like Terminal.app or launchd started
|
|
55
|
+
* it. Running in-process under a foreign parent (an embedded agent shell,
|
|
56
|
+
* for example) produces an AppleScript denial that says nothing about
|
|
57
|
+
* whether the package works.
|
|
58
|
+
*
|
|
59
|
+
* @returns {{ proceed: boolean, path: "daemon"|"local", reason: string }}
|
|
60
|
+
*/
|
|
61
|
+
export function resolveSmokePath({ apply, bridgeUp, allowLocal, indexerMode }) {
|
|
62
|
+
if (bridgeUp) {
|
|
63
|
+
return { proceed: true, path: "daemon", reason: "write bridge is listening; writes execute in the indexer daemon" };
|
|
64
|
+
}
|
|
65
|
+
if (indexerMode) {
|
|
66
|
+
return { proceed: true, path: "local", reason: "this process is the indexer daemon" };
|
|
67
|
+
}
|
|
68
|
+
if (!apply) {
|
|
69
|
+
return { proceed: true, path: "local", reason: "dry run; nothing is executed" };
|
|
70
|
+
}
|
|
71
|
+
if (allowLocal) {
|
|
72
|
+
return { proceed: true, path: "local", reason: "--allow-local given; this process must be the responsible one" };
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
proceed: false,
|
|
76
|
+
path: "local",
|
|
77
|
+
reason:
|
|
78
|
+
"no write bridge at the socket below and --apply was requested.\n" +
|
|
79
|
+
" The ship gate runs writes under launchd-owned node. Either:\n" +
|
|
80
|
+
" 1. start the indexer LaunchAgent (apple-tools-indexer) so writes route through the bridge, or\n" +
|
|
81
|
+
" 2. run this from Terminal.app, where node is the responsible process, and pass --allow-local.\n" +
|
|
82
|
+
" Executing in-process under another parent would only prove that parent's Automation rights,\n" +
|
|
83
|
+
" so this is refused rather than reported as a package failure."
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Pull the "event_id: <id>" value out of a write tool success message.
|
|
89
|
+
*/
|
|
90
|
+
export function extractEventId(message) {
|
|
91
|
+
const match = String(message || "").match(/event_id:\s*([A-Za-z0-9._:@+-]+)/);
|
|
92
|
+
return match ? match[1] : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Listing calendars is a live Calendar.app call even during a dry run, so a
|
|
97
|
+
* refusal there says something about this host - but it is not a failure of
|
|
98
|
+
* a run that created nothing. Only --apply, which must actually write,
|
|
99
|
+
* treats it as fatal.
|
|
100
|
+
*
|
|
101
|
+
* @returns {"error"|"warning"}
|
|
102
|
+
*/
|
|
103
|
+
export function calendarListSeverity(apply) {
|
|
104
|
+
return apply ? "error" : "warning";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A start/end pair well in the future, so a smoke event never collides with
|
|
109
|
+
* anything real on the calendar.
|
|
110
|
+
*/
|
|
111
|
+
export function smokeEventWindow(now = new Date()) {
|
|
112
|
+
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 400, 3, 0, 0, 0);
|
|
113
|
+
const pad = (v) => String(v).padStart(2, "0");
|
|
114
|
+
const fmt = (d, hour) =>
|
|
115
|
+
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(hour)}:00`;
|
|
116
|
+
return { start: fmt(start, 3), end: fmt(start, 4) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Pull the "contact_id: <id>" value out of a write tool success message.
|
|
121
|
+
*/
|
|
122
|
+
export function extractContactId(message) {
|
|
123
|
+
const match = String(message || "").match(/contact_id:\s*([A-Za-z0-9._:-]+)/);
|
|
124
|
+
return match ? match[1] : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function line(label, value) {
|
|
128
|
+
console.log(`${label.padEnd(22)} ${value}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function step(name, result, severity = "error") {
|
|
132
|
+
const failed = result.ok === false;
|
|
133
|
+
const status = failed ? (severity === "warning" ? "WARN" : "FAIL") : result.planned ? "PLANNED" : "OK";
|
|
134
|
+
console.log(`\n[${status}] ${name}`);
|
|
135
|
+
console.log(` ${result.message}`);
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function main() {
|
|
140
|
+
const { apply, keep, calendar, allowLocal } = parseSmokeArgs(process.argv.slice(2));
|
|
141
|
+
const socketPath = defaultSocketPath();
|
|
142
|
+
const bridgeUp = await probeSocket(socketPath);
|
|
143
|
+
const indexerMode = isIndexerMode();
|
|
144
|
+
const route = resolveSmokePath({ apply, bridgeUp, allowLocal, indexerMode });
|
|
145
|
+
|
|
146
|
+
console.log("apple-tools-mcp write smoke test");
|
|
147
|
+
console.log("=".repeat(60));
|
|
148
|
+
line("Mode:", apply
|
|
149
|
+
? "APPLY (will change Contacts and Calendar)"
|
|
150
|
+
: "DRY RUN (no creates, edits, or deletes)");
|
|
151
|
+
line("Process:", indexerMode ? "indexer daemon" : "plain node / stdio");
|
|
152
|
+
line("Write bridge:", bridgeUp ? `listening at ${socketPath}` : `not listening (${socketPath})`);
|
|
153
|
+
line("Write path:", route.path === "daemon" ? "indexer daemon via write bridge" : "in this process");
|
|
154
|
+
|
|
155
|
+
if (!route.proceed) {
|
|
156
|
+
console.log(`\nRefusing to run: ${route.reason}`);
|
|
157
|
+
process.exitCode = 2;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
console.log(
|
|
162
|
+
"\nTCC note: macOS attributes this work to the process responsible for it.\n" +
|
|
163
|
+
"Contact reads use sqlite + Full Disk Access; the CRUD steps use Contacts.app\n" +
|
|
164
|
+
"and Calendar.app, gated by the AddressBook and calendars privacy classes.\n" +
|
|
165
|
+
`Writes take the same route production MCP clients take: ${route.reason}.`
|
|
166
|
+
);
|
|
167
|
+
if (!apply) {
|
|
168
|
+
console.log(
|
|
169
|
+
"Dry run: nothing is created, edited, or deleted. calendar_list_calendars\n" +
|
|
170
|
+
"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."
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Every write goes through the production dispatcher, so the smoke test
|
|
176
|
+
// proves the path clients actually use rather than a private shortcut.
|
|
177
|
+
const run = (tool, args) => dispatchWriteTool(tool, args, { indexerMode, socketPath });
|
|
178
|
+
|
|
179
|
+
// Read side first: proves Full Disk Access independently of the write path.
|
|
180
|
+
console.log("\n--- Contacts read (sqlite + Full Disk Access) ---");
|
|
181
|
+
const contacts = loadContacts();
|
|
182
|
+
if (contacts.length === 0) {
|
|
183
|
+
console.log(" No contacts loaded. If you expected some, this is a Full Disk Access /");
|
|
184
|
+
console.log(" attribution problem on the responsible process, not the write entitlement.");
|
|
185
|
+
} else {
|
|
186
|
+
const stats = getContactStats();
|
|
187
|
+
line(" Contacts loaded:", String(stats.total));
|
|
188
|
+
line(" With unique ids:", String(contacts.filter((c) => c.uniqueId).length));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const stamp = new Date().toISOString().replace(/[^0-9]/g, "").slice(0, 14);
|
|
192
|
+
const common = apply ? { confirm: true } : { dry_run: true };
|
|
193
|
+
const results = [];
|
|
194
|
+
|
|
195
|
+
console.log("\n--- Contacts CRUD (Contacts.app / AddressBook privacy class) ---");
|
|
196
|
+
const created = step("contacts_add", await run("contacts_add", {
|
|
197
|
+
first_name: "ATM Smoke",
|
|
198
|
+
last_name: `Test ${stamp}`,
|
|
199
|
+
organization: "apple-tools-mcp smoke test",
|
|
200
|
+
emails: [`atm-smoke-${stamp}@example.com`],
|
|
201
|
+
...common
|
|
202
|
+
}));
|
|
203
|
+
results.push(created);
|
|
204
|
+
|
|
205
|
+
if (created.ok !== false) {
|
|
206
|
+
const contactId = apply ? extractContactId(created.message) : "ABCD1234-SMOKE:ABPerson";
|
|
207
|
+
if (apply && !contactId) {
|
|
208
|
+
console.log(" contacts_add reported success but returned no contact id; skipping edit/delete.");
|
|
209
|
+
results.push({ ok: false });
|
|
210
|
+
} else {
|
|
211
|
+
results.push(step("contacts_edit", await run("contacts_edit", {
|
|
212
|
+
contact_id: contactId,
|
|
213
|
+
job_title: "Smoke Tested",
|
|
214
|
+
...common
|
|
215
|
+
})));
|
|
216
|
+
|
|
217
|
+
if (apply && keep) {
|
|
218
|
+
console.log(` Left contact ${contactId} in place (--keep). Delete it when you are done.`);
|
|
219
|
+
} else {
|
|
220
|
+
const removed = step("contacts_remove", await run("contacts_remove", { contact_id: contactId, ...common }));
|
|
221
|
+
results.push(removed);
|
|
222
|
+
if (apply && removed.ok === false) {
|
|
223
|
+
console.log(` Created ${contactId} but could not delete it. Remove it in Contacts.app.`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- Calendar: the other entitlement-gated write path ----
|
|
230
|
+
console.log("\n--- Calendar CRUD (Calendar.app / calendars privacy class) ---");
|
|
231
|
+
if (!apply) {
|
|
232
|
+
console.log(" (live Calendar.app query even on a dry run)");
|
|
233
|
+
}
|
|
234
|
+
const listSeverity = calendarListSeverity(apply);
|
|
235
|
+
const calendars = step("calendar_list_calendars", await run("calendar_list_calendars", {}), listSeverity);
|
|
236
|
+
if (calendars.ok === false && listSeverity === "warning") {
|
|
237
|
+
console.log(" Warning only: the dry run changed nothing, so a refused listing is not a failure.");
|
|
238
|
+
} else {
|
|
239
|
+
results.push(calendars);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const writable = (calendars.calendars || []).filter((c) => c.writable);
|
|
243
|
+
const targetCalendar = calendar || (writable[0] && writable[0].name) || "Calendar";
|
|
244
|
+
if (calendar && writable.length > 0 && !writable.some((c) => c.name === calendar)) {
|
|
245
|
+
console.log(` Warning: --calendar=${calendar} is not in the writable list; trying it anyway.`);
|
|
246
|
+
}
|
|
247
|
+
line(" Target calendar:", targetCalendar);
|
|
248
|
+
|
|
249
|
+
const window = smokeEventWindow();
|
|
250
|
+
const eventCreated = step("calendar_add", await run("calendar_add", {
|
|
251
|
+
calendar_name: targetCalendar,
|
|
252
|
+
title: `ATM smoke test ${stamp}`,
|
|
253
|
+
start: window.start,
|
|
254
|
+
end: window.end,
|
|
255
|
+
notes: "Created by apple-tools-mcp smoke test; safe to delete.",
|
|
256
|
+
alerts_minutes_before: [10],
|
|
257
|
+
...common
|
|
258
|
+
}));
|
|
259
|
+
results.push(eventCreated);
|
|
260
|
+
|
|
261
|
+
if (eventCreated.ok !== false) {
|
|
262
|
+
const eventId = apply ? extractEventId(eventCreated.message) : "ATM-SMOKE-EVENT-UID";
|
|
263
|
+
if (apply && !eventId) {
|
|
264
|
+
console.log(" calendar_add reported success but returned no event id; skipping edit/delete.");
|
|
265
|
+
results.push({ ok: false });
|
|
266
|
+
} else {
|
|
267
|
+
results.push(step("calendar_edit", await run("calendar_edit", {
|
|
268
|
+
event_id: eventId,
|
|
269
|
+
title: `ATM smoke test ${stamp} (edited)`,
|
|
270
|
+
...common
|
|
271
|
+
})));
|
|
272
|
+
|
|
273
|
+
if (apply && keep) {
|
|
274
|
+
console.log(` Left event ${eventId} in place (--keep). Delete it when you are done.`);
|
|
275
|
+
} else {
|
|
276
|
+
const removed = step("calendar_remove", await run("calendar_remove", { event_id: eventId, ...common }));
|
|
277
|
+
results.push(removed);
|
|
278
|
+
if (apply && removed.ok === false) {
|
|
279
|
+
console.log(` Created ${eventId} but could not delete it. Remove it in Calendar.app.`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const failed = results.some((r) => r && r.ok === false);
|
|
286
|
+
console.log("\n" + "=".repeat(60));
|
|
287
|
+
if (failed) {
|
|
288
|
+
console.log("Result: FAILED - see the messages above.");
|
|
289
|
+
if (route.path === "local") {
|
|
290
|
+
console.log("These writes ran in this process, so the failure describes this process's");
|
|
291
|
+
console.log("Automation rights. Start the indexer LaunchAgent and re-run to test the");
|
|
292
|
+
console.log("shipping path, where writes execute under launchd-owned node.");
|
|
293
|
+
} else {
|
|
294
|
+
console.log("These writes ran inside the indexer daemon, so this is a real gate failure:");
|
|
295
|
+
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.");
|
|
298
|
+
}
|
|
299
|
+
process.exitCode = 1;
|
|
300
|
+
} else if (apply) {
|
|
301
|
+
console.log(`Result: PASS - Contacts and Calendar CRUD both work (${route.path === "daemon" ? "via the write bridge" : "in this process"}).`);
|
|
302
|
+
} else {
|
|
303
|
+
console.log("Result: PASS - dry run only. Re-run with --apply to prove real CRUD.");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Only run when invoked directly, so the helpers stay unit-testable.
|
|
308
|
+
if (process.argv[1] && process.argv[1].endsWith("smoke-writes.js")) {
|
|
309
|
+
main().catch((e) => {
|
|
310
|
+
console.error(`Smoke test error: ${e.message}`);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
});
|
|
313
|
+
}
|
package/search.js
CHANGED
|
@@ -1346,6 +1346,8 @@ export async function getCalendarDateResults(dateStr) {
|
|
|
1346
1346
|
|
|
1347
1347
|
const formattedResults = events.map((row, idx) => ({
|
|
1348
1348
|
index: idx + 1,
|
|
1349
|
+
// iCal UID: the id the calendar write tools address events by.
|
|
1350
|
+
eventId: row.uid || "",
|
|
1349
1351
|
title: row.title || "No title",
|
|
1350
1352
|
start: formatLocalDate(row.start) || row.start || "Unknown",
|
|
1351
1353
|
startTimestamp: row.startMac != null ? (Number(row.startMac) + 978307200) * 1000 : null,
|
|
@@ -1512,6 +1514,7 @@ export function formatCalendarResults(searchResult) {
|
|
|
1512
1514
|
result += `\nEvent: ${r.title}`;
|
|
1513
1515
|
if (r.isAllDay) result += " (All Day)";
|
|
1514
1516
|
result += `\nCalendar: ${r.calendar}\nStart: ${r.start}\nEnd: ${r.end}`;
|
|
1517
|
+
if (r.eventId) result += `\nEvent ID: ${r.eventId}`;
|
|
1515
1518
|
if (r.location) result += `\nLocation: ${r.location}`;
|
|
1516
1519
|
if (r.attendees && r.attendees.length > 0) {
|
|
1517
1520
|
const attendeeList = r.attendees.map(a => `${a.name} (${a.status})`).join(", ");
|