apple-tools-mcp 1.1.1 → 1.1.2

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,7 +8,7 @@ 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
12
 
13
13
  // Lock file to prevent duplicate indexing processes
14
14
  const LOCK_FILE = path.join(process.env.HOME, ".apple-tools-mcp", "indexer.lock");
@@ -234,12 +234,12 @@ function runIndexCycle() {
234
234
  progressCheckTimer = setInterval(() => {
235
235
  const timeSinceProgress = Date.now() - lastProgressTime;
236
236
  if (timeSinceProgress > MAX_NO_PROGRESS_MS) {
237
- console.error(`⚠️ No indexing progress for ${Math.round(timeSinceProgress / 60000)} minutes. Terminating hung process.`);
237
+ console.error(`⚠️ No indexing progress for ${Math.round(timeSinceProgress / 60000)} minutes. Indexing still running; not starting another cycle.`);
238
238
  clearInterval(progressCheckTimer);
239
239
  progressCheckTimer = null;
240
- indexingInProgress = false;
240
+ // Allow searches, but do NOT clear indexingInProgress or release the lock
241
+ // while indexAll() is still running — overlapping writes can corrupt LanceDB.
241
242
  sessionIndexComplete = true;
242
- releaseLock();
243
243
  }
244
244
  }, PROGRESS_CHECK_INTERVAL_MS);
245
245
 
@@ -464,7 +464,7 @@ function readFullEmail(filePath) {
464
464
  return "Email file not found.";
465
465
  }
466
466
 
467
- const content = fs.readFileSync(validatedPath, 'utf-8');
467
+ const content = unfoldRfc822Headers(fs.readFileSync(validatedPath, 'utf-8'));
468
468
 
469
469
  // Parse email headers and body
470
470
  const fromMatch = content.match(/^From:\s*(.+)$/m);
@@ -565,7 +565,7 @@ function formatSmartSearchResults(results, synthesizedGroups = null) {
565
565
  sections.push(` [${r.rank}] Score: ${r.score}`);
566
566
  sections.push(` From: ${r.sender}${r.isGroupChat ? ' (Group)' : ''}`);
567
567
  sections.push(` Date: ${r.date}`);
568
- sections.push(` Text: ${r.text.substring(0, 100)}...`);
568
+ sections.push(` Text: ${(r.text || "").substring(0, 100)}${(r.text || "").length > 100 ? "..." : ""}`);
569
569
  }
570
570
  sections.push("");
571
571
  }
