apple-tools-mcp 1.1.1 → 1.1.3

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
@@ -8,11 +8,20 @@ import {
8
8
  } from "@modelcontextprotocol/sdk/types.js";
9
9
  import fs from "fs";
10
10
  import path from "path";
11
- import { validateEmailPath, stripHtmlTags } from "./lib/validators.js";
11
+ import { validateEmailPath, stripHtmlTags, unfoldRfc822Headers, validateLimit, validateDaysBack, validateWeekOffset, toUnixMillis } from "./lib/validators.js";
12
+ import { isSearchBlockedByIndexing, cycleEndFlags, indexUnavailableMessage } from "./lib/indexGate.js";
13
+
14
+ const PACKAGE_VERSION = JSON.parse(
15
+ fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")
16
+ ).version;
12
17
 
13
18
  // Lock file to prevent duplicate indexing processes
14
19
  const LOCK_FILE = path.join(process.env.HOME, ".apple-tools-mcp", "indexer.lock");
15
20
  const LOCK_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes - if lock is older, assume hung process
21
+ // True only while this process won the indexer lock. Distinct from
22
+ // sessionIndexComplete: a secondary instance that lost the lock never
23
+ // completes a local cycle and must not stay on "still indexing" forever.
24
+ let ownsIndexLock = false;
16
25
 
