apple-tools-mcp 1.1.4 → 1.2.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.
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Shared LanceDB table cache for the vector index.
3
+ *
4
+ * MCP stdio and the indexer daemon are separate processes. The daemon keeps a
5
+ * writer connection for its lifetime. A short-lived MCP reader must still see
6
+ * on-disk emails/messages/calendar tables — including when the first connect
7
+ * listed an empty catalog (writer commit in flight, stale snapshot, or a
8
+ * concurrent initDB that returned before openTable finished).
9
+ *
10
+ * Default LanceDB consistency does not re-check other processes. Readers pass
11
+ * readConsistencyInterval: 0 so tableNames()/openTable() see committed catalog.
12
+ */
13
+
14
+ import fs from "fs";
15
+ import path from "path";
16
+
17
+ export const INDEX_TABLE_NAMES = ["emails", "messages", "calendar"];
18
+
19
+ /** Strong cross-process read freshness (seconds). 0 = check on every read. */
20
+ export const LANCE_READ_CONSISTENCY_INTERVAL = 0;
21
+
22
+ export const LANCE_CONNECT_OPTIONS = {
23
+ readConsistencyInterval: LANCE_READ_CONSISTENCY_INTERVAL
24
+ };
25
+
26
+ /**
27
+ * LanceDB stores each table as `<name>.lance` under the index directory.
28
+ *
29
+ * @param {string} indexDir
30
+ * @param {string} name
31
+ * @param {(p: string) => boolean} [existsSync]
32
+ * @returns {boolean}
33
+ */
34
+ export function lanceTableExistsOnDisk(indexDir, name, existsSync = fs.existsSync) {
35
+ if (!indexDir || !INDEX_TABLE_NAMES.includes(name)) {
36
+ return false;
37
+ }
38
+ try {
39
+ const tableDir = path.join(indexDir, `${name}.lance`);
40
+ const resolvedIndex = path.resolve(indexDir);
41
+ const resolvedTable = path.resolve(tableDir);
42
+ if (resolvedTable !== path.join(resolvedIndex, `${name}.lance`)) {
43
+ return false;
44
+ }
45
+ return existsSync(tableDir);
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Open any INDEX_TABLE_NAMES entries not already in `tables`.
53
+ * Always re-lists tableNames — never assume a prior empty catalog is final.
54
+ *
55
+ * @param {{
56
+ * db: { tableNames: () => Promise<string[]>, openTable: (name: string) => Promise<unknown>, close?: () => void },
57
+ * tables: Record<string, unknown>,
58
+ * indexDir: string,
59
+ * existsSync?: (p: string) => boolean,
60
+ * reconnect?: () => Promise<{ tableNames: () => Promise<string[]>, openTable: (name: string) => Promise<unknown>, close?: () => void }>,
61
+ * isCurrent?: () => boolean,
62
+ * log?: (msg: string) => void
63
+ * }} args
64
+ * @returns {Promise<{ db: object, tables: Record<string, unknown>, reconnected: boolean, aborted: boolean }>}
65
+ */
66
+ export async function openMissingIndexTables({
67
+ db,
68
+ tables,
69
+ indexDir,
70
+ existsSync = fs.existsSync,
71
+ reconnect,
72
+ isCurrent = () => true,
73
+ log = (msg) => console.error(msg)
74
+ }) {
75
+ const stillCurrent = () => {
76
+ try {
77
+ return isCurrent() !== false;
78
+ } catch {
79
+ return false;
80
+ }
81
+ };
82
+
83
+ const missing = INDEX_TABLE_NAMES.filter((name) => tables[name] == null);
84
+ if (missing.length === 0) {
85
+ return { db, tables, reconnected: false, aborted: false };
86
+ }
87
+
88
+ let tableNames = [];
89
+ try {
90
+ tableNames = await db.tableNames();
91
+ } catch (e) {
92
+ log(`Failed to list index tables: ${e.message}`);
93
+ return { db, tables, reconnected: false, aborted: !stillCurrent() };
94
+ }
95
+
96
+ if (!stillCurrent()) {
97
+ return { db, tables, reconnected: false, aborted: true };
98
+ }
99
+
100
+ const listed = new Set(tableNames);
101
+ const onDiskButUnlisted = missing.filter(
102
+ (name) => !listed.has(name) && lanceTableExistsOnDisk(indexDir, name, existsSync)
103
+ );
104
+
105
+ let reconnected = false;
106
+ let conn = db;
107
+ if (onDiskButUnlisted.length > 0 && typeof reconnect === "function") {
108
+ log("Index catalog missed on-disk tables; reconnecting to shared vector-index.");
109
+ for (const name of INDEX_TABLE_NAMES) {
110
+ delete tables[name];
111
+ }
112
+ try {
113
+ conn = await reconnect();
114
+ reconnected = true;
115
+ if (!stillCurrent()) {
116
+ return { db: conn, tables, reconnected, aborted: true };
117
+ }
118
+ tableNames = await conn.tableNames();
119
+ } catch (e) {
120
+ log(`Failed to reconnect to index: ${e.message}`);
121
+ return { db: conn, tables, reconnected, aborted: !stillCurrent() };
122
+ }
123
+ }
124
+
125
+ if (!stillCurrent()) {
126
+ return { db: conn, tables, reconnected, aborted: true };
127
+ }
128
+
129
+ const stillMissing = INDEX_TABLE_NAMES.filter((name) => tables[name] == null);
130
+ for (const name of stillMissing) {
131
+ if (!tableNames.includes(name)) {
132
+ continue;
133
+ }
134
+ try {
135
+ tables[name] = await conn.openTable(name);
136
+ } catch (e) {
137
+ log(`Failed to open ${name} table: ${e.message}`);
138
+ }
139
+ if (!stillCurrent()) {
140
+ return { db: conn, tables, reconnected, aborted: true };
141
+ }
142
+ }
143
+
144
+ return { db: conn, tables, reconnected, aborted: false };
145
+ }
146
+
147
+ /**
148
+ * Process-local LanceDB connection + table handles.
149
+ * `tables` is a stable object (reset deletes keys, does not replace the object)
150
+ * so indexer.js can alias it for createTable/dropTable mutation.
151
+ *
152
+ * @param {{
153
+ * connect: (uri: string, options: object) => Promise<object>,
154
+ * indexDir: string,
155
+ * mkdirSync?: (p: string, opts?: object) => void,
156
+ * existsSync?: (p: string) => boolean,
157
+ * log?: (msg: string) => void,
158
+ * connectOptions?: object
159
+ * }} options
160
+ */
161
+ export function createLanceTableCache(options) {
162
+ const {
163
+ connect,
164
+ indexDir,
165
+ mkdirSync,
166
+ existsSync = fs.existsSync,
167
+ log = (msg) => console.error(msg),
168
+ connectOptions = LANCE_CONNECT_OPTIONS
169
+ } = options;
170
+
171
+ const tables = {};
172
+ let db = null;
173
+ let connecting = null;
174
+ let refreshing = null;
175
+ let generation = 0;
176
+
177
+ function rememberPromise(getSlot, setSlot, pending) {
178
+ // Store the .finally() chain, not the raw pending. Discarding the derived
179
+ // promise leaves connect() failures as unhandledRejection (index.js exits).
180
+ const tracked = pending.finally(() => {
181
+ if (getSlot() === tracked) {
182
+ setSlot(null);
183
+ }
184
+ });
185
+ setSlot(tracked);
186
+ return tracked;
187
+ }
188
+
189
+ async function ensureDb() {
190
+ if (db) {
191
+ return db;
192
+ }
193
+ if (!connecting) {
194
+ const gen = generation;
195
+ rememberPromise(
196
+ () => connecting,
197
+ (value) => {
198
+ connecting = value;
199
+ },
200
+ (async () => {
201
+ if (typeof mkdirSync === "function") {
202
+ mkdirSync(indexDir, { recursive: true });
203
+ }
204
+ const conn = await connect(indexDir, connectOptions);
205
+ if (gen !== generation) {
206
+ try {
207
+ conn?.close?.();
208
+ } catch {
209
+ // stale connect after reset
210
+ }
211
+ return ensureDb();
212
+ }
213
+ db = conn;
214
+ return db;
215
+ })()
216
+ );
217
+ }
218
+ return connecting;
219
+ }
220
+
221
+ async function initDB() {
222
+ if (!refreshing) {
223
+ const gen = generation;
224
+ rememberPromise(
225
+ () => refreshing,
226
+ (value) => {
227
+ refreshing = value;
228
+ },
229
+ (async () => {
230
+ await ensureDb();
231
+ if (gen !== generation) {
232
+ return;
233
+ }
234
+ const result = await openMissingIndexTables({
235
+ db,
236
+ tables,
237
+ indexDir,
238
+ existsSync,
239
+ log,
240
+ isCurrent: () => gen === generation,
241
+ reconnect: async () => {
242
+ if (gen !== generation) {
243
+ return ensureDb();
244
+ }
245
+ try {
246
+ db?.close?.();
247
+ } catch {
248
+ // ignore close errors on a stale handle
249
+ }
250
+ if (gen !== generation) {
251
+ return ensureDb();
252
+ }
253
+ db = null;
254
+ return ensureDb();
255
+ }
256
+ });
257
+ if (gen !== generation || result.aborted) {
258
+ return;
259
+ }
260
+ db = result.db;
261
+ })()
262
+ );
263
+ }
264
+ await refreshing;
265
+ return { db, tables };
266
+ }
267
+
268
+ async function isIndexReady(type = "emails") {
269
+ await initDB();
270
+ return tables[type] != null;
271
+ }
272
+
273
+ async function getOpenTable(type) {
274
+ await initDB();
275
+ return tables[type] || null;
276
+ }
277
+
278
+ function reset() {
279
+ generation += 1;
280
+ const toClose = db;
281
+ db = null;
282
+ connecting = null;
283
+ refreshing = null;
284
+ for (const key of Object.keys(tables)) {
285
+ delete tables[key];
286
+ }
287
+ try {
288
+ toClose?.close?.();
289
+ } catch {
290
+ // ignore
291
+ }
292
+ }
293
+
294
+ return {
295
+ initDB,
296
+ isIndexReady,
297
+ getOpenTable,
298
+ reset,
299
+ get db() {
300
+ return db;
301
+ },
302
+ set db(value) {
303
+ db = value;
304
+ },
305
+ tables
306
+ };
307
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Process mode detection for apple-tools-mcp.
3
+ *
4
+ * Canonical indexer entrypoint: `node index.js --mode=indexer`
5
+ * Convenience bin: `apple-tools-indexer` (same file; detected via argv[1]).
6
+ * MCP stdio remains the default when neither is present.
7
+ */
8
+
9
+ import path from "path";
10
+
11
+ /**
12
+ * @param {string[]} [argv=process.argv]
13
+ * @returns {boolean}
14
+ */
15
+ export function isIndexerMode(argv = process.argv) {
16
+ if (!Array.isArray(argv) || argv.length === 0) {
17
+ return false;
18
+ }
19
+ if (argv.includes("--mode=indexer")) {
20
+ return true;
21
+ }
22
+ const modeIdx = argv.indexOf("--mode");
23
+ if (modeIdx !== -1 && argv[modeIdx + 1] === "indexer") {
24
+ return true;
25
+ }
26
+ const entry = argv[1] ? path.basename(argv[1]) : "";
27
+ return entry === "apple-tools-indexer";
28
+ }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "1.1.4",
3
+ "version": "1.2.1",
4
4
  "description": "MCP server for semantic search across Apple Mail, Messages, and Calendar",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "bin": {
8
- "apple-tools-mcp": "./index.js"
8
+ "apple-tools-mcp": "./index.js",
9
+ "apple-tools-indexer": "./index.js"
9
10
  },
10
11
  "author": "Peter Coates",
11
12
  "license": "MIT",
@@ -50,6 +51,7 @@
50
51
  ],
51
52
  "scripts": {
52
53
  "start": "node index.js",
54
+ "indexer": "node index.js --mode=indexer",
53
55
  "build-index": "node -e \"import('./indexer.js').then(i=>i.rebuildIndex()).catch(e=>{console.error(e.message);process.exit(1)})\"",
54
56
  "audit": "node scripts/audit-index.js",
55
57
  "test": "vitest run",
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