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.
package/index.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  import fs from "fs";
10
10
  import path from "path";
11
11
  import { validateEmailPath, stripHtmlTags, unfoldRfc822Headers, validateLimit, validateDaysBack, validateWeekOffset, toUnixMillis } from "./lib/validators.js";
12
- import { isSearchBlockedByIndexing, cycleEndFlags, indexUnavailableMessage } from "./lib/indexGate.js";
12
+ import { cycleEndFlags, indexUnavailableMessage, indexQueryGate } from "./lib/indexGate.js";
13
13
  import { isIndexerMode } from "./lib/processMode.js";
14
14
  import { loadResolvedIndexInterval, logResolvedInterval } from "./lib/config.js";
15
15
  import { createIndexerLock, DEFAULT_LOCK_HEARTBEAT_MS } from "./lib/indexerLock.js";
@@ -22,6 +22,13 @@ import {
22
22
  waitForIndexerLock,
23
23
  beginOwnedIndexing
24
24
  } from "./lib/indexerRuntime.js";
25
+ import {
26
+ WRITE_TOOL_DEFINITIONS,
27
+ isWriteTool,
28
+ dispatchWriteTool,
29
+ executeWriteToolLocally
30
+ } from "./lib/writeTools.js";
31
+ import { startWriteBridgeServer, defaultSocketPath } from "./lib/writeBridge.js";
25
32
 