@@ -591,6 +591,10 @@ function formatSmartSearchResults(results, synthesizedGroups = null) {
591
591
 
592
592
  // Smart search - routes to appropriate sources and optionally synthesizes results
593
593
  async function smartSearch(query, options = {}) {
594
+ if (!sessionIndexComplete) {
595
+ return getIndexingMessage();
596
+ }
597
+
594
598
  const { limit = 5, synthesize = true } = options;
595
599
 
596
600
  const sources = detectSources(query);
@@ -671,21 +675,21 @@ function synthesizeResults(mailResults, messageResults, calendarResults) {
671
675
 
672
676
  // Add mail results
673
677
  for (const r of mailResults) {
674
- const ts = r.dateTimestamp || new Date(r.date).getTime();
678
+ const ts = toUnixMillis(r.dateTimestamp) || new Date(r.date).getTime();
675
679
  const bucket = getBucket(ts);
676
680
  if (bucket) bucket.mail.push(r);
677
681
  }
678
682
 
679
683
  // Add message results
680
684
  for (const r of messageResults) {
681
- const ts = r.dateTimestamp || new Date(r.date).getTime();
685
+ const ts = toUnixMillis(r.dateTimestamp) || new Date(r.date).getTime();
682
686
  const bucket = getBucket(ts);
683
687
  if (bucket) bucket.messages.push(r);
684
688
  }
685
689
 
686
690
  // Add calendar results
687
691
  for (const r of calendarResults) {
688
- const ts = r.startTimestamp || new Date(r.start).getTime();
692
+ const ts = toUnixMillis(r.startTimestamp) || new Date(r.start).getTime();
689
693
  const bucket = getBucket(ts);
690
694
  if (bucket) bucket.calendar.push(r);
691
695
  }
@@ -759,6 +763,10 @@ function formatContactLookupResult(contact) {
759
763
  // ============ PERSON SEARCH (CROSS-SOURCE) ============
760
764
 
761
765
  async function personSearch(name, limit = 10) {
766
+ if (!sessionIndexComplete) {
767
+ return getIndexingMessage();
768
+ }
769
+
762
770
  // First, try to find the contact to get all their identifiers
763
771
  const contacts = searchContacts(name, 5);
764
772
 
@@ -887,7 +895,7 @@ function formatPersonSearchResults(results) {
887
895
  if (results.messages && results.messages.results && results.messages.results.length > 0) {
888
896
  sections.push(`💬 MESSAGES (${results.messages.results.length}):`);
889
897
  for (const r of results.messages.results.slice(0, 10)) {
890
- sections.push(` • ${r.text.substring(0, 80)}${r.text.length > 80 ? "..." : ""}`);
898
+ sections.push(` • ${(r.text || "").substring(0, 80)}${(r.text || "").length > 80 ? "..." : ""}`);
891
899
  sections.push(` ${r.date}`);
892
900
  }
893
901
  sections.push("");
@@ -1244,7 +1252,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1244
1252
  // Smart search (agentic)
1245
1253
  case "smart_search":
1246
1254
  result = await smartSearch(args.query, {
1247
- limit: args?.limit || 5,
1255
+ limit: validateLimit(args?.limit, 5, 100),
1248
1256
  synthesize: args?.synthesize !== false
1249
1257
  });
1250
1258
  break;
@@ -1252,8 +1260,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1252
1260
  // Email tools
1253
1261
  case "mail_search":
1254
1262
  result = await mailSearch(args.query, {
1255
- limit: args?.limit || 30,
1256
- daysBack: args?.days_back || 0,
1263
+ limit: validateLimit(args?.limit, 30),
1264
+ daysBack: validateDaysBack(args?.days_back),
1257
1265
  sender: args?.sender || null,
1258
1266
  recipient: args?.recipient || null,
1259
1267
  hasAttachment: args?.has_attachment ?? null,
@@ -1266,7 +1274,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1266
1274
  break;
1267
1275
 
1268
1276
  case "mail_recent":
1269
- result = await mailRecent(args?.limit || 30, args?.days_back || 7, args?.unread_only || false, args?.include_junk || false);
1277
+ result = await mailRecent(
1278
+ validateLimit(args?.limit, 30),
1279
+ validateDaysBack(args?.days_back) || 7,
1280
+ args?.unread_only || false,
1281
+ args?.include_junk || false
1282
+ );
1270
1283
  break;
1271
1284
 
1272
1285
  case "mail_date":
@@ -1280,8 +1293,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1280
1293
  // Messages tools
1281
1294
  case "messages_search":
1282
1295
  result = await messagesSearch(args.query, {
1283
- limit: args?.limit || 30,
1284
- daysBack: args?.days_back || 0,
1296
+ limit: validateLimit(args?.limit, 30),
1297
+ daysBack: validateDaysBack(args?.days_back),
1285
1298
  contact: args?.contact || null,
1286
1299
  groupChatOnly: args?.group_chat_only || false,
1287
1300
  groupChatName: args?.group_chat_name || null,
@@ -1291,19 +1304,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1291
1304
  break;
1292
1305
 
1293
1306
  case "messages_recent":
1294
- result = await messagesRecent(args?.limit || 30, args?.days_back || 1);
1307
+ result = await messagesRecent(
1308
+ validateLimit(args?.limit, 30),
1309
+ validateDaysBack(args?.days_back) || 1
1310
+ );
1295
1311
  break;
1296
1312
 
1297
1313
  case "messages_conversation":
1298
- result = await messagesConversation(args.contact, args?.limit || 50);
1314
+ result = await messagesConversation(args.contact, validateLimit(args?.limit, 50));
1299
1315
  break;
1300
1316
 
1301
1317
  // Calendar tools
1302
1318
  case "calendar_search":
1303
1319
  result = await calendarSearch(args.query, {
1304
- limit: args?.limit || 30,
1305
- daysBack: args?.days_back || 0,
1306
- daysAhead: args?.days_ahead || 0,
1320
+ limit: validateLimit(args?.limit, 30),
1321
+ daysBack: validateDaysBack(args?.days_back),
1322
+ daysAhead: validateDaysBack(args?.days_ahead),
1307
1323
  calendarName: args?.calendar_name || null,
1308
1324
  allDayOnly: args?.all_day_only || false,
1309
1325
  sortBy: args?.sort_by || "relevance"
@@ -1326,7 +1342,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1326
1342
 
1327
1343
  // Mail tools
1328
1344
  case "mail_senders":
1329
- result = formatSendersResults(await getFrequentSenders(args?.limit || 30, args?.days_back || 0, args?.include_junk || false));
1345
+ if (!sessionIndexComplete) {
1346
+ result = getIndexingMessage();
1347
+ break;
1348
+ }
1349
+ result = formatSendersResults(await getFrequentSenders(
1350
+ validateLimit(args?.limit, 30),
1351
+ validateDaysBack(args?.days_back),
1352
+ args?.include_junk || false
1353
+ ));
1330
1354
  break;
1331
1355
 
1332
1356
  case "rebuild_index":
@@ -1344,6 +1368,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1344
1368
 
1345
1369
  // Start rebuild in background and return immediately
1346
1370
  indexingInProgress = true;
1371
+ sessionIndexComplete = false;
1347
1372
  const rebuildSources = args?.sources || ["emails", "messages", "calendar"];
1348
1373
 
1349
1374
  // Fire and forget - don't await
@@ -1381,34 +1406,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1381
1406
 
1382
1407
  // Messages tools
1383
1408
  case "messages_contacts":
1384
- result = formatMessageContactsResults(getMessageContacts(args?.limit || 50));
1409
+ result = formatMessageContactsResults(getMessageContacts(validateLimit(args?.limit, 50, 500)));
1385
1410
  break;
1386
1411
 
1387
1412
  // Calendar tools
1388
1413
  case "calendar_upcoming":
1389
- result = formatUpcomingEventsResults(getUpcomingEvents(args?.limit || 30));
1414
+ result = formatUpcomingEventsResults(getUpcomingEvents(validateLimit(args?.limit, 30, 100)));
1390
1415
  break;
1391
1416
 
1392
1417
  // ============ NEW TOOLS - PHASE 2 ============
1393
1418
 
1394
1419
  case "calendar_week":
1395
- result = formatWeekEventsResults(getWeekEvents(args?.week_offset || 0));
1420
+ result = formatWeekEventsResults(getWeekEvents(validateWeekOffset(args?.week_offset)));
1396
1421
  break;
1397
1422
 
1398
1423
  // ============ NEW TOOLS - PHASE 3 ============
1399
1424
 
1400
1425
  case "mail_thread":
1401
- result = formatEmailThreadResults(await getEmailThread(args.file_path, args?.limit || 30));
1426
+ if (!sessionIndexComplete) {
1427
+ result = getIndexingMessage();
1428
+ break;
1429
+ }
1430
+ result = formatEmailThreadResults(await getEmailThread(args.file_path, validateLimit(args?.limit, 30)));
1402
1431
  break;
1403
1432
 
1404
1433
  case "calendar_recurring":
1405
- result = formatRecurringEventsResults(getRecurringEvents(args?.limit || 30));
1434
+ result = formatRecurringEventsResults(getRecurringEvents(validateLimit(args?.limit, 30, 100)));
1406
1435
  break;
1407
1436
 
1408
1437
  // ============ CONTACTS TOOLS ============
1409
1438
 
1410
1439
  case "contacts_search":
1411
- result = formatContactsSearchResults(searchContacts(args.query, args?.limit || 30));
1440
+ result = formatContactsSearchResults(searchContacts(args.query, validateLimit(args?.limit, 30)));
1412
1441
  break;
1413
1442
 
1414
1443
  case "contacts_lookup":
@@ -1416,7 +1445,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1416
1445
  break;
1417
1446
 
1418
1447
  case "person_search":
1419
- result = await personSearch(args.name, args?.limit || 10);
1448
+ result = await personSearch(args.name, validateLimit(args?.limit, 10));
1420
1449
  break;
1421
1450
 
1422
1451
  default:
@@ -1436,7 +1465,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1436
1465
  async function main() {
1437
1466
  const transport = new StdioServerTransport();
1438
1467
  await server.connect(transport);
1439
- console.error("Apple Tools MCP server running (v1.1.1)");
1468
+ console.error("Apple Tools MCP server running (v1.1.2)");
1440
1469
  // Background indexing runs automatically on startup and every INDEX_INTERVAL
1441
1470
  }
1442
1471
 
package/indexer.js CHANGED
@@ -9,7 +9,10 @@ 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";
15
18
 
@@ -252,14 +255,17 @@ function parseEmlx(filePath) {
252
255
  content = lines.slice(1).join("\n");
253
256
  }
254
257
 
258
+ // Unfold RFC 822 wrapped headers so long From/Subject/To values are complete
259
+ const unfolded = unfoldRfc822Headers(content);
260
+
255
261
  // 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);
262
+ const fromMatch = unfolded.match(/^From:\s*(.+)$/m);
263
+ const subjectMatch = unfolded.match(/^Subject:\s*(.+)$/m);
264
+ const dateMatch = unfolded.match(/^Date:\s*(.+)$/m);
265
+ const toMatch = unfolded.match(/^To:\s*(.+)$/m);
266
+ const ccMatch = unfolded.match(/^Cc:\s*(.+)$/m);
267
+ const messageIdMatch = unfolded.match(/^Message-ID:\s*(.+)$/im);
268
+ const flaggedMatch = unfolded.match(/^X-Flagged:\s*(.+)$/im) || unfolded.match(/flags.*flagged/i);
263
269
 
264
270
  // Check for attachments
265
271
  const hasAttachment = /Content-Disposition:\s*attachment/i.test(content) ||
@@ -335,13 +341,10 @@ function parseEmlx(filePath) {
335
341
  // Full scan - used for first run and rebuild_index
336
342
  // Includes both .emlx and .partial.emlx files (partial = not fully downloaded via IMAP)
337
343
  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
- }
344
+ // "*.emlx" also matches "*.partial.emlx"
345
+ // Let permission/IO errors propagate so indexEmails does not persist a
346
+ // timestamp for an empty scan and skip real mail forever.
347
+ return safeFind(MAIL_DIR, { name: "*.emlx", type: "f" });
345
348
  }
346
349
 
347
350
  // Fast incremental scan - uses find with -mtime filter (more reliable than mdfind/Spotlight)
@@ -579,6 +582,9 @@ function getMessages(sinceTimestamp = null) {
579
582
  ORDER BY m.date DESC
580
583
  `;
581
584
  const results = safeSqlite3Json(MESSAGES_DB, query, { timeout: 60000 });
585
+ if (!Array.isArray(results)) {
586
+ throw new Error("Unexpected sqlite3 result for messages");
587
+ }
582
588
 
583
589
  // Post-process: extract text from attributedBody where text is NULL
584
590
  let extractedCount = 0;
@@ -607,7 +613,7 @@ function getMessages(sinceTimestamp = null) {
607
613
  return results.filter(msg => msg.text && msg.text.trim() !== '');
608
614
  } catch (e) {
609
615
  console.error("Error reading messages:", e.message);
610
- return [];
616
+ throw e;
611
617
  }
612
618
  }
613
619
 
@@ -645,19 +651,24 @@ function getParticipantStatus(status) {
645
651
  }
646
652
 
647
653
  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 = `
654
+ // NOTE: We index ALL calendar events, not filtered by date
655
+ // Calendar events don't have a "file modification time" like emails do,
656
+ // so we can't use the mdfind + DAYS_BACK approach.
657
+ // Calendar indexing is always comprehensive - filtering by date would lose historical context.
658
+ // Must throw on source-read failure: a silent [] would look like "every event
659
+ // was deleted" and the stale-entry pass would wipe the calendar index.
660
+ if (!fs.existsSync(CALENDAR_DB)) {
661
+ throw new Error("Calendar database not found");
662
+ }
663
+
664
+ const now = Date.now();
665
+ const pastDate = unixMsToMacAbsolute(now - 10 * 365 * 24 * 60 * 60 * 1000); // 10 years back
666
+ const futureDate = unixMsToMacAbsolute(now + 10 * 365 * 24 * 60 * 60 * 1000); // 10 years ahead
667
+
668
+ // Query OccurrenceCache for recurring events and their calculated occurrences
669
+ // This includes both recurring and non-recurring events
670
+ // GROUP BY to avoid duplicate entries for recurring events
671
+ const query = `
661
672
  SELECT
662
673
  ci.ROWID as id,
663
674
  ci.summary,
@@ -679,10 +690,7 @@ function getCalendarEvents() {
679
690
  ORDER BY MIN(oc.day) ASC
680
691
  `;
681
692
 
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 = `
693
+ const attendeesQuery = `
686
694
  SELECT
687
695
  p.owner_id,
688
696
  COALESCE(i.display_name, p.email, 'Unknown') as name,
@@ -692,11 +700,13 @@ function getCalendarEvents() {
692
700
  WHERE p.entity_type = 0
693
701
  `;
694
702
 
703
+ return withCalendarCopy((dbPath) => {
704
+ const rows = safeSqlite3Json(dbPath, query, { timeout: 30000 });
705
+
695
706
  let attendeesMap = new Map();
696
707
  try {
697
- const attendeesRows = safeSqlite3Json(CALENDAR_DB, attendeesQuery, { timeout: 10000 });
708
+ const attendeesRows = safeSqlite3Json(dbPath, attendeesQuery, { timeout: 10000 });
698
709
 
699
- // Group attendees by owner_id (event id)
700
710
  for (const att of attendeesRows) {
701
711
  if (!attendeesMap.has(att.owner_id)) {
702
712
  attendeesMap.set(att.owner_id, []);
@@ -734,10 +744,7 @@ function getCalendarEvents() {
734
744
 
735
745
  console.error(`Calendar: Retrieved ${events.length} events via SQLite (~${Math.round((Date.now() - now))}ms)`);
736
746
  return events;
737
- } catch (e) {
738
- console.error("Error reading calendar:", e.message);
739
- return [];
740
- }
747
+ });
741
748
  }
742
749
 
743
750
  // ============ DATABASE FUNCTIONS ============
@@ -927,7 +934,13 @@ export async function indexEmails(progressCallback = null, forceFullScan = false
927
934
  // Use fast incremental scan if we have a previous timestamp
928
935
  const startTime = Date.now();
929
936
  console.error(`Calling findNewEmlxFiles with timestamp: ${lastEmailIndexTime ? new Date(lastEmailIndexTime).toISOString() : 'null (full scan)'}`);
930
- const newFiles = await findNewEmlxFiles(lastEmailIndexTime);
937
+ let newFiles;
938
+ try {
939
+ newFiles = await findNewEmlxFiles(lastEmailIndexTime);
940
+ } catch (e) {
941
+ console.error(`Email source read failed; skipping this cycle so the lookback timestamp is not advanced: ${e.message}`);
942
+ return { indexed: 0, added: 0, error: e.message };
943
+ }
931
944
  console.error(`Found ${newFiles.length} new/modified email files (${Date.now() - startTime}ms)`);
932
945
 
933
946
  const indexedPaths = await getIndexedIdsWithRetry("emails", "filePath");
@@ -1041,6 +1054,13 @@ export async function indexEmails(progressCallback = null, forceFullScan = false
1041
1054
  if (uniqueRecords.length > 0) {
1042
1055
  if (!tables.emails) {
1043
1056
  tables.emails = await db.createTable("emails", uniqueRecords, { mode: "overwrite" });
1057
+ // Track messageIds from the first batch so later batches can
1058
+ // dedupe IMAP copies (INBOX vs Junk) of the same Message-ID.
1059
+ for (const record of uniqueRecords) {
1060
+ if (record.messageId) {
1061
+ indexedMessageIds.add(record.messageId);
1062
+ }
1063
+ }
1044
1064
  } else {
1045
1065
  // Double-check: verify these IDs truly aren't in the index
1046
1066
  const currentIndexed = await getIndexedIdsWithRetry("emails", "filePath");
@@ -1130,7 +1150,13 @@ export async function indexMessages(forceFullScan = false) {
1130
1150
  const indexStartTime = Date.now();
1131
1151
 
1132
1152
  // Use incremental scan if we have a previous timestamp
1133
- const messages = getMessages(lastMessageIndexTime);
1153
+ let messages;
1154
+ try {
1155
+ messages = getMessages(lastMessageIndexTime);
1156
+ } catch (e) {
1157
+ console.error(`Messages source read failed; skipping this cycle so the lookback timestamp is not advanced: ${e.message}`);
1158
+ return { indexed: 0, added: 0, error: e.message };
1159
+ }
1134
1160
  console.error(`Found ${messages.length} messages${lastMessageIndexTime ? ' (incremental)' : ' (full scan)'}`);
1135
1161
 
1136
1162
  const indexed = await getIndexedIdsWithRetry("messages", "id");
@@ -1186,7 +1212,7 @@ export async function indexMessages(forceFullScan = false) {
1186
1212
  return {
1187
1213
  id: String(msg.id),
1188
1214
  date: msg.date,
1189
- dateTimestamp: msg.dateTimestamp || 0,
1215
+ dateTimestamp: toUnixMillis(msg.dateTimestamp),
1190
1216
  sender: msg.sender,
1191
1217
  text: msg.text?.substring(0, 500) || "",
1192
1218
  chatId: String(msg.chatId || ""),
@@ -1270,7 +1296,15 @@ export async function indexMessages(forceFullScan = false) {
1270
1296
  export async function indexCalendar() {
1271
1297
  await initDB();
1272
1298
 
1273
- const events = getCalendarEvents();
1299
+ let events;
1300
+ try {
1301
+ events = getCalendarEvents();
1302
+ } catch (e) {
1303
+ // Do not treat a source-read failure as "zero events" — that would mark
1304
+ // every indexed row stale and delete the calendar index.
1305
+ console.error(`Calendar source read failed; skipping index update to avoid data loss: ${e.message}`);
1306
+ return { indexed: 0, added: 0, removed: 0, error: e.message };
1307
+ }
1274
1308
  console.error(`Found ${events.length} calendar events`);
1275
1309
 
1276
1310
  // Get already indexed event IDs for incremental indexing
@@ -1493,14 +1527,14 @@ export async function getRecentMessages(limit = 10, daysBack = 1) {
1493
1527
  if (!tables.messages) return { messages: [], hasMore: false };
1494
1528
 
1495
1529
  try {
1496
- const cutoff = Date.now() / 1000 - (daysBack * 24 * 60 * 60); // Messages use Unix timestamp
1530
+ const cutoff = Date.now() - (daysBack * 24 * 60 * 60 * 1000);
1497
1531
  const results = await tables.messages.query()
1498
1532
  .select(["id", "date", "dateTimestamp", "sender", "text", "chatId", "isGroupChat"])
1499
1533
  .toArray();
1500
1534
 
1501
1535
  const filtered = results
1502
- .filter(r => r.dateTimestamp >= cutoff)
1503
- .sort((a, b) => b.dateTimestamp - a.dateTimestamp);
1536
+ .filter(r => toUnixMillis(r.dateTimestamp) >= cutoff)
1537
+ .sort((a, b) => toUnixMillis(b.dateTimestamp) - toUnixMillis(a.dateTimestamp));
1504
1538
 
1505
1539
  const hasMore = filtered.length > limit;
1506
1540
  const messages = filtered.slice(0, limit);
@@ -1530,7 +1564,7 @@ export async function getConversation(contact, limit = 50) {
1530
1564
 
1531
1565
  // Sort chronologically (oldest first for conversation view)
1532
1566
  return filtered
1533
- .sort((a, b) => a.dateTimestamp - b.dateTimestamp)
1567
+ .sort((a, b) => toUnixMillis(a.dateTimestamp) - toUnixMillis(b.dateTimestamp))
1534
1568
  .slice(-limit); // Take last N messages
1535
1569
  } catch (e) {
1536
1570
  console.error("Error getting conversation:", e.message);
@@ -1648,7 +1682,7 @@ export function getMessageContacts(limit = 50) {
1648
1682
  return safeSqlite3Json(MESSAGES_DB, query, { timeout: 30000 });
1649
1683
  } catch (e) {
1650
1684
  console.error("Error getting message contacts:", e.message);
1651
- return [];
1685
+ return { error: e.message };
1652
1686
  }
1653
1687
  }
1654
1688
 
@@ -1678,13 +1712,15 @@ export function getUpcomingEvents(limit = 10) {
1678
1712
  ORDER BY sort_day ASC
1679
1713
  LIMIT ${fetchLimit}
1680
1714
  `;
1681
- const events = safeSqlite3Json(CALENDAR_DB, query, { timeout: 10000 });
1715
+ const events = withCalendarCopy((dbPath) => {
1716
+ return safeSqlite3Json(dbPath, query, { timeout: 10000 });
1717
+ });
1682
1718
  const hasMore = events.length > safeLimit;
1683
1719
  const limitedEvents = events.slice(0, safeLimit);
1684
1720
  return { events: limitedEvents, showing: limitedEvents.length, hasMore };
1685
1721
  } catch (e) {
1686
1722
  console.error("Error getting upcoming events:", e.message);
1687
- return { events: [], showing: 0, hasMore: false };
1723
+ return { events: [], showing: 0, hasMore: false, error: e.message };
1688
1724
  }
1689
1725
  }
1690
1726
 
@@ -1876,7 +1912,9 @@ export function getWeekEvents(weekOffset = 0) {
1876
1912
  AND ci.summary IS NOT NULL AND ci.summary <> ''
1877
1913
  ORDER BY oc.day ASC
1878
1914
  `;
1879
- const events = safeSqlite3Json(CALENDAR_DB, query, { timeout: 15000 });
1915
+ const events = withCalendarCopy((dbPath) => {
1916
+ return safeSqlite3Json(dbPath, query, { timeout: 15000 });
1917
+ });
1880
1918
 
1881
1919
  const weekStart = monday.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
1882
1920
  const weekEnd = sunday.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
@@ -1915,15 +1953,13 @@ export async function getEmailThread(filePath, limit = 20) {
1915
1953
  }
1916
1954
 
1917
1955
  // Read the email to get subject
1918
- const content = fs.readFileSync(validatedPath, "utf-8");
1956
+ const content = unfoldRfc822Headers(fs.readFileSync(validatedPath, "utf-8"));
1919
1957
  const subjectMatch = content.match(/^Subject:\s*(.+)$/m);
1920
1958
  if (!subjectMatch) {
1921
1959
  return { error: "Could not extract subject from email", emails: [] };
1922
1960
  }
1923
1961
 
1924
- // Clean subject - remove Re:, Fwd:, etc.
1925
- let subject = subjectMatch[1].trim();
1926
- const baseSubject = subject.replace(/^(Re|Fwd|Fw):\s*/gi, "").trim();
1962
+ const baseSubject = stripSubjectPrefixes(subjectMatch[1]);
1927
1963
 
1928
1964
  if (baseSubject.length < 5) {
1929
1965
  return { error: "Subject too short to find thread", emails: [] };
@@ -1937,7 +1973,7 @@ export async function getEmailThread(filePath, limit = 20) {
1937
1973
  // Filter to emails with matching base subject
1938
1974
  const threadEmails = allEmails
1939
1975
  .filter(e => {
1940
- const eBaseSubject = (e.subject || "").replace(/^(Re|Fwd|Fw):\s*/gi, "").trim();
1976
+ const eBaseSubject = stripSubjectPrefixes(e.subject || "");
1941
1977
  return eBaseSubject.toLowerCase() === baseSubject.toLowerCase();
1942
1978
  })
1943
1979
  .sort((a, b) => a.dateTimestamp - b.dateTimestamp)
@@ -1981,12 +2017,14 @@ export function getRecurringEvents(limit = 20) {
1981
2017
  ORDER BY occurrenceCount DESC, MIN(oc.day) ASC
1982
2018
  LIMIT ${fetchLimit}
1983
2019
  `;
1984
- const events = safeSqlite3Json(CALENDAR_DB, query, { timeout: 30000 });
2020
+ const events = withCalendarCopy((dbPath) => {
2021
+ return safeSqlite3Json(dbPath, query, { timeout: 30000 });
2022
+ });
1985
2023
  const hasMore = events.length > safeLimit;
1986
2024
  const limitedEvents = events.slice(0, safeLimit);
1987
2025
  return { events: limitedEvents, showing: limitedEvents.length, hasMore };
1988
2026
  } catch (e) {
1989
2027
  console.error("Error getting recurring events:", e.message);
1990
- return { events: [], showing: 0, hasMore: false };
2028
+ return { events: [], showing: 0, hasMore: false, error: e.message };
1991
2029
  }
1992
2030
  }
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.2",
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,7 +1,7 @@
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
6
 
7
7
  let db = null;
@@ -102,13 +102,16 @@ function resolvePronouns(query) {
102
102
  return query;
103
103
  }
104
104
 
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);
105
+ if (!queryContext.lastPerson) {
106
+ return query;
109
107
  }
110
108
 
111
- return query;
109
+ // Do not use RegExp#test with /g — lastIndex is stateful and can skip the
110
+ // first (or only) pronoun. replace() with a fresh regex is sufficient.
111
+ return query.replace(
112
+ /\b(they|them|their|he|him|his|she|her|hers)\b/gi,
113
+ queryContext.lastPerson
114
+ );
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
@@ -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
@@ -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
@@ -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.";
@@ -1673,6 +1696,9 @@ export function formatEmailThreadResults(result) {
1673
1696
 
1674
1697
  // Format calendar_recurring results
1675
1698
  export function formatRecurringEventsResults(result) {
1699
+ if (result?.error) {
1700
+ return `Error getting recurring events: ${result.error}`;
1701
+ }
1676
1702
  const events = result.events || result; // Handle both new {events, showing, hasMore} and old array format
1677
1703
  if (!events || events.length === 0) {
1678
1704
  return "No recurring events found.";
@@ -1689,4 +1715,4 @@ export function formatRecurringEventsResults(result) {
1689
1715
  }
1690
1716
 
1691
1717
  // Export internal functions for testing
1692
- export { expandQuery, parseNegation, extractKeywords };
1718
+ export { expandQuery, parseNegation, extractKeywords, resolvePronouns, updateContext };