17
26
  function acquireLock() {
18
27
  try {
@@ -32,6 +41,7 @@ function acquireLock() {
32
41
 
33
42
  // If we already hold the lock, return true
34
43
  if (pid === process.pid) {
44
+ ownsIndexLock = true;
35
45
  return true;
36
46
  }
37
47
 
@@ -44,6 +54,7 @@ function acquireLock() {
44
54
  fs.unlinkSync(LOCK_FILE);
45
55
  } else {
46
56
  console.error(`Another indexing instance running (PID ${pid}). Skipping indexing.`);
57
+ ownsIndexLock = false;
47
58
  return false;
48
59
  }
49
60
  } catch {
@@ -57,17 +68,20 @@ function acquireLock() {
57
68
  // This prevents TOCTOU race condition - will throw EEXIST if file was created between check and write
58
69
  try {
59
70
  fs.writeFileSync(LOCK_FILE, `${process.pid}:${Date.now()}`, { flag: 'wx' });
71
+ ownsIndexLock = true;
60
72
  return true;
61
73
  } catch (err) {
62
74
  if (err.code === 'EEXIST') {
63
75
  // Another process won the race
64
76
  console.error("Another process acquired lock during race. Skipping indexing.");
77
+ ownsIndexLock = false;
65
78
  return false;
66
79
  }
67
80
  throw err; // Re-throw unexpected errors
68
81
  }
69
82
  } catch (e) {
70
83
  console.error("Lock file error:", e.message);
84
+ ownsIndexLock = false;
71
85
  return false; // On error, fail safe - don't proceed
72
86
  }
73
87
  }
@@ -80,6 +94,7 @@ function releaseLock() {
80
94
  const pid = parseInt(pidStr);
81
95
  if (pid === process.pid) {
82
96
  fs.unlinkSync(LOCK_FILE);
97
+ ownsIndexLock = false;
83
98
  console.error(`Released lock file (PID ${process.pid})`);
84
99
  }
85
100
  }
@@ -234,12 +249,12 @@ function runIndexCycle() {
234
249
  progressCheckTimer = setInterval(() => {
235
250
  const timeSinceProgress = Date.now() - lastProgressTime;
236
251
  if (timeSinceProgress > MAX_NO_PROGRESS_MS) {
237
- console.error(`⚠️ No indexing progress for ${Math.round(timeSinceProgress / 60000)} minutes. Terminating hung process.`);
252
+ console.error(`⚠️ No indexing progress for ${Math.round(timeSinceProgress / 60000)} minutes. Indexing still running; not starting another cycle.`);
238
253
  clearInterval(progressCheckTimer);
239
254
  progressCheckTimer = null;
240
- indexingInProgress = false;
255
+ // Allow searches, but do NOT clear indexingInProgress or release the lock
256
+ // while indexAll() is still running — overlapping writes can corrupt LanceDB.
241
257
  sessionIndexComplete = true;
242
- releaseLock();
243
258
  }
244
259
  }, PROGRESS_CHECK_INTERVAL_MS);
245
260
 
@@ -259,11 +274,8 @@ function runIndexCycle() {
259
274
 
260
275
  lastIndexTime = Date.now();
261
276
  lastProgressTime = Date.now();
262
- indexingInProgress = false;
263
- sessionIndexComplete = true;
264
- isFirstEverRun = false; // After successful index, no longer first run
277
+ applyCycleEnd(true);
265
278
  console.error("Indexing complete.");
266
- releaseLock(); // Allow other instances to index
267
279
  // Pre-warm tables to eliminate first-query latency
268
280
  await prewarmTables();
269
281
  }).catch(e => {
@@ -274,9 +286,7 @@ function runIndexCycle() {
274
286
  }
275
287
 
276
288
  console.error("Indexing error:", e.message);
277
- indexingInProgress = false;
278
- sessionIndexComplete = true; // Mark complete even on error so queries can proceed
279
- releaseLock(); // Allow other instances to index
289
+ applyCycleEnd(false);
280
290
  });
281
291
  }
282
292
 
@@ -313,6 +323,28 @@ function stopBackgroundIndexing() {
313
323
  console.error("Background indexing stopped");
314
324
  }
315
325
 
326
+ // Unblock searches and drop the indexer lock after a cycle ends.
327
+ // Must run on failure as well as success so tools are not stuck forever.
328
+ function applyCycleEnd(success) {
329
+ const flags = cycleEndFlags(success);
330
+ indexingInProgress = flags.indexingInProgress;
331
+ sessionIndexComplete = flags.sessionIndexComplete;
332
+ ownsIndexLock = flags.ownsIndexLock;
333
+ if (flags.isFirstEverRun === false) {
334
+ isFirstEverRun = false;
335
+ }
336
+ releaseLock();
337
+ }
338
+
339
+ // Index-backed tools wait only while THIS process owns the lock and has not
340
+ // finished its cycle. Lost-lock secondaries fall through to isIndexReady().
341
+ function stillIndexingMessage() {
342
+ if (isSearchBlockedByIndexing(sessionIndexComplete, ownsIndexLock)) {
343
+ return getIndexingMessage();
344
+ }
345
+ return null;
346
+ }
347
+
316
348
  // Initialize and start indexing
317
349
  async function initializeIndexing() {
318
350
  isFirstEverRun = await checkIfFirstRun();
@@ -321,6 +353,9 @@ async function initializeIndexing() {
321
353
  // indexing but keep the MCP server running so search still works.
322
354
  if (!acquireLock()) {
323
355
  console.error("Another apple-tools-mcp instance is indexing. Server will run without background indexing.");
356
+ // Lost lock is not "still indexing": this process will never complete a
357
+ // local cycle. Searches proceed whenever isIndexReady() is true.
358
+ ownsIndexLock = false;
324
359
  return;
325
360
  }
326
361
 
@@ -338,13 +373,14 @@ async function mailSearch(query, options = {}) {
338
373
  return "Error: query parameter is required for mail_search";
339
374
  }
340
375
 
341
- if (!sessionIndexComplete) {
342
- return getIndexingMessage();
376
+ const indexing = stillIndexingMessage();
377
+ if (indexing) {
378
+ return indexing;
343
379
  }
344
380
 
345
381
  const ready = await isIndexReady("emails");
346
382
  if (!ready) {
347
- return "Email index not available. Please try again shortly.";
383
+ return indexUnavailableMessage("emails");
348
384
  }
349
385
 
350
386
  const result = await searchEmails(query, options);
@@ -352,13 +388,14 @@ async function mailSearch(query, options = {}) {
352
388
  }
353
389
 
354
390
  async function mailRecent(limit = 30, daysBack = 7, unreadOnly = false, includeJunk = false) {
355
- if (!sessionIndexComplete) {
356
- return getIndexingMessage();
391
+ const indexing = stillIndexingMessage();
392
+ if (indexing) {
393
+ return indexing;
357
394
  }
358
395
 
359
396
  const ready = await isIndexReady("emails");
360
397
  if (!ready) {
361
- return "Email index not available. Please try again shortly.";
398
+ return indexUnavailableMessage("emails");
362
399
  }
363
400
 
364
401
  const result = await getRecentEmailResults(limit, daysBack, unreadOnly, includeJunk);
@@ -366,13 +403,14 @@ async function mailRecent(limit = 30, daysBack = 7, unreadOnly = false, includeJ
366
403
  }
367
404
 
368
405
  async function mailDate(date, includeJunk = false) {
369
- if (!sessionIndexComplete) {
370
- return getIndexingMessage();
406
+ const indexing = stillIndexingMessage();
407
+ if (indexing) {
408
+ return indexing;
371
409
  }
372
410
 
373
411
  const ready = await isIndexReady("emails");
374
412
  if (!ready) {
375
- return "Email index not available. Please try again shortly.";
413
+ return indexUnavailableMessage("emails");
376
414
  }
377
415
 
378
416
  const result = await getEmailDateResults(date, includeJunk);
@@ -380,13 +418,14 @@ async function mailDate(date, includeJunk = false) {
380
418
  }
381
419
 
382
420
  async function messagesSearch(query, options = {}) {
383
- if (!sessionIndexComplete) {
384
- return getIndexingMessage();
421
+ const indexing = stillIndexingMessage();
422
+ if (indexing) {
423
+ return indexing;
385
424
  }
386
425
 
387
426
  const ready = await isIndexReady("messages");
388
427
  if (!ready) {
389
- return "Messages index not available. Please try again shortly.";
428
+ return indexUnavailableMessage("messages");
390
429
  }
391
430
 
392
431
  const result = await searchMessages(query, options);
@@ -394,13 +433,14 @@ async function messagesSearch(query, options = {}) {
394
433
  }
395
434
 
396
435
  async function messagesRecent(limit = 10, daysBack = 1) {
397
- if (!sessionIndexComplete) {
398
- return getIndexingMessage();
436
+ const indexing = stillIndexingMessage();
437
+ if (indexing) {
438
+ return indexing;
399
439
  }
400
440
 
401
441
  const ready = await isIndexReady("messages");
402
442
  if (!ready) {
403
- return "Messages index not available. Please try again shortly.";
443
+ return indexUnavailableMessage("messages");
404
444
  }
405
445
 
406
446
  const result = await getRecentMessageResults(limit, daysBack);
@@ -408,13 +448,14 @@ async function messagesRecent(limit = 10, daysBack = 1) {
408
448
  }
409
449
 
410
450
  async function messagesConversation(contact, limit = 50) {
411
- if (!sessionIndexComplete) {
412
- return getIndexingMessage();
451
+ const indexing = stillIndexingMessage();
452
+ if (indexing) {
453
+ return indexing;
413
454
  }
414
455
 
415
456
  const ready = await isIndexReady("messages");
416
457
  if (!ready) {
417
- return "Messages index not available. Please try again shortly.";
458
+ return indexUnavailableMessage("messages");
418
459
  }
419
460
 
420
461
  const result = await getConversationResults(contact, limit);
@@ -422,13 +463,14 @@ async function messagesConversation(contact, limit = 50) {
422
463
  }
423
464
 
424
465
  async function calendarSearch(query, options = {}) {
425
- if (!sessionIndexComplete) {
426
- return getIndexingMessage();
466
+ const indexing = stillIndexingMessage();
467
+ if (indexing) {
468
+ return indexing;
427
469
  }
428
470
 
429
471
  const ready = await isIndexReady("calendar");
430
472
  if (!ready) {
431
- return "Calendar index not available. Please try again shortly.";
473
+ return indexUnavailableMessage("calendar");
432
474
  }
433
475
 
434
476
  const result = await searchCalendar(query, options);
@@ -464,7 +506,7 @@ function readFullEmail(filePath) {
464
506
  return "Email file not found.";
465
507
  }
466
508
 
467
- const content = fs.readFileSync(validatedPath, 'utf-8');
509
+ const content = unfoldRfc822Headers(fs.readFileSync(validatedPath, 'utf-8'));
468
510
 
469
511
  // Parse email headers and body
470
512
  const fromMatch = content.match(/^From:\s*(.+)$/m);
@@ -565,7 +607,7 @@ function formatSmartSearchResults(results, synthesizedGroups = null) {
565
607
  sections.push(` [${r.rank}] Score: ${r.score}`);
566
608
  sections.push(` From: ${r.sender}${r.isGroupChat ? ' (Group)' : ''}`);
567
609
  sections.push(` Date: ${r.date}`);
568
- sections.push(` Text: ${r.text.substring(0, 100)}...`);
610
+ sections.push(` Text: ${(r.text || "").substring(0, 100)}${(r.text || "").length > 100 ? "..." : ""}`);
569
611
  }
570
612
  sections.push("");
571
613
  }
@@ -591,6 +633,11 @@ function formatSmartSearchResults(results, synthesizedGroups = null) {
591
633
 
592
634
  // Smart search - routes to appropriate sources and optionally synthesizes results
593
635
  async function smartSearch(query, options = {}) {
636
+ const indexing = stillIndexingMessage();
637
+ if (indexing) {
638
+ return indexing;
639
+ }
640
+
594
641
  const { limit = 5, synthesize = true } = options;
595
642
 
596
643
  const sources = detectSources(query);
@@ -634,6 +681,10 @@ async function smartSearch(query, options = {}) {
634
681
 
635
682
  await Promise.all(searches);
636
683
 
684
+ if (Object.keys(results).length === 0) {
685
+ return indexUnavailableMessage();
686
+ }
687
+
637
688
  // Synthesize results into timeline if multiple sources returned data
638
689
  let synthesizedGroups = null;
639
690
  if (synthesize) {
@@ -671,21 +722,21 @@ function synthesizeResults(mailResults, messageResults, calendarResults) {
671
722
 
672
723
  // Add mail results
673
724
  for (const r of mailResults) {
674
- const ts = r.dateTimestamp || new Date(r.date).getTime();
725
+ const ts = toUnixMillis(r.dateTimestamp) || new Date(r.date).getTime();
675
726
  const bucket = getBucket(ts);
676
727
  if (bucket) bucket.mail.push(r);
677
728
  }
678
729
 
679
730
  // Add message results
680
731
  for (const r of messageResults) {
681
- const ts = r.dateTimestamp || new Date(r.date).getTime();
732
+ const ts = toUnixMillis(r.dateTimestamp) || new Date(r.date).getTime();
682
733
  const bucket = getBucket(ts);
683
734
  if (bucket) bucket.messages.push(r);
684
735
  }
685
736
 
686
737
  // Add calendar results
687
738
  for (const r of calendarResults) {
688
- const ts = r.startTimestamp || new Date(r.start).getTime();
739
+ const ts = toUnixMillis(r.startTimestamp) || new Date(r.start).getTime();
689
740
  const bucket = getBucket(ts);
690
741
  if (bucket) bucket.calendar.push(r);
691
742
  }
@@ -759,6 +810,11 @@ function formatContactLookupResult(contact) {
759
810
  // ============ PERSON SEARCH (CROSS-SOURCE) ============
760
811
 
761
812
  async function personSearch(name, limit = 10) {
813
+ const indexing = stillIndexingMessage();
814
+ if (indexing) {
815
+ return indexing;
816
+ }
817
+
762
818
  // First, try to find the contact to get all their identifiers
763
819
  const contacts = searchContacts(name, 5);
764
820
 
@@ -887,7 +943,7 @@ function formatPersonSearchResults(results) {
887
943
  if (results.messages && results.messages.results && results.messages.results.length > 0) {
888
944
  sections.push(`💬 MESSAGES (${results.messages.results.length}):`);
889
945
  for (const r of results.messages.results.slice(0, 10)) {
890
- sections.push(` • ${r.text.substring(0, 80)}${r.text.length > 80 ? "..." : ""}`);
946
+ sections.push(` • ${(r.text || "").substring(0, 80)}${(r.text || "").length > 80 ? "..." : ""}`);
891
947
  sections.push(` ${r.date}`);
892
948
  }
893
949
  sections.push("");
@@ -916,7 +972,7 @@ function formatPersonSearchResults(results) {
916
972
  // ============ MCP SERVER SETUP ============
917
973
 
918
974
  const server = new Server(
919
- { name: "apple-tools-mcp", version: "2.0.0" },
975
+ { name: "apple-tools-mcp", version: PACKAGE_VERSION },
920
976
  { capabilities: { tools: {} } }
921
977
  );
922
978
 
@@ -1244,7 +1300,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1244
1300
  // Smart search (agentic)
1245
1301
  case "smart_search":
1246
1302
  result = await smartSearch(args.query, {
1247
- limit: args?.limit || 5,
1303
+ limit: validateLimit(args?.limit, 5, 100),
1248
1304
  synthesize: args?.synthesize !== false
1249
1305
  });
1250
1306
  break;
@@ -1252,8 +1308,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1252
1308
  // Email tools
1253
1309
  case "mail_search":
1254
1310
  result = await mailSearch(args.query, {
1255
- limit: args?.limit || 30,
1256
- daysBack: args?.days_back || 0,
1311
+ limit: validateLimit(args?.limit, 30),
1312
+ daysBack: validateDaysBack(args?.days_back),
1257
1313
  sender: args?.sender || null,
1258
1314
  recipient: args?.recipient || null,
1259
1315
  hasAttachment: args?.has_attachment ?? null,
@@ -1266,7 +1322,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1266
1322
  break;
1267
1323
 
1268
1324
  case "mail_recent":
1269
- result = await mailRecent(args?.limit || 30, args?.days_back || 7, args?.unread_only || false, args?.include_junk || false);
1325
+ result = await mailRecent(
1326
+ validateLimit(args?.limit, 30),
1327
+ validateDaysBack(args?.days_back) || 7,
1328
+ args?.unread_only || false,
1329
+ args?.include_junk || false
1330
+ );
1270
1331
  break;
1271
1332
 
1272
1333
  case "mail_date":
@@ -1280,8 +1341,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1280
1341
  // Messages tools
1281
1342
  case "messages_search":
1282
1343
  result = await messagesSearch(args.query, {
1283
- limit: args?.limit || 30,
1284
- daysBack: args?.days_back || 0,
1344
+ limit: validateLimit(args?.limit, 30),
1345
+ daysBack: validateDaysBack(args?.days_back),
1285
1346
  contact: args?.contact || null,
1286
1347
  groupChatOnly: args?.group_chat_only || false,
1287
1348
  groupChatName: args?.group_chat_name || null,
@@ -1291,19 +1352,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1291
1352
  break;
1292
1353
 
1293
1354
  case "messages_recent":
1294
- result = await messagesRecent(args?.limit || 30, args?.days_back || 1);
1355
+ result = await messagesRecent(
1356
+ validateLimit(args?.limit, 30),
1357
+ validateDaysBack(args?.days_back) || 1
1358
+ );
1295
1359
  break;
1296
1360
 
1297
1361
  case "messages_conversation":
1298
- result = await messagesConversation(args.contact, args?.limit || 50);
1362
+ result = await messagesConversation(args.contact, validateLimit(args?.limit, 50));
1299
1363
  break;
1300
1364
 
1301
1365
  // Calendar tools
1302
1366
  case "calendar_search":
1303
1367
  result = await calendarSearch(args.query, {
1304
- limit: args?.limit || 30,
1305
- daysBack: args?.days_back || 0,
1306
- daysAhead: args?.days_ahead || 0,
1368
+ limit: validateLimit(args?.limit, 30),
1369
+ daysBack: validateDaysBack(args?.days_back),
1370
+ daysAhead: validateDaysBack(args?.days_ahead),
1307
1371
  calendarName: args?.calendar_name || null,
1308
1372
  allDayOnly: args?.all_day_only || false,
1309
1373
  sortBy: args?.sort_by || "relevance"
@@ -1326,32 +1390,45 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1326
1390
 
1327
1391
  // Mail tools
1328
1392
  case "mail_senders":
1329
- result = formatSendersResults(await getFrequentSenders(args?.limit || 30, args?.days_back || 0, args?.include_junk || false));
1393
+ {
1394
+ const indexing = stillIndexingMessage();
1395
+ if (indexing) {
1396
+ result = indexing;
1397
+ break;
1398
+ }
1399
+ }
1400
+ if (!(await isIndexReady("emails"))) {
1401
+ result = indexUnavailableMessage("emails");
1402
+ break;
1403
+ }
1404
+ result = formatSendersResults(await getFrequentSenders(
1405
+ validateLimit(args?.limit, 30),
1406
+ validateDaysBack(args?.days_back),
1407
+ args?.include_junk || false
1408
+ ));
1330
1409
  break;
1331
1410
 
1332
1411
  case "rebuild_index":
1333
1412
  // Check if indexing is already in progress in this session
1334
1413
  if (indexingInProgress) {
1335
- result = "⏳ Indexing is already in progress. Please wait for it to complete before starting a rebuild.";
1414
+ result = "Indexing is already in progress. Please wait for it to complete before starting a rebuild.";
1336
1415
  break;
1337
1416
  }
1338
1417
 
1339
1418
  // Acquire lock to prevent parallel rebuilds across multiple MCP instances
1340
1419
  if (!acquireLock()) {
1341
- result = "Another indexing operation is already in progress in a different session. Please wait for it to complete.";
1420
+ result = "Indexing is already in progress in a different session. Please wait for it to complete before starting a rebuild.";
1342
1421
  break;
1343
1422
  }
1344
1423
 
1345
1424
  // Start rebuild in background and return immediately
1346
1425
  indexingInProgress = true;
1426
+ sessionIndexComplete = false;
1347
1427
  const rebuildSources = args?.sources || ["emails", "messages", "calendar"];
1348
1428
 
1349
1429
  // Fire and forget - don't await
1350
1430
  rebuildIndex(rebuildSources).then((rebuildResult) => {
1351
- sessionIndexComplete = true;
1352
- isFirstEverRun = false;
1353
- indexingInProgress = false;
1354
- releaseLock();
1431
+ applyCycleEnd(true);
1355
1432
  console.error("Index rebuild completed:", JSON.stringify({
1356
1433
  cleared: rebuildResult.cleared,
1357
1434
  indexed: Object.fromEntries(
@@ -1361,8 +1438,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1361
1438
  }));
1362
1439
  }).catch(e => {
1363
1440
  console.error("Index rebuild error:", e.message);
1364
- indexingInProgress = false;
1365
- releaseLock();
1441
+ applyCycleEnd(false);
1366
1442
  });
1367
1443
 
1368
1444
  result = `🔄 Index rebuild started for: ${rebuildSources.join(", ")}.\n\nThis runs in the background and may take several minutes for large mailboxes. You can continue using other tools - searches will use the new index once complete.`;
@@ -1381,34 +1457,45 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1381
1457
 
1382
1458
  // Messages tools
1383
1459
  case "messages_contacts":
1384
- result = formatMessageContactsResults(getMessageContacts(args?.limit || 50));
1460
+ result = formatMessageContactsResults(getMessageContacts(validateLimit(args?.limit, 50, 500)));
1385
1461
  break;
1386
1462
 
1387
1463
  // Calendar tools
1388
1464
  case "calendar_upcoming":
1389
- result = formatUpcomingEventsResults(getUpcomingEvents(args?.limit || 30));
1465
+ result = formatUpcomingEventsResults(getUpcomingEvents(validateLimit(args?.limit, 30, 100)));
1390
1466
  break;
1391
1467
 
1392
1468
  // ============ NEW TOOLS - PHASE 2 ============
1393
1469
 
1394
1470
  case "calendar_week":
1395
- result = formatWeekEventsResults(getWeekEvents(args?.week_offset || 0));
1471
+ result = formatWeekEventsResults(getWeekEvents(validateWeekOffset(args?.week_offset)));
1396
1472
  break;
1397
1473
 
1398
1474
  // ============ NEW TOOLS - PHASE 3 ============
1399
1475
 
1400
1476
  case "mail_thread":
1401
- result = formatEmailThreadResults(await getEmailThread(args.file_path, args?.limit || 30));
1477
+ {
1478
+ const indexing = stillIndexingMessage();
1479
+ if (indexing) {
1480
+ result = indexing;
1481
+ break;
1482
+ }
1483
+ }
1484
+ if (!(await isIndexReady("emails"))) {
1485
+ result = indexUnavailableMessage("emails");
1486
+ break;
1487
+ }
1488
+ result = formatEmailThreadResults(await getEmailThread(args.file_path, validateLimit(args?.limit, 30)));
1402
1489
  break;
1403
1490
 
1404
1491
  case "calendar_recurring":
1405
- result = formatRecurringEventsResults(getRecurringEvents(args?.limit || 30));
1492
+ result = formatRecurringEventsResults(getRecurringEvents(validateLimit(args?.limit, 30, 100)));
1406
1493
  break;
1407
1494
 
1408
1495
  // ============ CONTACTS TOOLS ============
1409
1496
 
1410
1497
  case "contacts_search":
1411
- result = formatContactsSearchResults(searchContacts(args.query, args?.limit || 30));
1498
+ result = formatContactsSearchResults(searchContacts(args.query, validateLimit(args?.limit, 30)));
1412
1499
  break;
1413
1500
 
1414
1501
  case "contacts_lookup":
@@ -1416,7 +1503,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1416
1503
  break;
1417
1504
 
1418
1505
  case "person_search":
1419
- result = await personSearch(args.name, args?.limit || 10);
1506
+ result = await personSearch(args.name, validateLimit(args?.limit, 10));
1420
1507
  break;
1421
1508
 
1422
1509
  default:
@@ -1436,7 +1523,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1436
1523
  async function main() {
1437
1524
  const transport = new StdioServerTransport();
1438
1525
  await server.connect(transport);
1439
- console.error("Apple Tools MCP server running (v1.1.1)");
1526
+ console.error(`Apple Tools MCP server running (v${PACKAGE_VERSION})`);
1440
1527
  // Background indexing runs automatically on startup and every INDEX_INTERVAL
1441
1528
  }
1442
1529
 
package/indexer.js CHANGED
@@ -9,9 +9,13 @@ import {
9
9
  validateLimit,
10
10
  validateLanceDBId,
11
11
  escapeSQL,
12
- stripHtmlTags
12
+ stripHtmlTags,
13
+ toUnixMillis,
14
+ unfoldRfc822Headers,
15
+ stripSubjectPrefixes
13
16
  } from "./lib/validators.js";
14
17
  import { safeSqlite3Json, safeOsascript, safeFind } from "./lib/shell.js";
18
+ import { indexUnavailableMessage } from "./lib/indexGate.js";
15
19
 
16
20
  // Re-export contact functions for use by other modules
17
21
  export {
@@ -252,14 +256,17 @@ function parseEmlx(filePath) {
252
256
  content = lines.slice(1).join("\n");
253
257
  }
254
258
 
259
+ // Unfold RFC 822 wrapped headers so long From/Subject/To values are complete
260
+ const unfolded = unfoldRfc822Headers(content);
261
+
255
262
  // Extract headers
256
- const fromMatch = content.match(/^From:\s*(.+)$/m);
257
- const subjectMatch = content.match(/^Subject:\s*(.+)$/m);
258
- const dateMatch = content.match(/^Date:\s*(.+)$/m);
259
- const toMatch = content.match(/^To:\s*(.+)$/m);
260
- const ccMatch = content.match(/^Cc:\s*(.+)$/m);
261
- const messageIdMatch = content.match(/^Message-ID:\s*(.+)$/im);
262
- const flaggedMatch = content.match(/^X-Flagged:\s*(.+)$/im) || content.match(/flags.*flagged/i);
263
+ const fromMatch = unfolded.match(/^From:\s*(.+)$/m);
264
+ const subjectMatch = unfolded.match(/^Subject:\s*(.+)$/m);
265
+ const dateMatch = unfolded.match(/^Date:\s*(.+)$/m);
266
+ const toMatch = unfolded.match(/^To:\s*(.+)$/m);
267
+ const ccMatch = unfolded.match(/^Cc:\s*(.+)$/m);
268
+ const messageIdMatch = unfolded.match(/^Message-ID:\s*(.+)$/im);
269
+ const flaggedMatch = unfolded.match(/^X-Flagged:\s*(.+)$/im) || unfolded.match(/flags.*flagged/i);
263
270
 
264
271
  // Check for attachments
265
272
  const hasAttachment = /Content-Disposition:\s*attachment/i.test(content) ||
@@ -335,13 +342,10 @@ function parseEmlx(filePath) {
335
342
  // Full scan - used for first run and rebuild_index
336
343
  // Includes both .emlx and .partial.emlx files (partial = not fully downloaded via IMAP)
337
344
  async function findAllEmlxFiles() {
338
- try {
339
- // "*.emlx" also matches "*.partial.emlx"
340
- return safeFind(MAIL_DIR, { name: "*.emlx", type: "f" });
341
- } catch (e) {
342
- console.error("Error finding emlx files:", e.message);
343
- return [];
344
- }
345
+ // "*.emlx" also matches "*.partial.emlx"
346
+ // Let permission/IO errors propagate so indexEmails does not persist a
347
+ // timestamp for an empty scan and skip real mail forever.
348
+ return safeFind(MAIL_DIR, { name: "*.emlx", type: "f" });
345
349
  }
346
350
 
347
351
  // Fast incremental scan - uses find with -mtime filter (more reliable than mdfind/Spotlight)
@@ -579,6 +583,9 @@ function getMessages(sinceTimestamp = null) {
579
583
  ORDER BY m.date DESC
580
584
  `;
581
585
  const results = safeSqlite3Json(MESSAGES_DB, query, { timeout: 60000 });
586
+ if (!Array.isArray(results)) {
587
+ throw new Error("Unexpected sqlite3 result for messages");
588
+ }
582
589
 
583
590
  // Post-process: extract text from attributedBody where text is NULL
584
591
  let extractedCount = 0;
@@ -607,7 +614,7 @@ function getMessages(sinceTimestamp = null) {
607
614
  return results.filter(msg => msg.text && msg.text.trim() !== '');
608
615
  } catch (e) {
609
616
  console.error("Error reading messages:", e.message);
610
- return [];
617
+ throw e;
611
618
  }
612
619
  }
613
620
 
@@ -645,19 +652,24 @@ function getParticipantStatus(status) {
645
652
  }
646
653
 
647
654
  function getCalendarEvents() {
648
- try {
649
- // NOTE: We index ALL calendar events, not filtered by date
650
- // Calendar events don't have a "file modification time" like emails do,
651
- // so we can't use the mdfind + DAYS_BACK approach.
652
- // Calendar indexing is always comprehensive - filtering by date would lose historical context.
653
- const now = Date.now();
654
- const pastDate = unixMsToMacAbsolute(now - 10 * 365 * 24 * 60 * 60 * 1000); // 10 years back
655
- const futureDate = unixMsToMacAbsolute(now + 10 * 365 * 24 * 60 * 60 * 1000); // 10 years ahead
656
-
657
- // Query OccurrenceCache for recurring events and their calculated occurrences
658
- // This includes both recurring and non-recurring events
659
- // GROUP BY to avoid duplicate entries for recurring events
660
- const query = `
655
+ // NOTE: We index ALL calendar events, not filtered by date
656
+ // Calendar events don't have a "file modification time" like emails do,
657
+ // so we can't use the mdfind + DAYS_BACK approach.
658
+ // Calendar indexing is always comprehensive - filtering by date would lose historical context.
659
+ // Must throw on source-read failure: a silent [] would look like "every event
660
+ // was deleted" and the stale-entry pass would wipe the calendar index.
661
+ if (!fs.existsSync(CALENDAR_DB)) {
662
+ throw new Error("Calendar database not found");
663
+ }
664
+
665
+ const now = Date.now();
666
+ const pastDate = unixMsToMacAbsolute(now - 10 * 365 * 24 * 60 * 60 * 1000); // 10 years back
667
+ const futureDate = unixMsToMacAbsolute(now + 10 * 365 * 24 * 60 * 60 * 1000); // 10 years ahead
668
+
669
+ // Query OccurrenceCache for recurring events and their calculated occurrences
670
+ // This includes both recurring and non-recurring events
671
+ // GROUP BY to avoid duplicate entries for recurring events
672
+ const query = `
661
673
  SELECT
662
674
  ci.ROWID as id,
663
675
  ci.summary,
@@ -679,10 +691,7 @@ function getCalendarEvents() {
679
691
  ORDER BY MIN(oc.day) ASC
680
692
  `;
681
693
 
682
- const rows = safeSqlite3Json(CALENDAR_DB, query, { timeout: 30000 });
683
-
684
- // Get attendees for events that have them (separate query for efficiency)
685
- const attendeesQuery = `
694
+ const attendeesQuery = `
686
695
  SELECT
687
696
  p.owner_id,
688
697
  COALESCE(i.display_name, p.email, 'Unknown') as name,
@@ -692,11 +701,13 @@ function getCalendarEvents() {
692
701
  WHERE p.entity_type = 0
693
702
  `;
694
703
 
704
+ return withCalendarCopy((dbPath) => {
705
+ const rows = safeSqlite3Json(dbPath, query, { timeout: 30000 });
706
+
695
707
  let attendeesMap = new Map();
696
708
  try {
697
- const attendeesRows = safeSqlite3Json(CALENDAR_DB, attendeesQuery, { timeout: 10000 });
709
+ const attendeesRows = safeSqlite3Json(dbPath, attendeesQuery, { timeout: 10000 });
698
710
 
699
- // Group attendees by owner_id (event id)
700
711
  for (const att of attendeesRows) {
701
712
  if (!attendeesMap.has(att.owner_id)) {
702
713
  attendeesMap.set(att.owner_id, []);
@@ -734,10 +745,7 @@ function getCalendarEvents() {
734
745
 
735
746
  console.error(`Calendar: Retrieved ${events.length} events via SQLite (~${Math.round((Date.now() - now))}ms)`);
736
747
  return events;
737
- } catch (e) {
738
- console.error("Error reading calendar:", e.message);
739
- return [];
740
- }
748
+ });
741
749
  }
742
750
 
743
751
  // ============ DATABASE FUNCTIONS ============
@@ -927,7 +935,13 @@ export async function indexEmails(progressCallback = null, forceFullScan = false
927
935
  // Use fast incremental scan if we have a previous timestamp
928
936
  const startTime = Date.now();
929
937
  console.error(`Calling findNewEmlxFiles with timestamp: ${lastEmailIndexTime ? new Date(lastEmailIndexTime).toISOString() : 'null (full scan)'}`);
930
- const newFiles = await findNewEmlxFiles(lastEmailIndexTime);
938
+ let newFiles;
939
+ try {
940
+ newFiles = await findNewEmlxFiles(lastEmailIndexTime);
941
+ } catch (e) {
942
+ console.error(`Email source read failed; skipping this cycle so the lookback timestamp is not advanced: ${e.message}`);
943
+ return { indexed: 0, added: 0, error: e.message };
944
+ }
931
945
  console.error(`Found ${newFiles.length} new/modified email files (${Date.now() - startTime}ms)`);
932
946
 
933
947
  const indexedPaths = await getIndexedIdsWithRetry("emails", "filePath");
@@ -1041,6 +1055,13 @@ export async function indexEmails(progressCallback = null, forceFullScan = false
1041
1055
  if (uniqueRecords.length > 0) {
1042
1056
  if (!tables.emails) {
1043
1057
  tables.emails = await db.createTable("emails", uniqueRecords, { mode: "overwrite" });
1058
+ // Track messageIds from the first batch so later batches can
1059
+ // dedupe IMAP copies (INBOX vs Junk) of the same Message-ID.
1060
+ for (const record of uniqueRecords) {
1061
+ if (record.messageId) {
1062
+ indexedMessageIds.add(record.messageId);
1063
+ }
1064
+ }
1044
1065
  } else {
1045
1066
  // Double-check: verify these IDs truly aren't in the index
1046
1067
  const currentIndexed = await getIndexedIdsWithRetry("emails", "filePath");
@@ -1130,7 +1151,13 @@ export async function indexMessages(forceFullScan = false) {
1130
1151
  const indexStartTime = Date.now();
1131
1152
 
1132
1153
  // Use incremental scan if we have a previous timestamp
1133
- const messages = getMessages(lastMessageIndexTime);
1154
+ let messages;
1155
+ try {
1156
+ messages = getMessages(lastMessageIndexTime);
1157
+ } catch (e) {
1158
+ console.error(`Messages source read failed; skipping this cycle so the lookback timestamp is not advanced: ${e.message}`);
1159
+ return { indexed: 0, added: 0, error: e.message };
1160
+ }
1134
1161
  console.error(`Found ${messages.length} messages${lastMessageIndexTime ? ' (incremental)' : ' (full scan)'}`);
1135
1162
 
1136
1163
  const indexed = await getIndexedIdsWithRetry("messages", "id");
@@ -1186,7 +1213,7 @@ export async function indexMessages(forceFullScan = false) {
1186
1213
  return {
1187
1214
  id: String(msg.id),
1188
1215
  date: msg.date,
1189
- dateTimestamp: msg.dateTimestamp || 0,
1216
+ dateTimestamp: toUnixMillis(msg.dateTimestamp),
1190
1217
  sender: msg.sender,
1191
1218
  text: msg.text?.substring(0, 500) || "",
1192
1219
  chatId: String(msg.chatId || ""),
@@ -1270,7 +1297,15 @@ export async function indexMessages(forceFullScan = false) {
1270
1297
  export async function indexCalendar() {
1271
1298
  await initDB();
1272
1299
 
1273
- const events = getCalendarEvents();
1300
+ let events;
1301
+ try {
1302
+ events = getCalendarEvents();
1303
+ } catch (e) {
1304
+ // Do not treat a source-read failure as "zero events" — that would mark
1305
+ // every indexed row stale and delete the calendar index.
1306
+ console.error(`Calendar source read failed; skipping index update to avoid data loss: ${e.message}`);
1307
+ return { indexed: 0, added: 0, removed: 0, error: e.message };
1308
+ }
1274
1309
  console.error(`Found ${events.length} calendar events`);
1275
1310
 
1276
1311
  // Get already indexed event IDs for incremental indexing
@@ -1493,14 +1528,14 @@ export async function getRecentMessages(limit = 10, daysBack = 1) {
1493
1528
  if (!tables.messages) return { messages: [], hasMore: false };
1494
1529
 
1495
1530
  try {
1496
- const cutoff = Date.now() / 1000 - (daysBack * 24 * 60 * 60); // Messages use Unix timestamp
1531
+ const cutoff = Date.now() - (daysBack * 24 * 60 * 60 * 1000);
1497
1532
  const results = await tables.messages.query()
1498
1533
  .select(["id", "date", "dateTimestamp", "sender", "text", "chatId", "isGroupChat"])
1499
1534
  .toArray();
1500
1535
 
1501
1536
  const filtered = results
1502
- .filter(r => r.dateTimestamp >= cutoff)
1503
- .sort((a, b) => b.dateTimestamp - a.dateTimestamp);
1537
+ .filter(r => toUnixMillis(r.dateTimestamp) >= cutoff)
1538
+ .sort((a, b) => toUnixMillis(b.dateTimestamp) - toUnixMillis(a.dateTimestamp));
1504
1539
 
1505
1540
  const hasMore = filtered.length > limit;
1506
1541
  const messages = filtered.slice(0, limit);
@@ -1530,7 +1565,7 @@ export async function getConversation(contact, limit = 50) {
1530
1565
 
1531
1566
  // Sort chronologically (oldest first for conversation view)
1532
1567
  return filtered
1533
- .sort((a, b) => a.dateTimestamp - b.dateTimestamp)
1568
+ .sort((a, b) => toUnixMillis(a.dateTimestamp) - toUnixMillis(b.dateTimestamp))
1534
1569
  .slice(-limit); // Take last N messages
1535
1570
  } catch (e) {
1536
1571
  console.error("Error getting conversation:", e.message);
@@ -1648,7 +1683,7 @@ export function getMessageContacts(limit = 50) {
1648
1683
  return safeSqlite3Json(MESSAGES_DB, query, { timeout: 30000 });
1649
1684
  } catch (e) {
1650
1685
  console.error("Error getting message contacts:", e.message);
1651
- return [];
1686
+ return { error: e.message };
1652
1687
  }
1653
1688
  }
1654
1689
 
@@ -1678,13 +1713,15 @@ export function getUpcomingEvents(limit = 10) {
1678
1713
  ORDER BY sort_day ASC
1679
1714
  LIMIT ${fetchLimit}
1680
1715
  `;
1681
- const events = safeSqlite3Json(CALENDAR_DB, query, { timeout: 10000 });
1716
+ const events = withCalendarCopy((dbPath) => {
1717
+ return safeSqlite3Json(dbPath, query, { timeout: 10000 });
1718
+ });
1682
1719
  const hasMore = events.length > safeLimit;
1683
1720
  const limitedEvents = events.slice(0, safeLimit);
1684
1721
  return { events: limitedEvents, showing: limitedEvents.length, hasMore };
1685
1722
  } catch (e) {
1686
1723
  console.error("Error getting upcoming events:", e.message);
1687
- return { events: [], showing: 0, hasMore: false };
1724
+ return { events: [], showing: 0, hasMore: false, error: e.message };
1688
1725
  }
1689
1726
  }
1690
1727
 
@@ -1876,7 +1913,9 @@ export function getWeekEvents(weekOffset = 0) {
1876
1913
  AND ci.summary IS NOT NULL AND ci.summary <> ''
1877
1914
  ORDER BY oc.day ASC
1878
1915
  `;
1879
- const events = safeSqlite3Json(CALENDAR_DB, query, { timeout: 15000 });
1916
+ const events = withCalendarCopy((dbPath) => {
1917
+ return safeSqlite3Json(dbPath, query, { timeout: 15000 });
1918
+ });
1880
1919
 
1881
1920
  const weekStart = monday.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
1882
1921
  const weekEnd = sunday.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
@@ -1898,7 +1937,7 @@ export function getWeekEvents(weekOffset = 0) {
1898
1937
  // Uses subject-based matching since Message-ID isn't indexed
1899
1938
  export async function getEmailThread(filePath, limit = 20) {
1900
1939
  await initDB();
1901
- if (!tables.emails) return { error: "Email index not ready", emails: [] };
1940
+ if (!tables.emails) return { error: indexUnavailableMessage("emails"), emails: [] };
1902
1941
 
1903
1942
  try {
1904
1943
  // Validate file path to prevent path traversal attacks
@@ -1915,15 +1954,13 @@ export async function getEmailThread(filePath, limit = 20) {
1915
1954
  }
1916
1955
 
1917
1956
  // Read the email to get subject
1918
- const content = fs.readFileSync(validatedPath, "utf-8");
1957
+ const content = unfoldRfc822Headers(fs.readFileSync(validatedPath, "utf-8"));
1919
1958
  const subjectMatch = content.match(/^Subject:\s*(.+)$/m);
1920
1959
  if (!subjectMatch) {
1921
1960
  return { error: "Could not extract subject from email", emails: [] };
1922
1961
  }
1923
1962
 
1924
- // Clean subject - remove Re:, Fwd:, etc.
1925
- let subject = subjectMatch[1].trim();
1926
- const baseSubject = subject.replace(/^(Re|Fwd|Fw):\s*/gi, "").trim();
1963
+ const baseSubject = stripSubjectPrefixes(subjectMatch[1]);
1927
1964
 
1928
1965
  if (baseSubject.length < 5) {
1929
1966
  return { error: "Subject too short to find thread", emails: [] };
@@ -1937,7 +1974,7 @@ export async function getEmailThread(filePath, limit = 20) {
1937
1974
  // Filter to emails with matching base subject
1938
1975
  const threadEmails = allEmails
1939
1976
  .filter(e => {
1940
- const eBaseSubject = (e.subject || "").replace(/^(Re|Fwd|Fw):\s*/gi, "").trim();
1977
+ const eBaseSubject = stripSubjectPrefixes(e.subject || "");
1941
1978
  return eBaseSubject.toLowerCase() === baseSubject.toLowerCase();
1942
1979
  })
1943
1980
  .sort((a, b) => a.dateTimestamp - b.dateTimestamp)
@@ -1981,12 +2018,14 @@ export function getRecurringEvents(limit = 20) {
1981
2018
  ORDER BY occurrenceCount DESC, MIN(oc.day) ASC
1982
2019
  LIMIT ${fetchLimit}
1983
2020
  `;
1984
- const events = safeSqlite3Json(CALENDAR_DB, query, { timeout: 30000 });
2021
+ const events = withCalendarCopy((dbPath) => {
2022
+ return safeSqlite3Json(dbPath, query, { timeout: 30000 });
2023
+ });
1985
2024
  const hasMore = events.length > safeLimit;
1986
2025
  const limitedEvents = events.slice(0, safeLimit);
1987
2026
  return { events: limitedEvents, showing: limitedEvents.length, hasMore };
1988
2027
  } catch (e) {
1989
2028
  console.error("Error getting recurring events:", e.message);
1990
- return { events: [], showing: 0, hasMore: false };
2029
+ return { events: [], showing: 0, hasMore: false, error: e.message };
1991
2030
  }
1992
2031
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Index-session search gating.
3
+ *
4
+ * sessionIndexComplete means "this process finished (or abandoned) its own
5
+ * index cycle." A second MCP instance that lost the indexer lock never runs
6
+ * a cycle, so that flag stays false. Lost-lock must not be treated as
7
+ * "still indexing" — callers still check isIndexReady() for a missing index.
8
+ */
9
+
10
+ /**
11
+ * Whether index-backed tools should return the still-indexing message.
12
+ *
13
+ * @param {boolean} sessionIndexComplete
14
+ * @param {boolean} ownsIndexLock true if this process won/holds the indexer lock
15
+ * @returns {boolean}
16
+ */
17
+ export function isSearchBlockedByIndexing(sessionIndexComplete, ownsIndexLock) {
18
+ return Boolean(ownsIndexLock) && !sessionIndexComplete;
19
+ }
20
+
21
+ /**
22
+ * In-memory flags after an index or rebuild cycle ends.
23
+ * Searches must be unblocked on both success and failure.
24
+ *
25
+ * @param {boolean} success
26
+ * @returns {{ indexingInProgress: false, sessionIndexComplete: true, ownsIndexLock: false, isFirstEverRun?: false }}
27
+ */
28
+ export function cycleEndFlags(success) {
29
+ const flags = {
30
+ indexingInProgress: false,
31
+ sessionIndexComplete: true,
32
+ ownsIndexLock: false
33
+ };
34
+ if (success) {
35
+ flags.isFirstEverRun = false;
36
+ }
37
+ return flags;
38
+ }
39
+
40
+ /**
41
+ * User-facing message when a source table is missing.
42
+ * Distinct from still-indexing: retry because the index is not there yet,
43
+ * not because this process is mid-cycle.
44
+ *
45
+ * @param {"emails"|"messages"|"calendar"|undefined} type
46
+ * @returns {string}
47
+ */
48
+ export function indexUnavailableMessage(type) {
49
+ if (type === "messages") {
50
+ return "Messages index not available. Please try again shortly.";
51
+ }
52
+ if (type === "calendar") {
53
+ return "Calendar index not available. Please try again shortly.";
54
+ }
55
+ if (type === "emails") {
56
+ return "Email index not available. Please try again shortly.";
57
+ }
58
+ return "Index not available. Please try again shortly.";
59
+ }
package/lib/shell.js CHANGED
@@ -93,8 +93,7 @@ export function safeSqlite3Json(dbPath, query, options = {}) {
93
93
  try {
94
94
  return JSON.parse(output);
95
95
  } catch (e) {
96
- console.error('Failed to parse sqlite3 JSON output:', e.message);
97
- return [];
96
+ throw new Error(`Failed to parse sqlite3 JSON output: ${e.message}`);
98
97
  }
99
98
  }
100
99
 
@@ -267,12 +266,20 @@ export function safeFind(searchPath, options = {}) {
267
266
  throw result.error;
268
267
  }
269
268
 
270
- // find may return non-zero if some paths are inaccessible
271
- // We still want to return whatever paths it found
272
- return result.stdout
269
+ const paths = (result.stdout || '')
273
270
  .split('\n')
274
271
  .map(line => line.trim())
275
272
  .filter(line => line.length > 0);
273
+
274
+ // Partial permission errors can still yield some paths; keep those.
275
+ // A complete failure (no paths + non-zero status) must not be treated as
276
+ // "zero files" — callers persist index timestamps on empty scans.
277
+ if (result.status !== 0 && paths.length === 0) {
278
+ const errorMsg = (result.stderr || '').trim() || `find exited with code ${result.status}`;
279
+ throw new Error(errorMsg);
280
+ }
281
+
282
+ return paths;
276
283
  }
277
284
 
278
285
  // ============ GENERIC SAFE SPAWN ============
package/lib/validators.js CHANGED
@@ -118,10 +118,14 @@ export function escapeAppleScript(str) {
118
118
  if (!str || typeof str !== 'string') {
119
119
  return '';
120
120
  }
121
- // Escape backslashes first, then double quotes
121
+ // Escape backslashes first, then quotes, then line breaks.
122
+ // Unescaped CR/LF inside an AppleScript double-quoted string can close the
123
+ // string and inject additional statements.
122
124
  return str
123
125
  .replace(/\\/g, '\\\\')
124
- .replace(/"/g, '\\"');
126
+ .replace(/"/g, '\\"')
127
+ .replace(/\r/g, '\\r')
128
+ .replace(/\n/g, '\\n');
125
129
  }
126
130
 
127
131
  /**
@@ -407,6 +411,64 @@ export function stripHtmlTags(html, maxLength = 100000) {
407
411
  */
408
412
  const MAC_ABSOLUTE_EPOCH = 978307200;
409
413
 
414
+ /**
415
+ * Normalize a Unix timestamp to milliseconds.
416
+ * Messages chat.db values are stored as seconds; email/calendar timestamps
417
+ * are milliseconds. Treating seconds as ms drops every date-filtered message.
418
+ *
419
+ * Threshold 1e11 ms is 1973-03-03 — well before any real mail/message date,
420
+ * and far above Unix seconds for year 2100 (~4e9).
421
+ *
422
+ * @param {number|string|null|undefined} ts
423
+ * @returns {number} Timestamp in milliseconds, or 0 if invalid
424
+ */
425
+ export function toUnixMillis(ts) {
426
+ if (ts === null || ts === undefined || ts === '') {
427
+ return 0;
428
+ }
429
+ const n = Number(ts);
430
+ if (!Number.isFinite(n) || n <= 0) {
431
+ return 0;
432
+ }
433
+ return n < 1e11 ? n * 1000 : n;
434
+ }
435
+
436
+ /**
437
+ * Unfold RFC 822 wrapped header lines (CRLF + WSP continuation) so
438
+ * From/Subject/To matchers see the full header value.
439
+ *
440
+ * @param {string} content - Raw email content (headers + body)
441
+ * @returns {string} Content with folded headers joined into single lines
442
+ */
443
+ export function unfoldRfc822Headers(content) {
444
+ if (!content || typeof content !== 'string') {
445
+ return '';
446
+ }
447
+ const split = content.search(/\r?\n\r?\n/);
448
+ const headers = split >= 0 ? content.slice(0, split) : content;
449
+ const body = split >= 0 ? content.slice(split) : '';
450
+ return headers.replace(/\r?\n[ \t]+/g, ' ') + body;
451
+ }
452
+
453
+ /**
454
+ * Strip repeated Re:/Fwd:/Fw: prefixes from an email subject for thread matching.
455
+ *
456
+ * @param {string} subject
457
+ * @returns {string}
458
+ */
459
+ export function stripSubjectPrefixes(subject) {
460
+ if (!subject || typeof subject !== 'string') {
461
+ return '';
462
+ }
463
+ let s = subject.trim();
464
+ let prev;
465
+ do {
466
+ prev = s;
467
+ s = s.replace(/^(re|fwd|fw)\s*:\s*/i, '').trim();
468
+ } while (s !== prev);
469
+ return s;
470
+ }
471
+
410
472
  /**
411
473
  * Convert Mac Absolute Time to Unix timestamp (milliseconds)
412
474
  * Handles both seconds and nanoseconds formats
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "MCP server for semantic search across Apple Mail, Messages, and Calendar",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/search.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import * as lancedb from "@lancedb/lancedb";
2
2
  import * as chrono from "chrono-node";
3
3
  import { safeOsascript } from "./lib/shell.js";
4
- import { safeMatch, validateSearchQuery } from "./lib/validators.js";
4
+ import { safeMatch, validateSearchQuery, toUnixMillis } from "./lib/validators.js";
5
5
  import { embed, INDEX_DIR, getRecentEmails, getEmailsByDateRange, getRecentMessages, getConversation, getEventsOnDate, resolveEmail, resolvePhone, formatContact } from "./indexer.js";
6
+ import { indexUnavailableMessage } from "./lib/indexGate.js";
6
7
 
7
8
  let db = null;
8
9
  let tables = {};
@@ -102,13 +103,15 @@ function resolvePronouns(query) {
102
103
  return query;
103
104
  }
104
105
 
105
- const pronounPattern = /\b(they|them|their|he|him|his|she|her|hers)\b/gi;
106
-
107
- if (pronounPattern.test(query) && queryContext.lastPerson) {
108
- return query.replace(pronounPattern, queryContext.lastPerson);
106
+ if (!queryContext.lastPerson) {
107
+ return query;
109
108
  }
110
109
 
111
- return query;
110
+ // Never use RegExp#test with a /g regex — lastIndex is stateful and can
111
+ // skip the first (or only) pronoun on this or a later call. A fresh
112
+ // regex plus replace() is lastIndex-safe; do not hoist or test() it.
113
+ const pronounRe = new RegExp('\\b(they|them|their|he|him|his|she|her|hers)\\b', 'gi');
114
+ return query.replace(pronounRe, queryContext.lastPerson);
112
115
  }
113
116
 
114
117
  // Extract entities (people, dates) from natural language query and convert to filters
@@ -525,8 +528,10 @@ export function getDateRange(dateStr) {
525
528
  const start = parseNaturalDate(dateStr);
526
529
  if (!start) return null;
527
530
 
528
- const end = start + (24 * 60 * 60 * 1000); // End of day
529
- return { start, end };
531
+ // Local next midnight — not +24h, which is wrong on DST transition days
532
+ const endDate = new Date(start);
533
+ endDate.setDate(endDate.getDate() + 1);
534
+ return { start, end: endDate.getTime() };
530
535
  }
531
536
 
532
537
  // Parse various date formats and return timestamp (for filtering results)
@@ -559,7 +564,7 @@ function filterByDateRange(results, daysBack, daysAhead, dateField = "date") {
559
564
  if (daysBack === 0 && daysAhead === 0) return results;
560
565
 
561
566
  return results.filter(r => {
562
- const ts = r.dateTimestamp || parseDate(r[dateField]);
567
+ const ts = toUnixMillis(r.dateTimestamp) || parseDate(r[dateField]);
563
568
  if (!ts) return daysBack === 0 && daysAhead === 0;
564
569
  return ts >= cutoffPast && ts <= cutoffFuture;
565
570
  });
@@ -568,8 +573,8 @@ function filterByDateRange(results, daysBack, daysAhead, dateField = "date") {
568
573
  // Sort results by date (newest first)
569
574
  function sortByDate(results, descending = true) {
570
575
  return results.sort((a, b) => {
571
- const tsA = a.dateTimestamp || parseDate(a.date) || parseDate(a.start) || 0;
572
- const tsB = b.dateTimestamp || parseDate(b.date) || parseDate(b.start) || 0;
576
+ const tsA = toUnixMillis(a.dateTimestamp) || parseDate(a.date) || parseDate(a.start) || 0;
577
+ const tsB = toUnixMillis(b.dateTimestamp) || parseDate(b.date) || parseDate(b.start) || 0;
573
578
  return descending ? tsB - tsA : tsA - tsB;
574
579
  });
575
580
  }
@@ -582,7 +587,7 @@ export async function searchEmails(query, options = {}) {
582
587
  try {
583
588
  validatedQuery = validateSearchQuery(query);
584
589
  } catch (e) {
585
- return { results: [], error: e.message };
590
+ return { success: false, results: [], error: e.message };
586
591
  }
587
592
 
588
593
  // Check result cache first
@@ -622,7 +627,7 @@ export async function searchEmails(query, options = {}) {
622
627
  if (!tbl) {
623
628
  return {
624
629
  success: false,
625
- error: "Email index not ready. Please wait for indexing to complete."
630
+ error: indexUnavailableMessage("emails")
626
631
  };
627
632
  }
628
633
 
@@ -714,6 +719,7 @@ export async function searchEmails(query, options = {}) {
714
719
  to: row.to || "Unknown",
715
720
  subject: row.subject || "No subject",
716
721
  date: formatLocalDate(row.date) || "Unknown",
722
+ dateTimestamp: toUnixMillis(row.dateTimestamp) || null,
717
723
  mailbox: row.mailbox || "Unknown",
718
724
  hasAttachment: row.hasAttachment || false,
719
725
  isFlagged: row.isFlagged || false,
@@ -950,7 +956,7 @@ export async function getEmailDateResults(dateStr, includeJunk = false) {
950
956
  }
951
957
 
952
958
  export function formatEmailResults(searchResult) {
953
- if (!searchResult.success) return searchResult.error;
959
+ if (!searchResult.success) return searchResult.error || "Email search failed";
954
960
  if (searchResult.results.length === 0) return searchResult.message;
955
961
 
956
962
  const results = searchResult.results.map(r => {
@@ -965,6 +971,9 @@ export function formatEmailResults(searchResult) {
965
971
  return result + "\n---";
966
972
  }).join("\n");
967
973
 
974
+ if (searchResult.hasMore) {
975
+ return results + `\n\nShowing ${searchResult.showing} results. More matches exist; increase limit to see them.`;
976
+ }
968
977
  return results;
969
978
  }
970
979
 
@@ -976,7 +985,7 @@ export async function searchMessages(query, options = {}) {
976
985
  try {
977
986
  validatedQuery = validateSearchQuery(query);
978
987
  } catch (e) {
979
- return { results: [], error: e.message };
988
+ return { success: false, results: [], error: e.message };
980
989
  }
981
990
 
982
991
  // Check result cache first
@@ -1013,7 +1022,7 @@ export async function searchMessages(query, options = {}) {
1013
1022
  if (!tbl) {
1014
1023
  return {
1015
1024
  success: false,
1016
- error: "Messages index not ready. Please wait for indexing to complete."
1025
+ error: indexUnavailableMessage("messages")
1017
1026
  };
1018
1027
  }
1019
1028
 
@@ -1084,6 +1093,7 @@ export async function searchMessages(query, options = {}) {
1084
1093
  rank: idx + 1,
1085
1094
  score: row._hybridScore ? row._hybridScore.toFixed(3) : (row._distance ? (1 - row._distance).toFixed(3) : "N/A"),
1086
1095
  date: formatLocalDate(row.date) || "Unknown",
1096
+ dateTimestamp: toUnixMillis(row.dateTimestamp) || null,
1087
1097
  sender: sender,
1088
1098
  senderContact: senderContact, // Resolved contact name (if found)
1089
1099
  text: row.text || "",
@@ -1126,6 +1136,7 @@ export async function getRecentMessageResults(limit = 10, daysBack = 1) {
1126
1136
  return {
1127
1137
  rank: idx + 1,
1128
1138
  date: formatLocalDate(row.date) || "Unknown",
1139
+ dateTimestamp: toUnixMillis(row.dateTimestamp) || null,
1129
1140
  sender: sender,
1130
1141
  senderContact: senderContact,
1131
1142
  text: row.text || "",
@@ -1166,7 +1177,7 @@ export async function getConversationResults(contact, limit = 50) {
1166
1177
  }
1167
1178
 
1168
1179
  export function formatMessageResults(searchResult) {
1169
- if (!searchResult.success) return searchResult.error;
1180
+ if (!searchResult.success) return searchResult.error || "Message search failed";
1170
1181
  if (searchResult.results.length === 0) return searchResult.message;
1171
1182
 
1172
1183
  const results = searchResult.results.map(r => {
@@ -1180,6 +1191,9 @@ export function formatMessageResults(searchResult) {
1180
1191
  return result + "\n---";
1181
1192
  }).join("\n");
1182
1193
 
1194
+ if (searchResult.hasMore) {
1195
+ return results + `\n\nShowing ${searchResult.showing} results. More matches exist; increase limit to see them.`;
1196
+ }
1183
1197
  return results;
1184
1198
  }
1185
1199
 
@@ -1202,7 +1216,7 @@ export async function searchCalendar(query, options = {}) {
1202
1216
  try {
1203
1217
  validatedQuery = validateSearchQuery(query);
1204
1218
  } catch (e) {
1205
- return { results: [], error: e.message };
1219
+ return { success: false, results: [], error: e.message };
1206
1220
  }
1207
1221
 
1208
1222
  // Check result cache first
@@ -1238,7 +1252,7 @@ export async function searchCalendar(query, options = {}) {
1238
1252
  if (!tbl) {
1239
1253
  return {
1240
1254
  success: false,
1241
- error: "Calendar index not ready. Please wait for indexing to complete."
1255
+ error: indexUnavailableMessage("calendar")
1242
1256
  };
1243
1257
  }
1244
1258
 
@@ -1498,7 +1512,7 @@ function formatMinutes(minutes) {
1498
1512
  }
1499
1513
 
1500
1514
  export function formatCalendarResults(searchResult) {
1501
- if (!searchResult.success) return searchResult.error;
1515
+ if (!searchResult.success) return searchResult.error || "Calendar search failed";
1502
1516
  if (searchResult.results.length === 0) return searchResult.message;
1503
1517
 
1504
1518
  let header = "";
@@ -1521,6 +1535,9 @@ export function formatCalendarResults(searchResult) {
1521
1535
  return result + "\n---";
1522
1536
  }).join("\n");
1523
1537
 
1538
+ if (searchResult.hasMore) {
1539
+ return header + results + `\n\nShowing ${searchResult.showing} results. More matches exist; increase limit to see them.`;
1540
+ }
1524
1541
  return header + results;
1525
1542
  }
1526
1543
 
@@ -1567,6 +1584,9 @@ export function formatSendersResults(senders) {
1567
1584
 
1568
1585
  // Format messages_contacts results
1569
1586
  export function formatMessageContactsResults(contacts) {
1587
+ if (contacts?.error) {
1588
+ return `Error getting message contacts: ${contacts.error}`;
1589
+ }
1570
1590
  if (!contacts || contacts.length === 0) {
1571
1591
  return "No message contacts found.";
1572
1592
  }
@@ -1581,6 +1601,9 @@ export function formatMessageContactsResults(contacts) {
1581
1601
 
1582
1602
  // Format calendar_upcoming results
1583
1603
  export function formatUpcomingEventsResults(result) {
1604
+ if (result?.error) {
1605
+ return `Error getting upcoming events: ${result.error}`;
1606
+ }
1584
1607
  const events = result.events || result; // Handle both new {events, showing, hasMore} and old array format
1585
1608
  if (!events || events.length === 0) {
1586
1609
  return "No upcoming events found.";
@@ -1650,6 +1673,9 @@ export function formatWeekEventsResults(result) {
1650
1673
  // Format mail_thread results
1651
1674
  export function formatEmailThreadResults(result) {
1652
1675
  if (result.error) {
1676
+ if (result.error === indexUnavailableMessage("emails")) {
1677
+ return result.error;
1678
+ }
1653
1679
  return `Error: ${result.error}`;
1654
1680
  }
1655
1681
 
@@ -1673,6 +1699,9 @@ export function formatEmailThreadResults(result) {
1673
1699
 
1674
1700
  // Format calendar_recurring results
1675
1701
  export function formatRecurringEventsResults(result) {
1702
+ if (result?.error) {
1703
+ return `Error getting recurring events: ${result.error}`;
1704
+ }
1676
1705
  const events = result.events || result; // Handle both new {events, showing, hasMore} and old array format
1677
1706
  if (!events || events.length === 0) {
1678
1707
  return "No recurring events found.";
@@ -1689,4 +1718,4 @@ export function formatRecurringEventsResults(result) {
1689
1718
  }
1690
1719
 
1691
1720
  // Export internal functions for testing
1692
- export { expandQuery, parseNegation, extractKeywords };
1721
+ export { expandQuery, parseNegation, extractKeywords, resolvePronouns, updateContext };