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,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
@@ -1,13 +1,9 @@
1
- import * as lancedb from "@lancedb/lancedb";
2
1
  import * as chrono from "chrono-node";
3
2
  import { safeOsascript } from "./lib/shell.js";
4
3
  import { safeMatch, validateSearchQuery, toUnixMillis } from "./lib/validators.js";
5
- import { embed, INDEX_DIR, getRecentEmails, getEmailsByDateRange, getRecentMessages, getConversation, getEventsOnDate, resolveEmail, resolvePhone, formatContact } from "./indexer.js";
4
+ import { embed, getOpenTable, getRecentEmails, getEmailsByDateRange, getRecentMessages, getConversation, getEventsOnDate, resolveEmail, resolvePhone, formatContact } from "./indexer.js";
6
5
  import { indexUnavailableMessage } from "./lib/indexGate.js";
7
6
 
8
- let db = null;
9
- let tables = {};
10
-
11
7
  // ============ CACHING ============
12
8
 
13
9
  // Embedding cache with TTL (5 minutes)
@@ -458,19 +454,9 @@ async function searchWithRetry(_searchFn, tbl, query, options, keyField) {
458
454
  }
459
455
 
460
456
  async function getTable(type) {
461
- if (tables[type]) return tables[type];
462
-
463
- if (!db) {
464
- db = await lancedb.connect(INDEX_DIR);
465
- }
466
-
467
- const tableNames = await db.tableNames();
468
- if (!tableNames.includes(type)) {
469
- return null;
470
- }
471
-
472
- tables[type] = await db.openTable(type);
473
- return tables[type];
457
+ // Share indexer.js's connection so a first empty catalog is refreshed the
458
+ // same way isIndexReady() is — not a second handle that can stay empty.
459
+ return getOpenTable(type);
474
460
  }
475
461
 
476
462
  // Pre-warm all tables to eliminate first-query latency
@@ -1360,6 +1346,8 @@ export async function getCalendarDateResults(dateStr) {
1360
1346
 
1361
1347
  const formattedResults = events.map((row, idx) => ({
1362
1348
  index: idx + 1,
1349
+ // iCal UID: the id the calendar write tools address events by.
1350
+ eventId: row.uid || "",
1363
1351
  title: row.title || "No title",
1364
1352
  start: formatLocalDate(row.start) || row.start || "Unknown",
1365
1353
  startTimestamp: row.startMac != null ? (Number(row.startMac) + 978307200) * 1000 : null,
@@ -1526,6 +1514,7 @@ export function formatCalendarResults(searchResult) {
1526
1514
  result += `\nEvent: ${r.title}`;
1527
1515
  if (r.isAllDay) result += " (All Day)";
1528
1516
  result += `\nCalendar: ${r.calendar}\nStart: ${r.start}\nEnd: ${r.end}`;
1517
+ if (r.eventId) result += `\nEvent ID: ${r.eventId}`;
1529
1518
  if (r.location) result += `\nLocation: ${r.location}`;
1530
1519
  if (r.attendees && r.attendees.length > 0) {
1531
1520
  const attendeeList = r.attendees.map(a => `${a.name} (${a.status})`).join(", ");