26
33
  const PACKAGE_VERSION = JSON.parse(
27
34
  fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")
@@ -70,9 +77,37 @@ function stopLockHeartbeat() {
70
77
  indexerLock.stopHeartbeat();
71
78
  }
72
79
 
80
+ // Daemon-only: serves writes for stdio clients whose host app cannot be
81
+ // granted Contacts/Calendar automation (see lib/writeBridge.js).
82
+ const WRITE_SOCKET_PATH = defaultSocketPath();
83
+ let writeBridge = null;
84
+
85
+ function stopWriteBridge() {
86
+ if (!writeBridge) return;
87
+ try {
88
+ writeBridge.close();
89
+ } catch (e) {
90
+ console.error("Error closing write bridge:", e.message);
91
+ }
92
+ writeBridge = null;
93
+ }
94
+
95
+ async function startWriteBridge() {
96
+ try {
97
+ writeBridge = await startWriteBridgeServer({
98
+ socketPath: WRITE_SOCKET_PATH,
99
+ handler: (tool, args) => executeWriteToolLocally(tool, args),
100
+ log: (msg) => console.error(msg)
101
+ });
102
+ } catch (e) {
103
+ console.error(`Write bridge unavailable: ${e.message}. Writes will run in each MCP process.`);
104
+ }
105
+ }
106
+
73
107
  function shutdownIndexing(exitCode) {
74
108
  stopBackgroundIndexing();
75
109
  stopLockHeartbeat();
110
+ stopWriteBridge();
76
111
  releaseLock();
77
112
  if (exitCode !== undefined) {
78
113
  process.exit(exitCode);
@@ -83,6 +118,7 @@ function shutdownIndexing(exitCode) {
83
118
  process.on("exit", () => {
84
119
  stopBackgroundIndexing();
85
120
  stopLockHeartbeat();
121
+ stopWriteBridge();
86
122
  releaseLock();
87
123
  });
88
124
  process.on("SIGINT", () => {
@@ -180,14 +216,6 @@ async function checkIfFirstRun() {
180
216
  return !(emailsReady || messagesReady || calendarReady);
181
217
  }
182
218
 
183
- // Get appropriate status message based on indexing state
184
- function getIndexingMessage() {
185
- if (isFirstEverRun) {
186
- return "Building initial index. This may take several minutes on first run. Please try again shortly.";
187
- }
188
- return "Indexing new data. Please try again in a moment.";
189
- }
190
-
191
219
  // Run a single indexing cycle (called by background timer)
192
220
  function runIndexCycle() {
193
221
  const cycle = beginIndexCycle(indexingInProgress);
@@ -318,10 +346,30 @@ function applyCycleEnd(success) {
318
346
  // Index-backed tools wait only while THIS process owns the lock and has not
319
347
  // finished its cycle. Lost-lock secondaries fall through to isIndexReady().
320
348
  function stillIndexingMessage() {
321
- if (isSearchBlockedByIndexing(sessionIndexComplete, ownsIndexLock)) {
322
- return getIndexingMessage();
349
+ const gate = indexQueryGate({
350
+ sessionIndexComplete,
351
+ ownsIndexLock,
352
+ indexReady: true,
353
+ isFirstEverRun
354
+ });
355
+ return gate.ok ? null : gate.message;
356
+ }
357
+
358
+ /** Shared preflight for index-backed query tools (on-disk tables, not local cycle). */
359
+ async function requireIndex(type) {
360
+ const indexing = stillIndexingMessage();
361
+ if (indexing) {
362
+ return indexing;
323
363
  }
324
- return null;
364
+ const ready = await isIndexReady(type);
365
+ const gate = indexQueryGate({
366
+ sessionIndexComplete,
367
+ ownsIndexLock,
368
+ indexReady: ready,
369
+ type,
370
+ isFirstEverRun
371
+ });
372
+ return gate.ok ? null : gate.message;
325
373
  }
326
374
 
327
375
  function waitForLockAndStartDaemon() {
@@ -338,12 +386,27 @@ function waitForLockAndStartDaemon() {
338
386
 
339
387
  // Initialize and start indexing
340
388
  async function initializeIndexing() {
341
- isFirstEverRun = await checkIfFirstRun();
342
-
343
389
  if (INDEXER_MODE) {
344
390
  console.error(`Apple Tools MCP indexer running (v${PACKAGE_VERSION})`);
345
391
  logResolvedInterval(resolvedIndexInterval);
346
392
  loggedIndexInterval = true;
393
+ // launchd started this process, so node owns its TCC prompts. Offer the
394
+ // write bridge to stdio clients whose host app cannot get those grants.
395
+ // This happens before any index work: writes must stay available even if
396
+ // the vector index is missing, locked, or unreadable.
397
+ await startWriteBridge();
398
+ }
399
+
400
+ try {
401
+ isFirstEverRun = await checkIfFirstRun();
402
+ } catch (e) {
403
+ // A failed readiness probe must not take the daemon (or its write
404
+ // bridge) down; assume a first run and let the cycle report the details.
405
+ console.error(`Could not determine index state: ${e.message}`);
406
+ isFirstEverRun = true;
407
+ }
408
+
409
+ if (INDEXER_MODE) {
347
410
  waitForLockAndStartDaemon();
348
411
  return;
349
412
  }
@@ -355,7 +418,8 @@ async function initializeIndexing() {
355
418
  if (!startup.startBackground) {
356
419
  console.error("Another apple-tools-mcp instance is indexing. Server will run without background indexing.");
357
420
  // Lost lock is not "still indexing": this process will never complete a
358
- // local cycle. Searches proceed whenever isIndexReady() is true.
421
+ // local cycle. Searches proceed whenever isIndexReady() is true (initDB
422
+ // re-lists shared on-disk tables; it must not cache an empty first connect).
359
423
  ownsIndexLock = false;
360
424
  return;
361
425
  }
@@ -369,8 +433,12 @@ async function initializeIndexing() {
369
433
  });
370
434
  }
371
435
 
372
- // Start indexing immediately on server startup
373
- initializeIndexing();
436
+ // Start indexing immediately on server startup. A startup failure is logged
437
+ // rather than rejected: an unhandled rejection would tear down the daemon,
438
+ // taking the write bridge with it.
439
+ initializeIndexing().catch((e) => {
440
+ console.error(`Indexing startup failed: ${e.message}`);
441
+ });
374
442
 
375
443
  // ============ SEMANTIC SEARCH FUNCTIONS ============
376
444
 
@@ -379,14 +447,9 @@ async function mailSearch(query, options = {}) {
379
447
  return "Error: query parameter is required for mail_search";
380
448
  }
381
449
 
382
- const indexing = stillIndexingMessage();
383
- if (indexing) {
384
- return indexing;
385
- }
386
-
387
- const ready = await isIndexReady("emails");
388
- if (!ready) {
389
- return indexUnavailableMessage("emails");
450
+ const blocked = await requireIndex("emails");
451
+ if (blocked) {
452
+ return blocked;
390
453
  }
391
454
 
392
455
  const result = await searchEmails(query, options);
@@ -394,14 +457,9 @@ async function mailSearch(query, options = {}) {
394
457
  }
395
458
 
396
459
  async function mailRecent(limit = 30, daysBack = 7, unreadOnly = false, includeJunk = false) {
397
- const indexing = stillIndexingMessage();
398
- if (indexing) {
399
- return indexing;
400
- }
401
-
402
- const ready = await isIndexReady("emails");
403
- if (!ready) {
404
- return indexUnavailableMessage("emails");
460
+ const blocked = await requireIndex("emails");
461
+ if (blocked) {
462
+ return blocked;
405
463
  }
406
464
 
407
465
  const result = await getRecentEmailResults(limit, daysBack, unreadOnly, includeJunk);
@@ -409,14 +467,9 @@ async function mailRecent(limit = 30, daysBack = 7, unreadOnly = false, includeJ
409
467
  }
410
468
 
411
469
  async function mailDate(date, includeJunk = false) {
412
- const indexing = stillIndexingMessage();
413
- if (indexing) {
414
- return indexing;
415
- }
416
-
417
- const ready = await isIndexReady("emails");
418
- if (!ready) {
419
- return indexUnavailableMessage("emails");
470
+ const blocked = await requireIndex("emails");
471
+ if (blocked) {
472
+ return blocked;
420
473
  }
421
474
 
422
475
  const result = await getEmailDateResults(date, includeJunk);
@@ -424,14 +477,9 @@ async function mailDate(date, includeJunk = false) {
424
477
  }
425
478
 
426
479
  async function messagesSearch(query, options = {}) {
427
- const indexing = stillIndexingMessage();
428
- if (indexing) {
429
- return indexing;
430
- }
431
-
432
- const ready = await isIndexReady("messages");
433
- if (!ready) {
434
- return indexUnavailableMessage("messages");
480
+ const blocked = await requireIndex("messages");
481
+ if (blocked) {
482
+ return blocked;
435
483
  }
436
484
 
437
485
  const result = await searchMessages(query, options);
@@ -439,14 +487,9 @@ async function messagesSearch(query, options = {}) {
439
487
  }
440
488
 
441
489
  async function messagesRecent(limit = 10, daysBack = 1) {
442
- const indexing = stillIndexingMessage();
443
- if (indexing) {
444
- return indexing;
445
- }
446
-
447
- const ready = await isIndexReady("messages");
448
- if (!ready) {
449
- return indexUnavailableMessage("messages");
490
+ const blocked = await requireIndex("messages");
491
+ if (blocked) {
492
+ return blocked;
450
493
  }
451
494
 
452
495
  const result = await getRecentMessageResults(limit, daysBack);
@@ -454,14 +497,9 @@ async function messagesRecent(limit = 10, daysBack = 1) {
454
497
  }
455
498
 
456
499
  async function messagesConversation(contact, limit = 50) {
457
- const indexing = stillIndexingMessage();
458
- if (indexing) {
459
- return indexing;
460
- }
461
-
462
- const ready = await isIndexReady("messages");
463
- if (!ready) {
464
- return indexUnavailableMessage("messages");
500
+ const blocked = await requireIndex("messages");
501
+ if (blocked) {
502
+ return blocked;
465
503
  }
466
504
 
467
505
  const result = await getConversationResults(contact, limit);
@@ -469,14 +507,9 @@ async function messagesConversation(contact, limit = 50) {
469
507
  }
470
508
 
471
509
  async function calendarSearch(query, options = {}) {
472
- const indexing = stillIndexingMessage();
473
- if (indexing) {
474
- return indexing;
475
- }
476
-
477
- const ready = await isIndexReady("calendar");
478
- if (!ready) {
479
- return indexUnavailableMessage("calendar");
510
+ const blocked = await requireIndex("calendar");
511
+ if (blocked) {
512
+ return blocked;
480
513
  }
481
514
 
482
515
  const result = await searchCalendar(query, options);
@@ -772,6 +805,7 @@ function formatContactsSearchResults(contacts) {
772
805
  output += `• ${c.displayName}`;
773
806
  if (c.organization) output += ` (${c.organization})`;
774
807
  output += "\n";
808
+ if (c.uniqueId) output += ` Contact ID: ${c.uniqueId}\n`;
775
809
  if (c.emails.length > 0) {
776
810
  output += ` Emails: ${c.emails.map(e => e.email).join(", ")}\n`;
777
811
  }
@@ -791,6 +825,7 @@ function formatContactLookupResult(contact) {
791
825
  let output = `Contact: ${contact.displayName}\n`;
792
826
  output += "─".repeat(40) + "\n";
793
827
 
828
+ if (contact.uniqueId) output += `Contact ID: ${contact.uniqueId}\n`;
794
829
  if (contact.organization) output += `Organization: ${contact.organization}\n`;
795
830
  if (contact.department) output += `Department: ${contact.department}\n`;
796
831
  if (contact.jobTitle) output += `Job Title: ${contact.jobTitle}\n`;
@@ -1292,6 +1327,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1292
1327
  required: ["name"],
1293
1328
  },
1294
1329
  },
1330
+
1331
+ // ============ WRITE TOOLS (2.0.0) ============
1332
+ // Mail / Messages / Calendar / Contacts writes with dry_run + confirm.
1333
+ ...WRITE_TOOL_DEFINITIONS,
1295
1334
  ],
1296
1335
  }));
1297
1336
 
@@ -1302,6 +1341,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1302
1341
  try {
1303
1342
  let result;
1304
1343
 
1344
+ // Writes never wait on the vector index: they talk to Mail / Messages /
1345
+ // Calendar / Contacts directly and must keep working while the indexer
1346
+ // daemon holds the lock.
1347
+ if (isWriteTool(name)) {
1348
+ const writeResult = await dispatchWriteTool(name, args || {}, {
1349
+ indexerMode: INDEXER_MODE,
1350
+ socketPath: WRITE_SOCKET_PATH,
1351
+ log: (msg) => console.error(msg)
1352
+ });
1353
+ return {
1354
+ content: [{ type: "text", text: writeResult.message }],
1355
+ ...(writeResult.ok === false ? { isError: true } : {})
1356
+ };
1357
+ }
1358
+
1305
1359
  switch (name) {
1306
1360
  // Smart search (agentic)
1307
1361
  case "smart_search":
@@ -1397,16 +1451,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1397
1451
  // Mail tools
1398
1452
  case "mail_senders":
1399
1453
  {
1400
- const indexing = stillIndexingMessage();
1401
- if (indexing) {
1402
- result = indexing;
1454
+ const blocked = await requireIndex("emails");
1455
+ if (blocked) {
1456
+ result = blocked;
1403
1457
  break;
1404
1458
  }
1405
1459
  }
1406
- if (!(await isIndexReady("emails"))) {
1407
- result = indexUnavailableMessage("emails");
1408
- break;
1409
- }
1410
1460
  result = formatSendersResults(await getFrequentSenders(
1411
1461
  validateLimit(args?.limit, 30),
1412
1462
  validateDaysBack(args?.days_back),
@@ -1481,16 +1531,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1481
1531
 
1482
1532
  case "mail_thread":
1483
1533
  {
1484
- const indexing = stillIndexingMessage();
1485
- if (indexing) {
1486
- result = indexing;
1534
+ const blocked = await requireIndex("emails");
1535
+ if (blocked) {
1536
+ result = blocked;
1487
1537
  break;
1488
1538
  }
1489
1539
  }
1490
- if (!(await isIndexReady("emails"))) {
1491
- result = indexUnavailableMessage("emails");
1492
- break;
1493
- }
1494
1540
  result = formatEmailThreadResults(await getEmailThread(args.file_path, validateLimit(args?.limit, 30)));
1495
1541
  break;
1496
1542
 
package/indexer.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from "./lib/validators.js";
17
17
  import { safeSqlite3Json, safeOsascript, safeFind } from "./lib/shell.js";
18
18
  import { indexUnavailableMessage } from "./lib/indexGate.js";
19
+ import { createLanceTableCache, LANCE_CONNECT_OPTIONS } from "./lib/lancedbTables.js";
19
20
 
20
21
  // Re-export contact functions for use by other modules
21
22
  export {
@@ -70,8 +71,17 @@ const MAC_ABSOLUTE_EPOCH = 978307200;
70
71
  export const CALENDAR_TMP_PREFIX = "apple-tools-cal-";
71
72
 
72
73
  let embeddingPipeline = null;
74
+
75
+ // One connection per process. Do not early-return a first connect that saw an
76
+ // empty catalog — MCP stdio must pick up tables the indexer daemon already wrote.
77
+ const lanceCache = createLanceTableCache({
78
+ connect: (uri, options) => lancedb.connect(uri, options),
79
+ indexDir: INDEX_DIR,
80
+ mkdirSync: (p, o) => fs.mkdirSync(p, o),
81
+ connectOptions: LANCE_CONNECT_OPTIONS
82
+ });
83
+ const tables = lanceCache.tables;
73
84
  let db = null;
74
- let tables = {};
75
85
 
76
86
  async function getEmbedder() {
77
87
  if (!embeddingPipeline) {
@@ -751,23 +761,19 @@ function getCalendarEvents() {
751
761
  // ============ DATABASE FUNCTIONS ============
752
762
 
753
763
  export async function initDB() {
754
- if (db) return { db, tables };
755
-
756
- fs.mkdirSync(INDEX_DIR, { recursive: true });
757
- db = await lancedb.connect(INDEX_DIR);
764
+ const result = await lanceCache.initDB();
765
+ db = result.db;
766
+ return { db, tables };
767
+ }
758
768
 
759
- const tableNames = await db.tableNames();
760
- if (tableNames.includes("emails")) {
761
- tables.emails = await db.openTable("emails");
762
- }
763
- if (tableNames.includes("messages")) {
764
- tables.messages = await db.openTable("messages");
765
- }
766
- if (tableNames.includes("calendar")) {
767
- tables.calendar = await db.openTable("calendar");
768
- }
769
+ export async function getOpenTable(type) {
770
+ await initDB();
771
+ return tables[type] || null;
772
+ }
769
773
 
770
- return { db, tables };
774
+ function resetLanceConnection() {
775
+ lanceCache.reset();
776
+ db = null;
771
777
  }
772
778
 
773
779
  export async function clearEmailsTable() {
@@ -828,10 +834,9 @@ export async function rebuildIndex(sources = ["emails", "messages", "calendar"],
828
834
  }
829
835
  }
830
836
 
831
- // Reset module-level cache after dropping tables
832
- // This ensures that initDB() will re-initialize and pick up the newly created tables
833
- db = null;
834
- tables = {};
837
+ // Reset connection cache after dropping tables so initDB() re-opens
838
+ // (or recreates) them instead of holding stale/empty handles.
839
+ resetLanceConnection();
835
840
 
836
841
  // Re-index requested sources
837
842
  for (const source of sources) {
@@ -1451,7 +1456,7 @@ export async function indexAll(progressCallback = null) {
1451
1456
 
1452
1457
  export async function isIndexReady(type = "emails") {
1453
1458
  await initDB();
1454
- return tables[type] !== null && tables[type] !== undefined;
1459
+ return tables[type] != null;
1455
1460
  }
1456
1461
 
1457
1462
  // ============ DIRECT QUERIES (for recent items) ============
@@ -1736,7 +1741,9 @@ export function localDayToMacBounds(startMs, endMs) {
1736
1741
  }
1737
1742
 
1738
1743
  // Date-bounded OccurrenceCache query: one row per occurrence, no GROUP BY ci.ROWID
1739
- export function buildEventsOnDateQuery(startMac, endMac) {
1744
+ // includeUid adds the iCal UID that the calendar write tools address events by.
1745
+ // Older Calendar schemas may lack the column, so callers can retry without it.
1746
+ export function buildEventsOnDateQuery(startMac, endMac, { includeUid = true } = {}) {
1740
1747
  const startBound = Math.floor(Number(startMac));
1741
1748
  const endBound = Math.floor(Number(endMac));
1742
1749
  if (!Number.isFinite(startBound) || !Number.isFinite(endBound)) {
@@ -1750,6 +1757,7 @@ export function buildEventsOnDateQuery(startMac, endMac) {
1750
1757
  return `
1751
1758
  SELECT DISTINCT
1752
1759
  ci.ROWID as itemId,
1760
+ ${includeUid ? "ci.unique_identifier as uid," : "'' as uid,"}
1753
1761
  ci.summary as title,
1754
1762
  datetime(CASE WHEN ci.all_day THEN ${allDayStartMac} ELSE ${timedStart} END + 978307200, 'unixepoch', 'localtime') as start,
1755
1763
  datetime(${occEnd} + 978307200, 'unixepoch', 'localtime') as end,
@@ -1816,9 +1824,11 @@ export function getEventsOnDate(startMs, endMs) {
1816
1824
  return { events: [], error: "Calendar database not found" };
1817
1825
  }
1818
1826
  const { startMac, endMac } = localDayToMacBounds(startMs, endMs);
1819
- const query = buildEventsOnDateQuery(startMac, endMac);
1820
1827
  let lastError;
1821
1828
  for (let attempt = 0; attempt < 3; attempt++) {
1829
+ // Drop the UID column after a failed first attempt: a schema without
1830
+ // unique_identifier must still return events for calendar_date.
1831
+ const query = buildEventsOnDateQuery(startMac, endMac, { includeUid: attempt === 0 });
1822
1832
  try {
1823
1833
  const events = withCalendarCopy((dbPath) => {
1824
1834
  return safeSqlite3Json(dbPath, query, { timeout: 15000 });