apple-tools-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,1502 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import {
6
+ CallToolRequestSchema,
7
+ ListToolsRequestSchema,
8
+ } from "@modelcontextprotocol/sdk/types.js";
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import { validateEmailPath, stripHtmlTags } from "./lib/validators.js";
12
+
13
+ // Lock file to prevent duplicate indexing processes
14
+ const LOCK_FILE = path.join(process.env.HOME, ".apple-tools-mcp", "indexer.lock");
15
+ const LOCK_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes - if lock is older, assume hung process
16
+
17
+ function acquireLock() {
18
+ try {
19
+ // Ensure directory exists first
20
+ const lockDir = path.dirname(LOCK_FILE);
21
+ if (!fs.existsSync(lockDir)) {
22
+ fs.mkdirSync(lockDir, { recursive: true });
23
+ }
24
+
25
+ // Check for existing lock file
26
+ if (fs.existsSync(LOCK_FILE)) {
27
+ const lockData = fs.readFileSync(LOCK_FILE, "utf8");
28
+ const [pidStr, timestampStr] = lockData.split(':');
29
+ const pid = parseInt(pidStr);
30
+ const timestamp = parseInt(timestampStr) || Date.now();
31
+ const lockAge = Date.now() - timestamp;
32
+
33
+ // If we already hold the lock, return true
34
+ if (pid === process.pid) {
35
+ return true;
36
+ }
37
+
38
+ try {
39
+ process.kill(pid, 0); // Check if process exists (signal 0 = no-op)
40
+
41
+ // Process exists - check if lock is stale (hung process)
42
+ if (lockAge > LOCK_TIMEOUT_MS) {
43
+ console.error(`Lock file is ${Math.round(lockAge / 60000)} minutes old. Assuming hung process (PID ${pid}). Removing stale lock.`);
44
+ fs.unlinkSync(LOCK_FILE);
45
+ } else {
46
+ console.error(`Another indexing instance running (PID ${pid}). Skipping indexing.`);
47
+ return false;
48
+ }
49
+ } catch {
50
+ // Process doesn't exist, stale lock file - remove it
51
+ console.error(`Removing stale lock file (PID ${pid} not running)`);
52
+ fs.unlinkSync(LOCK_FILE);
53
+ }
54
+ }
55
+
56
+ // Use atomic 'wx' flag to create lock file exclusively
57
+ // This prevents TOCTOU race condition - will throw EEXIST if file was created between check and write
58
+ try {
59
+ fs.writeFileSync(LOCK_FILE, `${process.pid}:${Date.now()}`, { flag: 'wx' });
60
+ return true;
61
+ } catch (err) {
62
+ if (err.code === 'EEXIST') {
63
+ // Another process won the race
64
+ console.error("Another process acquired lock during race. Skipping indexing.");
65
+ return false;
66
+ }
67
+ throw err; // Re-throw unexpected errors
68
+ }
69
+ } catch (e) {
70
+ console.error("Lock file error:", e.message);
71
+ return false; // On error, fail safe - don't proceed
72
+ }
73
+ }
74
+
75
+ function releaseLock() {
76
+ try {
77
+ if (fs.existsSync(LOCK_FILE)) {
78
+ const lockData = fs.readFileSync(LOCK_FILE, "utf8");
79
+ const [pidStr] = lockData.split(':');
80
+ const pid = parseInt(pidStr);
81
+ if (pid === process.pid) {
82
+ fs.unlinkSync(LOCK_FILE);
83
+ console.error(`Released lock file (PID ${process.pid})`);
84
+ }
85
+ }
86
+ } catch (err) {
87
+ // Log error but don't throw - we're likely shutting down
88
+ console.error(`Error releasing lock: ${err.message}`);
89
+ }
90
+ }
91
+
92
+ // Kill any zombie MCP processes on startup (except this one)
93
+ function cleanupZombieProcesses() {
94
+ try {
95
+ const { execSync } = require('child_process');
96
+ // Find all apple-tools-mcp index.js processes
97
+ const psOutput = execSync('ps aux | grep "apple-tools-mcp/index.js" | grep -v grep || true', { encoding: 'utf-8' });
98
+ const lines = psOutput.trim().split('\n').filter(l => l);
99
+
100
+ for (const line of lines) {
101
+ const parts = line.trim().split(/\s+/);
102
+ const pid = parseInt(parts[1]);
103
+
104
+ // Skip this process
105
+ if (pid === process.pid) continue;
106
+
107
+ // Check if process is still running and kill it
108
+ try {
109
+ process.kill(pid, 0); // Check if exists
110
+ console.error(`Killing zombie MCP process: ${pid}`);
111
+ process.kill(pid, 'SIGTERM');
112
+ } catch {
113
+ // Process already dead
114
+ }
115
+ }
116
+ } catch (e) {
117
+ // Ignore errors - cleanup is best-effort
118
+ }
119
+ }
120
+
121
+ // Clean up lock and timer on exit
122
+ process.on("exit", () => {
123
+ stopBackgroundIndexing();
124
+ releaseLock();
125
+ });
126
+ process.on("SIGINT", () => {
127
+ stopBackgroundIndexing();
128
+ releaseLock();
129
+ process.exit();
130
+ });
131
+ process.on("SIGTERM", () => {
132
+ stopBackgroundIndexing();
133
+ releaseLock();
134
+ process.exit();
135
+ });
136
+ process.on("SIGHUP", () => {
137
+ stopBackgroundIndexing();
138
+ releaseLock();
139
+ process.exit();
140
+ });
141
+
142
+ // Handle uncaught errors - cleanup before crashing
143
+ process.on("uncaughtException", (err) => {
144
+ console.error("Uncaught exception:", err);
145
+ stopBackgroundIndexing();
146
+ releaseLock();
147
+ process.exit(1);
148
+ });
149
+
150
+ process.on("unhandledRejection", (reason, promise) => {
151
+ console.error("Unhandled rejection at:", promise, "reason:", reason);
152
+ stopBackgroundIndexing();
153
+ releaseLock();
154
+ process.exit(1);
155
+ });
156
+
157
+ // Exit when stdin closes (Claude client disconnected)
158
+ process.stdin.on("close", () => {
159
+ console.error("Client disconnected. Exiting.");
160
+ stopBackgroundIndexing();
161
+ releaseLock();
162
+ process.exit(0);
163
+ });
164
+
165
+ // Vector search imports
166
+ import {
167
+ indexAll,
168
+ isIndexReady,
169
+ rebuildIndex,
170
+ getFrequentSenders,
171
+ getMessageContacts,
172
+ getUpcomingEvents,
173
+ getWeekEvents,
174
+ getEmailThread,
175
+ getRecurringEvents,
176
+ // Contacts functions
177
+ loadContacts,
178
+ searchContacts,
179
+ lookupContact,
180
+ getContactIdentifiers
181
+ } from "./indexer.js";
182
+ import {
183
+ searchEmails,
184
+ formatEmailResults,
185
+ getRecentEmailResults,
186
+ getEmailDateResults,
187
+ searchMessages,
188
+ formatMessageResults,
189
+ getRecentMessageResults,
190
+ getConversationResults,
191
+ formatConversationResults,
192
+ searchCalendar,
193
+ formatCalendarResults,
194
+ getCalendarDateResults,
195
+ calculateFreeTime,
196
+ formatFreeTimeResults,
197
+ prewarmTables,
198
+ formatSendersResults,
199
+ formatMessageContactsResults,
200
+ formatUpcomingEventsResults,
201
+ formatWeekEventsResults,
202
+ formatEmailThreadResults,
203
+ formatRecurringEventsResults
204
+ } from "./search.js";
205
+ import {
206
+ auditAll,
207
+ formatAuditReport
208
+ } from "./lib/audit.js";
209
+
210
+ // Track indexing status
211
+ let indexingInProgress = false;
212
+ let sessionIndexComplete = false; // Track if this session's indexing is done
213
+ let isFirstEverRun = true; // True if no index exists yet
214
+ let lastIndexTime = 0;
215
+ let lastProgressTime = 0; // Track when we last made progress (for hung detection)
216
+ // Allow environment variable to override default 5-minute interval
217
+ const INDEX_INTERVAL = parseInt(process.env.INDEX_INTERVAL_MS || (5 * 60 * 1000));
218
+ let indexTimer = null;
219
+ let progressCheckTimer = null;
220
+
221
+ // Check if this is the first ever run (no index exists)
222
+ async function checkIfFirstRun() {
223
+ const emailsReady = await isIndexReady("emails");
224
+ const messagesReady = await isIndexReady("messages");
225
+ const calendarReady = await isIndexReady("calendar");
226
+ // If any index exists, this isn't the first run
227
+ return !(emailsReady || messagesReady || calendarReady);
228
+ }
229
+
230
+ // Get appropriate status message based on indexing state
231
+ function getIndexingMessage() {
232
+ if (isFirstEverRun) {
233
+ return "Building initial index. This may take several minutes on first run. Please try again shortly.";
234
+ }
235
+ return "Indexing new data. Please try again in a moment.";
236
+ }
237
+
238
+ // Timeout wrapper for promises
239
+ function withTimeout(promise, timeoutMs, operation = "Operation") {
240
+ return Promise.race([
241
+ promise,
242
+ new Promise((_, reject) =>
243
+ setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs)
244
+ )
245
+ ]);
246
+ }
247
+
248
+ // Run a single indexing cycle (called by background timer)
249
+ function runIndexCycle() {
250
+ if (indexingInProgress) {
251
+ console.error("Indexing already in progress, skipping cycle");
252
+ return;
253
+ }
254
+
255
+ // Safety net: check lock before indexing
256
+ if (!acquireLock()) {
257
+ console.error("Another instance is indexing. Skipping.");
258
+ return;
259
+ }
260
+
261
+ indexingInProgress = true;
262
+ const startMsg = isFirstEverRun ? "Building initial index..." : "Indexing new data...";
263
+ console.error(startMsg);
264
+
265
+ // Initialize progress tracking - we're starting work now
266
+ lastProgressTime = Date.now();
267
+
268
+ // Progress-based timeout: Check every minute if we're making progress
269
+ // If no progress for 10 minutes, assume the process is hung
270
+ const PROGRESS_CHECK_INTERVAL_MS = 60 * 1000; // Check every minute
271
+ const MAX_NO_PROGRESS_MS = 10 * 60 * 1000; // Kill if no progress for 10 minutes
272
+
273
+ progressCheckTimer = setInterval(() => {
274
+ const timeSinceProgress = Date.now() - lastProgressTime;
275
+ if (timeSinceProgress > MAX_NO_PROGRESS_MS) {
276
+ console.error(`⚠️ No indexing progress for ${Math.round(timeSinceProgress / 60000)} minutes. Terminating hung process.`);
277
+ clearInterval(progressCheckTimer);
278
+ progressCheckTimer = null;
279
+ indexingInProgress = false;
280
+ sessionIndexComplete = true;
281
+ releaseLock();
282
+ }
283
+ }, PROGRESS_CHECK_INTERVAL_MS);
284
+
285
+ // Progress callback to track that indexing is making forward progress
286
+ const reportProgress = (stage) => {
287
+ lastProgressTime = Date.now();
288
+ // Progress reported, no need to log each batch
289
+ };
290
+
291
+ indexAll(reportProgress)
292
+ .then(async () => {
293
+ // Clear progress monitor
294
+ if (progressCheckTimer) {
295
+ clearInterval(progressCheckTimer);
296
+ progressCheckTimer = null;
297
+ }
298
+
299
+ lastIndexTime = Date.now();
300
+ lastProgressTime = Date.now();
301
+ indexingInProgress = false;
302
+ sessionIndexComplete = true;
303
+ isFirstEverRun = false; // After successful index, no longer first run
304
+ console.error("Indexing complete.");
305
+ releaseLock(); // Allow other instances to index
306
+ // Pre-warm tables to eliminate first-query latency
307
+ await prewarmTables();
308
+ }).catch(e => {
309
+ // Clear progress monitor
310
+ if (progressCheckTimer) {
311
+ clearInterval(progressCheckTimer);
312
+ progressCheckTimer = null;
313
+ }
314
+
315
+ console.error("Indexing error:", e.message);
316
+ indexingInProgress = false;
317
+ sessionIndexComplete = true; // Mark complete even on error so queries can proceed
318
+ releaseLock(); // Allow other instances to index
319
+ });
320
+ }
321
+
322
+ // DEPRECATED: Legacy function kept for backward compatibility
323
+ // New implementation uses background timer instead of on-demand triggering
324
+ function triggerIndexIfNeeded() {
325
+ // No-op: indexing now runs on background timer
326
+ // This function is kept to avoid breaking any external dependencies
327
+ }
328
+
329
+ // Start continuous background indexing
330
+ function startBackgroundIndexing() {
331
+ // Run indexing immediately on startup
332
+ runIndexCycle();
333
+
334
+ // Then schedule every INDEX_INTERVAL milliseconds
335
+ indexTimer = setInterval(() => {
336
+ runIndexCycle();
337
+ }, INDEX_INTERVAL);
338
+
339
+ console.error(`Background indexing started (interval: ${INDEX_INTERVAL / 1000}s)`);
340
+ }
341
+
342
+ // Stop background indexing and clean up timers
343
+ function stopBackgroundIndexing() {
344
+ if (indexTimer) {
345
+ clearInterval(indexTimer);
346
+ indexTimer = null;
347
+ }
348
+ if (progressCheckTimer) {
349
+ clearInterval(progressCheckTimer);
350
+ progressCheckTimer = null;
351
+ }
352
+ console.error("Background indexing stopped");
353
+ }
354
+
355
+ // Initialize and start indexing
356
+ async function initializeIndexing() {
357
+ isFirstEverRun = await checkIfFirstRun();
358
+
359
+ // Try to acquire lock - if another instance is running, exit
360
+ if (!acquireLock()) {
361
+ console.error("Another apple-tools-mcp instance is running. Exiting.");
362
+ process.exit(0);
363
+ }
364
+
365
+ // Start background indexing
366
+ startBackgroundIndexing();
367
+ }
368
+
369
+ // Start indexing immediately on server startup
370
+ initializeIndexing();
371
+
372
+ // ============ SEMANTIC SEARCH FUNCTIONS ============
373
+
374
+ async function mailSearch(query, options = {}) {
375
+ if (!query) {
376
+ return "Error: query parameter is required for mail_search";
377
+ }
378
+
379
+ if (!sessionIndexComplete) {
380
+ return getIndexingMessage();
381
+ }
382
+
383
+ const ready = await isIndexReady("emails");
384
+ if (!ready) {
385
+ return "Email index not available. Please try again shortly.";
386
+ }
387
+
388
+ const result = await searchEmails(query, options);
389
+ return formatEmailResults(result);
390
+ }
391
+
392
+ async function mailRecent(limit = 30, daysBack = 7, unreadOnly = false, includeJunk = false) {
393
+ if (!sessionIndexComplete) {
394
+ return getIndexingMessage();
395
+ }
396
+
397
+ const ready = await isIndexReady("emails");
398
+ if (!ready) {
399
+ return "Email index not available. Please try again shortly.";
400
+ }
401
+
402
+ const result = await getRecentEmailResults(limit, daysBack, unreadOnly, includeJunk);
403
+ return formatEmailResults(result);
404
+ }
405
+
406
+ async function mailDate(date, includeJunk = false) {
407
+ if (!sessionIndexComplete) {
408
+ return getIndexingMessage();
409
+ }
410
+
411
+ const ready = await isIndexReady("emails");
412
+ if (!ready) {
413
+ return "Email index not available. Please try again shortly.";
414
+ }
415
+
416
+ const result = await getEmailDateResults(date, includeJunk);
417
+ return formatEmailResults(result);
418
+ }
419
+
420
+ async function messagesSearch(query, options = {}) {
421
+ if (!sessionIndexComplete) {
422
+ return getIndexingMessage();
423
+ }
424
+
425
+ const ready = await isIndexReady("messages");
426
+ if (!ready) {
427
+ return "Messages index not available. Please try again shortly.";
428
+ }
429
+
430
+ const result = await searchMessages(query, options);
431
+ return formatMessageResults(result);
432
+ }
433
+
434
+ async function messagesRecent(limit = 10, daysBack = 1) {
435
+ if (!sessionIndexComplete) {
436
+ return getIndexingMessage();
437
+ }
438
+
439
+ const ready = await isIndexReady("messages");
440
+ if (!ready) {
441
+ return "Messages index not available. Please try again shortly.";
442
+ }
443
+
444
+ const result = await getRecentMessageResults(limit, daysBack);
445
+ return formatMessageResults(result);
446
+ }
447
+
448
+ async function messagesConversation(contact, limit = 50) {
449
+ if (!sessionIndexComplete) {
450
+ return getIndexingMessage();
451
+ }
452
+
453
+ const ready = await isIndexReady("messages");
454
+ if (!ready) {
455
+ return "Messages index not available. Please try again shortly.";
456
+ }
457
+
458
+ const result = await getConversationResults(contact, limit);
459
+ return formatConversationResults(result);
460
+ }
461
+
462
+ async function calendarSearch(query, options = {}) {
463
+ if (!sessionIndexComplete) {
464
+ return getIndexingMessage();
465
+ }
466
+
467
+ const ready = await isIndexReady("calendar");
468
+ if (!ready) {
469
+ return "Calendar index not available. Please try again shortly.";
470
+ }
471
+
472
+ const result = await searchCalendar(query, options);
473
+ return formatCalendarResults(result);
474
+ }
475
+
476
+ async function calendarDate(date) {
477
+ if (!sessionIndexComplete) {
478
+ return getIndexingMessage();
479
+ }
480
+
481
+ const ready = await isIndexReady("calendar");
482
+ if (!ready) {
483
+ return "Calendar index not available. Please try again shortly.";
484
+ }
485
+
486
+ const result = await getCalendarDateResults(date);
487
+ return formatCalendarResults(result);
488
+ }
489
+
490
+ async function calendarFreeTime(date, options = {}) {
491
+ if (!sessionIndexComplete) {
492
+ return getIndexingMessage();
493
+ }
494
+
495
+ const ready = await isIndexReady("calendar");
496
+ if (!ready) {
497
+ return "Calendar index not available. Please try again shortly.";
498
+ }
499
+
500
+ const result = await calculateFreeTime(date, options);
501
+ return formatFreeTimeResults(result);
502
+ }
503
+
504
+ // Mail directory for path validation
505
+ const MAIL_DIR = path.join(process.env.HOME, "Library", "Mail");
506
+
507
+ // Read full email content from file path
508
+ function readFullEmail(filePath) {
509
+ try {
510
+ // Validate file path to prevent path traversal attacks
511
+ let validatedPath;
512
+ try {
513
+ validatedPath = validateEmailPath(filePath, MAIL_DIR);
514
+ } catch (e) {
515
+ return `Invalid file path: ${e.message}. Use a path from mail_search results.`;
516
+ }
517
+
518
+ // Verify file exists
519
+ if (!fs.existsSync(validatedPath)) {
520
+ return "Email file not found.";
521
+ }
522
+
523
+ const content = fs.readFileSync(validatedPath, 'utf-8');
524
+
525
+ // Parse email headers and body
526
+ const fromMatch = content.match(/^From:\s*(.+)$/m);
527
+ const toMatch = content.match(/^To:\s*(.+)$/m);
528
+ const subjectMatch = content.match(/^Subject:\s*(.+)$/m);
529
+ const dateMatch = content.match(/^Date:\s*(.+)$/m);
530
+
531
+ // Find body after headers
532
+ const headerEnd = content.search(/\r?\n\r?\n/);
533
+ let body = headerEnd > 0 ? content.substring(headerEnd + 2) : content;
534
+
535
+ // Clean up body - remove HTML if present using safe method
536
+ if (body.includes('<html') || body.includes('<HTML')) {
537
+ // Use safe HTML stripping to prevent ReDoS
538
+ body = stripHtmlTags(body);
539
+ // Decode common HTML entities
540
+ body = body.replace(/&nbsp;/g, ' ');
541
+ body = body.replace(/&amp;/g, '&');
542
+ body = body.replace(/&lt;/g, '<');
543
+ body = body.replace(/&gt;/g, '>');
544
+ }
545
+
546
+ return `From: ${fromMatch?.[1]?.trim() || 'Unknown'}
547
+ To: ${toMatch?.[1]?.trim() || 'Unknown'}
548
+ Subject: ${subjectMatch?.[1]?.trim() || 'No subject'}
549
+ Date: ${dateMatch?.[1]?.trim() || 'Unknown'}
550
+
551
+ ${body.substring(0, 10000)}`;
552
+ } catch (error) {
553
+ return `Error reading email: ${error.message}`;
554
+ }
555
+ }
556
+
557
+ // ============ SMART SEARCH (AGENTIC RAG) ============
558
+
559
+ // Detect which data sources to search based on query intent
560
+ function detectSources(query) {
561
+ const q = query.toLowerCase();
562
+ const sources = [];
563
+
564
+ // Calendar indicators
565
+ if (/\b(when|schedule|calendar|event|meeting|appointment|today|tomorrow|next \w+day|this week|free time|available|busy)\b/.test(q)) {
566
+ sources.push('calendar');
567
+ }
568
+
569
+ // Messages indicators
570
+ if (/\b(said|message|text|chat|conversation|imessage|sms|texted|replied)\b/.test(q)) {
571
+ sources.push('messages');
572
+ }
573
+
574
+ // Mail indicators
575
+ if (/\b(email|mail|sent|inbox|from .+ about|subject|attachment|forward|reply)\b/.test(q)) {
576
+ sources.push('mail');
577
+ }
578
+
579
+ // Default to all if no clear indicators
580
+ return sources.length > 0 ? sources : ['mail', 'messages', 'calendar'];
581
+ }
582
+
583
+ // Format smart search results from multiple sources
584
+ function formatSmartSearchResults(results, synthesizedGroups = null) {
585
+ const sections = [];
586
+
587
+ // If we have synthesized timeline groups, show those first
588
+ if (synthesizedGroups && synthesizedGroups.length > 0) {
589
+ sections.push("=== Timeline View (Related Items Grouped) ===\n");
590
+ for (const group of synthesizedGroups.slice(0, 5)) {
591
+ sections.push(`📅 ${group.timeWindow} (${group.totalItems} items)`);
592
+ if (group.calendar.length > 0) {
593
+ sections.push(` Calendar: ${group.calendar.map(c => c.title).join(', ')}`);
594
+ }
595
+ if (group.mail.length > 0) {
596
+ sections.push(` Emails: ${group.mail.map(m => m.subject).join(', ')}`);
597
+ }
598
+ if (group.messages.length > 0) {
599
+ sections.push(` Messages: ${group.messages.length} from ${[...new Set(group.messages.map(m => m.sender))].join(', ')}`);
600
+ }
601
+ sections.push("");
602
+ }
603
+ sections.push("=== Detailed Results ===\n");
604
+ }
605
+
606
+ if (results.mail && results.mail.success && results.mail.results.length > 0) {
607
+ sections.push("📧 EMAILS:");
608
+ for (const r of results.mail.results) {
609
+ sections.push(` [${r.rank}] Score: ${r.score}`);
610
+ sections.push(` From: ${r.from}`);
611
+ sections.push(` Subject: ${r.subject}`);
612
+ sections.push(` Date: ${r.date}`);
613
+ sections.push(` File: ${r.filePath}`);
614
+ }
615
+ sections.push("");
616
+ }
617
+
618
+ if (results.messages && results.messages.success && results.messages.results.length > 0) {
619
+ sections.push("💬 MESSAGES:");
620
+ for (const r of results.messages.results) {
621
+ sections.push(` [${r.rank}] Score: ${r.score}`);
622
+ sections.push(` From: ${r.sender}${r.isGroupChat ? ' (Group)' : ''}`);
623
+ sections.push(` Date: ${r.date}`);
624
+ sections.push(` Text: ${r.text.substring(0, 100)}...`);
625
+ }
626
+ sections.push("");
627
+ }
628
+
629
+ if (results.calendar && results.calendar.success && results.calendar.results.length > 0) {
630
+ sections.push("📅 CALENDAR:");
631
+ for (const r of results.calendar.results) {
632
+ sections.push(` [${r.rank}] Score: ${r.score}`);
633
+ sections.push(` Event: ${r.title}${r.isAllDay ? ' (All Day)' : ''}`);
634
+ sections.push(` Start: ${r.start}`);
635
+ sections.push(` Calendar: ${r.calendar}`);
636
+ if (r.location) sections.push(` Location: ${r.location}`);
637
+ }
638
+ sections.push("");
639
+ }
640
+
641
+ if (sections.length === 0) {
642
+ return "No results found across Mail, Messages, or Calendar.";
643
+ }
644
+
645
+ return sections.join("\n");
646
+ }
647
+
648
+ // Smart search - routes to appropriate sources and optionally synthesizes results
649
+ async function smartSearch(query, options = {}) {
650
+ const { limit = 5, synthesize = true } = options;
651
+
652
+ const sources = detectSources(query);
653
+ const results = {};
654
+
655
+ // Search relevant sources in parallel
656
+ const searches = [];
657
+
658
+ if (sources.includes('calendar')) {
659
+ searches.push(
660
+ (async () => {
661
+ const ready = await isIndexReady("calendar");
662
+ if (ready) {
663
+ results.calendar = await searchCalendar(query, { limit, daysBack: 30, daysAhead: 30 });
664
+ }
665
+ })()
666
+ );
667
+ }
668
+
669
+ if (sources.includes('messages')) {
670
+ searches.push(
671
+ (async () => {
672
+ const ready = await isIndexReady("messages");
673
+ if (ready) {
674
+ results.messages = await searchMessages(query, { limit, daysBack: 30 });
675
+ }
676
+ })()
677
+ );
678
+ }
679
+
680
+ if (sources.includes('mail')) {
681
+ searches.push(
682
+ (async () => {
683
+ const ready = await isIndexReady("emails");
684
+ if (ready) {
685
+ results.mail = await searchEmails(query, { limit, daysBack: 30 });
686
+ }
687
+ })()
688
+ );
689
+ }
690
+
691
+ await Promise.all(searches);
692
+
693
+ // Synthesize results into timeline if multiple sources returned data
694
+ let synthesizedGroups = null;
695
+ if (synthesize) {
696
+ const hasMultipleSources =
697
+ (results.mail?.results?.length > 0 ? 1 : 0) +
698
+ (results.messages?.results?.length > 0 ? 1 : 0) +
699
+ (results.calendar?.results?.length > 0 ? 1 : 0) > 1;
700
+
701
+ if (hasMultipleSources) {
702
+ synthesizedGroups = synthesizeResults(
703
+ results.mail?.results || [],
704
+ results.messages?.results || [],
705
+ results.calendar?.results || []
706
+ );
707
+ }
708
+ }
709
+
710
+ return formatSmartSearchResults(results, synthesizedGroups);
711
+ }
712
+
713
+ // Synthesize results from multiple sources into time-based groups
714
+ function synthesizeResults(mailResults, messageResults, calendarResults) {
715
+ const WINDOW_MS = 60 * 60 * 1000; // 1 hour window
716
+ const timeline = new Map();
717
+
718
+ // Helper to find/create time bucket
719
+ function getBucket(timestamp) {
720
+ if (!timestamp || isNaN(timestamp)) return null;
721
+ const rounded = Math.floor(timestamp / WINDOW_MS) * WINDOW_MS;
722
+ if (!timeline.has(rounded)) {
723
+ timeline.set(rounded, { mail: [], messages: [], calendar: [] });
724
+ }
725
+ return timeline.get(rounded);
726
+ }
727
+
728
+ // Add mail results
729
+ for (const r of mailResults) {
730
+ const ts = r.dateTimestamp || new Date(r.date).getTime();
731
+ const bucket = getBucket(ts);
732
+ if (bucket) bucket.mail.push(r);
733
+ }
734
+
735
+ // Add message results
736
+ for (const r of messageResults) {
737
+ const ts = r.dateTimestamp || new Date(r.date).getTime();
738
+ const bucket = getBucket(ts);
739
+ if (bucket) bucket.messages.push(r);
740
+ }
741
+
742
+ // Add calendar results
743
+ for (const r of calendarResults) {
744
+ const ts = r.startTimestamp || new Date(r.start).getTime();
745
+ const bucket = getBucket(ts);
746
+ if (bucket) bucket.calendar.push(r);
747
+ }
748
+
749
+ // Format grouped results
750
+ const groups = Array.from(timeline.entries())
751
+ .filter(([_, g]) => g.mail.length + g.messages.length + g.calendar.length > 0)
752
+ .sort((a, b) => b[0] - a[0]) // Newest first
753
+ .map(([ts, group]) => ({
754
+ timeWindow: new Date(ts).toLocaleString(),
755
+ ...group,
756
+ totalItems: group.mail.length + group.messages.length + group.calendar.length
757
+ }));
758
+
759
+ return groups;
760
+ }
761
+
762
+ // ============ CONTACTS FORMATTING ============
763
+
764
+ function formatContactsSearchResults(contacts) {
765
+ if (!contacts || contacts.length === 0) {
766
+ return "No contacts found matching your search.";
767
+ }
768
+
769
+ let output = `Found ${contacts.length} contacts:\n\n`;
770
+ for (const c of contacts) {
771
+ output += `• ${c.displayName}`;
772
+ if (c.organization) output += ` (${c.organization})`;
773
+ output += "\n";
774
+ if (c.emails.length > 0) {
775
+ output += ` Emails: ${c.emails.map(e => e.email).join(", ")}\n`;
776
+ }
777
+ if (c.phones.length > 0) {
778
+ output += ` Phones: ${c.phones.map(p => p.phone).join(", ")}\n`;
779
+ }
780
+ output += "\n";
781
+ }
782
+ return output;
783
+ }
784
+
785
+ function formatContactLookupResult(contact) {
786
+ if (!contact) {
787
+ return "Contact not found. Try searching with contacts_search for partial matches.";
788
+ }
789
+
790
+ let output = `Contact: ${contact.displayName}\n`;
791
+ output += "─".repeat(40) + "\n";
792
+
793
+ if (contact.organization) output += `Organization: ${contact.organization}\n`;
794
+ if (contact.department) output += `Department: ${contact.department}\n`;
795
+ if (contact.jobTitle) output += `Job Title: ${contact.jobTitle}\n`;
796
+ if (contact.nickname) output += `Nickname: ${contact.nickname}\n`;
797
+
798
+ if (contact.emails.length > 0) {
799
+ output += "\nEmail Addresses:\n";
800
+ for (const e of contact.emails) {
801
+ output += ` • ${e.email} (${e.label})\n`;
802
+ }
803
+ }
804
+
805
+ if (contact.phones.length > 0) {
806
+ output += "\nPhone Numbers:\n";
807
+ for (const p of contact.phones) {
808
+ output += ` • ${p.phone} (${p.label})\n`;
809
+ }
810
+ }
811
+
812
+ return output;
813
+ }
814
+
815
+ // ============ PERSON SEARCH (CROSS-SOURCE) ============
816
+
817
+ async function personSearch(name, limit = 10) {
818
+ // First, try to find the contact to get all their identifiers
819
+ const contacts = searchContacts(name, 5);
820
+
821
+ if (contacts.length === 0) {
822
+ // No contact found, fall back to name-based search
823
+ return await smartSearch(name, { limit, synthesize: true });
824
+ }
825
+
826
+ // Get the best matching contact
827
+ const contact = contacts[0];
828
+ const emails = contact.emails.map(e => e.email);
829
+ const phones = contact.phones.map(p => p.phone);
830
+
831
+ const results = {
832
+ contact: {
833
+ name: contact.displayName,
834
+ organization: contact.organization,
835
+ emails,
836
+ phones
837
+ },
838
+ mail: null,
839
+ messages: null,
840
+ calendar: null
841
+ };
842
+
843
+ // Search all sources in parallel using the contact's identifiers
844
+ const searches = [];
845
+
846
+ // Mail search - search by all email addresses
847
+ if (emails.length > 0) {
848
+ searches.push(
849
+ (async () => {
850
+ const ready = await isIndexReady("emails");
851
+ if (ready) {
852
+ // Search for emails from any of the contact's addresses
853
+ for (const email of emails.slice(0, 3)) { // Limit to first 3 emails
854
+ const emailResults = await searchEmails(contact.displayName, {
855
+ limit,
856
+ sender: email,
857
+ daysBack: 365 // Last year
858
+ });
859
+ if (emailResults.success && emailResults.results.length > 0) {
860
+ if (!results.mail) results.mail = emailResults;
861
+ else results.mail.results.push(...emailResults.results);
862
+ }
863
+ }
864
+ }
865
+ })()
866
+ );
867
+ }
868
+
869
+ // Messages search - search by phone numbers
870
+ if (phones.length > 0) {
871
+ searches.push(
872
+ (async () => {
873
+ const ready = await isIndexReady("messages");
874
+ if (ready) {
875
+ for (const phone of phones.slice(0, 3)) { // Limit to first 3 phones
876
+ const msgResults = await searchMessages(contact.displayName, {
877
+ limit,
878
+ contact: phone,
879
+ daysBack: 365
880
+ });
881
+ if (msgResults.success && msgResults.results.length > 0) {
882
+ if (!results.messages) results.messages = msgResults;
883
+ else results.messages.results.push(...msgResults.results);
884
+ }
885
+ }
886
+ }
887
+ })()
888
+ );
889
+ }
890
+
891
+ // Calendar search - search by name
892
+ searches.push(
893
+ (async () => {
894
+ const ready = await isIndexReady("calendar");
895
+ if (ready) {
896
+ results.calendar = await searchCalendar(contact.displayName, {
897
+ limit,
898
+ daysBack: 90,
899
+ daysAhead: 365
900
+ });
901
+ }
902
+ })()
903
+ );
904
+
905
+ await Promise.all(searches);
906
+
907
+ // Format the results
908
+ return formatPersonSearchResults(results);
909
+ }
910
+
911
+ function formatPersonSearchResults(results) {
912
+ const sections = [];
913
+
914
+ // Contact info header
915
+ sections.push(`👤 ${results.contact.name}`);
916
+ if (results.contact.organization) {
917
+ sections.push(` Organization: ${results.contact.organization}`);
918
+ }
919
+ if (results.contact.emails.length > 0) {
920
+ sections.push(` Emails: ${results.contact.emails.join(", ")}`);
921
+ }
922
+ if (results.contact.phones.length > 0) {
923
+ sections.push(` Phones: ${results.contact.phones.join(", ")}`);
924
+ }
925
+ sections.push("");
926
+ sections.push("=".repeat(50));
927
+ sections.push("");
928
+
929
+ // Email results
930
+ if (results.mail && results.mail.results && results.mail.results.length > 0) {
931
+ sections.push(`📧 EMAILS (${results.mail.results.length}):`);
932
+ for (const r of results.mail.results.slice(0, 10)) {
933
+ sections.push(` • ${r.subject}`);
934
+ sections.push(` ${r.date} | ${r.from}`);
935
+ }
936
+ sections.push("");
937
+ } else {
938
+ sections.push("📧 EMAILS: None found");
939
+ sections.push("");
940
+ }
941
+
942
+ // Message results
943
+ if (results.messages && results.messages.results && results.messages.results.length > 0) {
944
+ sections.push(`💬 MESSAGES (${results.messages.results.length}):`);
945
+ for (const r of results.messages.results.slice(0, 10)) {
946
+ sections.push(` • ${r.text.substring(0, 80)}${r.text.length > 80 ? "..." : ""}`);
947
+ sections.push(` ${r.date}`);
948
+ }
949
+ sections.push("");
950
+ } else {
951
+ sections.push("💬 MESSAGES: None found");
952
+ sections.push("");
953
+ }
954
+
955
+ // Calendar results
956
+ if (results.calendar && results.calendar.results && results.calendar.results.length > 0) {
957
+ sections.push(`📅 CALENDAR (${results.calendar.results.length}):`);
958
+ for (const r of results.calendar.results.slice(0, 10)) {
959
+ sections.push(` • ${r.title}`);
960
+ sections.push(` ${r.start} | ${r.calendar}`);
961
+ }
962
+ sections.push("");
963
+ } else {
964
+ sections.push("📅 CALENDAR: None found");
965
+ sections.push("");
966
+ }
967
+
968
+ return sections.join("\n");
969
+ }
970
+
971
+
972
+ // ============ MCP SERVER SETUP ============
973
+
974
+ const server = new Server(
975
+ { name: "apple-tools-mcp", version: "2.0.0" },
976
+ { capabilities: { tools: {} } }
977
+ );
978
+
979
+ // Define available tools
980
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
981
+ tools: [
982
+ // ============ SMART SEARCH (AGENTIC) ============
983
+ {
984
+ name: "smart_search",
985
+ description: "Intelligent search across Mail, Messages, and Calendar. Automatically determines which sources to search based on your query. Returns results grouped by time when multiple sources match. Use this for complex queries that might span multiple data sources.",
986
+ inputSchema: {
987
+ type: "object",
988
+ properties: {
989
+ query: { type: "string", description: "Natural language search query (e.g., 'meeting with John', 'budget discussion', 'what happened yesterday')" },
990
+ limit: { type: "number", description: "Max results per source (default 5)" },
991
+ synthesize: { type: "boolean", description: "Group results by time proximity (default true)" }
992
+ },
993
+ required: ["query"],
994
+ },
995
+ },
996
+
997
+ // ============ EMAIL TOOLS ============
998
+ {
999
+ name: "mail_search",
1000
+ description: "Semantic search for emails using AI embeddings. Finds emails by meaning, not just keywords. Supports filtering by sender, recipient, attachments, mailbox, sent/received, and flagged.",
1001
+ inputSchema: {
1002
+ type: "object",
1003
+ properties: {
1004
+ query: { type: "string", description: "Natural language search (e.g., 'invoices', 'meeting notes', 'from John about project')" },
1005
+ limit: { type: "number", description: "Maximum results (default 30)" },
1006
+ days_back: { type: "number", description: "Only emails from last N days (0 = all time)" },
1007
+ sender: { type: "string", description: "Filter by sender name or email address" },
1008
+ recipient: { type: "string", description: "Filter by recipient name or email address" },
1009
+ has_attachment: { type: "boolean", description: "Filter to only emails with attachments (true) or without (false)" },
1010
+ mailbox: { type: "string", description: "Filter by mailbox name (e.g., 'INBOX', 'Archive', 'Sent Messages')" },
1011
+ sent_only: { type: "boolean", description: "true = only sent emails, false = only received emails, omit for all" },
1012
+ flagged_only: { type: "boolean", description: "Only show flagged/starred emails" },
1013
+ include_junk: { type: "boolean", description: "Include emails from Junk/Trash folders (excluded by default)" },
1014
+ sort_by: { type: "string", enum: ["relevance", "date"], description: "Sort by relevance (default) or date (newest first)" }
1015
+ },
1016
+ required: ["query"],
1017
+ },
1018
+ },
1019
+ {
1020
+ name: "mail_recent",
1021
+ description: "Get most recent emails without semantic search. Use this when the user asks for 'recent emails', 'latest emails', 'what emails did I get', or 'unread emails'.",
1022
+ inputSchema: {
1023
+ type: "object",
1024
+ properties: {
1025
+ limit: { type: "number", description: "Maximum results (default 30)" },
1026
+ days_back: { type: "number", description: "Only emails from last N days (default 7)" },
1027
+ unread_only: { type: "boolean", description: "Only show unread emails (queries Mail.app for read status)" },
1028
+ include_junk: { type: "boolean", description: "Include emails from Junk/Trash folders (excluded by default)" }
1029
+ },
1030
+ },
1031
+ },
1032
+ {
1033
+ name: "mail_date",
1034
+ description: "Get all emails from a specific date. Supports natural language like 'today', 'yesterday', 'November 13', 'last Friday'. Use this when the user asks for emails on a specific date.",
1035
+ inputSchema: {
1036
+ type: "object",
1037
+ properties: {
1038
+ date: { type: "string", description: "Date to retrieve emails (e.g., 'today', 'yesterday', 'Nov 13', '2025-01-15')" },
1039
+ include_junk: { type: "boolean", description: "Include emails from Junk/Trash folders (excluded by default)" }
1040
+ },
1041
+ required: ["date"],
1042
+ },
1043
+ },
1044
+ {
1045
+ name: "mail_read",
1046
+ description: "Read full email content. Use the file_path from mail_search or mail_recent results.",
1047
+ inputSchema: {
1048
+ type: "object",
1049
+ properties: {
1050
+ file_path: { type: "string", description: "File path from mail_search results" },
1051
+ },
1052
+ required: ["file_path"],
1053
+ },
1054
+ },
1055
+
1056
+ // ============ MESSAGES TOOLS ============
1057
+ {
1058
+ name: "messages_search",
1059
+ description: "Semantic search for iMessages/SMS using AI embeddings. Finds messages by meaning. Supports filtering by contact, group chats, specific group name, and attachments.",
1060
+ inputSchema: {
1061
+ type: "object",
1062
+ properties: {
1063
+ query: { type: "string", description: "Natural language search (e.g., 'dinner plans', 'about the trip', 'address')" },
1064
+ limit: { type: "number", description: "Maximum results (default 30)" },
1065
+ days_back: { type: "number", description: "Only messages from last N days (0 = all time)" },
1066
+ contact: { type: "string", description: "Filter by contact name or phone number" },
1067
+ group_chat_only: { type: "boolean", description: "Only show messages from group chats" },
1068
+ group_chat_name: { type: "string", description: "Filter by specific group chat name" },
1069
+ has_attachment: { type: "boolean", description: "Filter to messages with attachments (photos, files)" },
1070
+ sort_by: { type: "string", enum: ["relevance", "date"], description: "Sort by relevance (default) or date (newest first)" }
1071
+ },
1072
+ required: ["query"],
1073
+ },
1074
+ },
1075
+ {
1076
+ name: "messages_recent",
1077
+ description: "Get most recent messages without semantic search. Use this when the user asks for 'recent messages', 'latest texts', or 'what messages did I get'.",
1078
+ inputSchema: {
1079
+ type: "object",
1080
+ properties: {
1081
+ limit: { type: "number", description: "Maximum results (default 30)" },
1082
+ days_back: { type: "number", description: "Only messages from last N days (default 1)" }
1083
+ },
1084
+ },
1085
+ },
1086
+ {
1087
+ name: "messages_conversation",
1088
+ description: "Get full conversation history with a specific contact. Shows messages in chronological order.",
1089
+ inputSchema: {
1090
+ type: "object",
1091
+ properties: {
1092
+ contact: { type: "string", description: "Contact name or phone number" },
1093
+ limit: { type: "number", description: "Maximum messages to return (default 50)" }
1094
+ },
1095
+ required: ["contact"],
1096
+ },
1097
+ },
1098
+
1099
+ // ============ CALENDAR TOOLS ============
1100
+ {
1101
+ name: "calendar_search",
1102
+ description: "Semantic search for calendar events using AI embeddings. Finds events by meaning. Supports filtering by calendar name and all-day events.",
1103
+ inputSchema: {
1104
+ type: "object",
1105
+ properties: {
1106
+ query: { type: "string", description: "Natural language search (e.g., 'meetings', 'doctor appointments', 'lunch')" },
1107
+ limit: { type: "number", description: "Maximum results (default 30)" },
1108
+ days_back: { type: "number", description: "Include events from last N days (0 = none)" },
1109
+ days_ahead: { type: "number", description: "Include events in next N days (0 = none). Use for 'today', 'this week', etc." },
1110
+ calendar_name: { type: "string", description: "Filter to specific calendar (e.g., 'Work', 'Personal')" },
1111
+ all_day_only: { type: "boolean", description: "Only show all-day events" },
1112
+ sort_by: { type: "string", enum: ["relevance", "date"], description: "Sort by relevance (default) or date (chronological)" }
1113
+ },
1114
+ required: ["query"],
1115
+ },
1116
+ },
1117
+ {
1118
+ name: "calendar_date",
1119
+ description: "Get all events on a specific date. Supports natural language dates like 'today', 'tomorrow', 'next Tuesday', 'Jan 15'.",
1120
+ inputSchema: {
1121
+ type: "object",
1122
+ properties: {
1123
+ date: { type: "string", description: "Date to check (e.g., 'today', 'tomorrow', 'next Monday', '2025-01-15')" }
1124
+ },
1125
+ required: ["date"],
1126
+ },
1127
+ },
1128
+ {
1129
+ name: "calendar_free_time",
1130
+ description: "Find free time slots on a specific date. Analyzes calendar to find available time windows.",
1131
+ inputSchema: {
1132
+ type: "object",
1133
+ properties: {
1134
+ date: { type: "string", description: "Date to check (e.g., 'today', 'tomorrow', 'next Monday')" },
1135
+ start_hour: { type: "number", description: "Start of working hours (default 9 = 9 AM)" },
1136
+ end_hour: { type: "number", description: "End of working hours (default 17 = 5 PM)" },
1137
+ calendar_name: { type: "string", description: "Only consider events from this calendar" }
1138
+ },
1139
+ required: ["date"],
1140
+ },
1141
+ },
1142
+
1143
+ // ============ NEW TOOLS - PHASE 1 ============
1144
+
1145
+ // Mail tools
1146
+ {
1147
+ name: "mail_senders",
1148
+ description: "List most frequent email senders. Helps identify who you communicate with most.",
1149
+ inputSchema: {
1150
+ type: "object",
1151
+ properties: {
1152
+ limit: { type: "number", description: "Maximum senders to return (default 30)" },
1153
+ days_back: { type: "number", description: "Only count emails from last N days (0 = all time)" },
1154
+ include_junk: { type: "boolean", description: "Include senders from Junk/Trash folders (excluded by default)" }
1155
+ },
1156
+ },
1157
+ },
1158
+ {
1159
+ name: "rebuild_index",
1160
+ description: "Rebuild the search index for one or more data sources. This clears the existing index and re-indexes all content from scratch. Use this if search results are stale, missing, or if the index is corrupted. Can rebuild emails, messages, calendar, or all sources at once.",
1161
+ inputSchema: {
1162
+ type: "object",
1163
+ properties: {
1164
+ sources: {
1165
+ type: "array",
1166
+ items: { type: "string", enum: ["emails", "messages", "calendar"] },
1167
+ description: "Which sources to rebuild. Defaults to all sources if not specified."
1168
+ }
1169
+ },
1170
+ },
1171
+ },
1172
+ {
1173
+ name: "audit_index",
1174
+ description: "Audit search index against source data with 0% tolerance. Reports missing items, orphaned entries, and duplicates with detailed file paths and remediation suggestions. Validates 100% of source data.",
1175
+ inputSchema: {
1176
+ type: "object",
1177
+ properties: {
1178
+ sources: {
1179
+ type: "array",
1180
+ items: { type: "string", enum: ["emails", "messages", "calendar"] },
1181
+ description: "Data sources to audit (default: all)"
1182
+ },
1183
+ max_items: {
1184
+ type: "number",
1185
+ description: "Max items to list per category (default: 100, use 0 for unlimited)"
1186
+ }
1187
+ },
1188
+ },
1189
+ },
1190
+
1191
+ // Messages tools
1192
+ {
1193
+ name: "messages_contacts",
1194
+ description: "List all contacts you've messaged, sorted by most recent. Shows message count and last message date.",
1195
+ inputSchema: {
1196
+ type: "object",
1197
+ properties: {
1198
+ limit: { type: "number", description: "Maximum contacts to return (default 50)" }
1199
+ },
1200
+ },
1201
+ },
1202
+
1203
+ // Calendar tools
1204
+ {
1205
+ name: "calendar_upcoming",
1206
+ description: "Get next N upcoming events across all calendars. Simpler than calendar_search for quick schedule overview.",
1207
+ inputSchema: {
1208
+ type: "object",
1209
+ properties: {
1210
+ limit: { type: "number", description: "Maximum events to return (default 10)" }
1211
+ },
1212
+ },
1213
+ },
1214
+
1215
+ // ============ NEW TOOLS - PHASE 2 ============
1216
+
1217
+ {
1218
+ name: "calendar_week",
1219
+ description: "Get all events for the current week or a future week. Shows events grouped by day.",
1220
+ inputSchema: {
1221
+ type: "object",
1222
+ properties: {
1223
+ week_offset: { type: "number", description: "0 = this week, 1 = next week, 2 = week after, etc. (default 0)" }
1224
+ },
1225
+ },
1226
+ },
1227
+ // ============ NEW TOOLS - PHASE 3 ============
1228
+
1229
+ {
1230
+ name: "mail_thread",
1231
+ description: "Get all emails in a conversation thread. Finds related emails by matching subject lines.",
1232
+ inputSchema: {
1233
+ type: "object",
1234
+ properties: {
1235
+ file_path: { type: "string", description: "File path to any email in the thread" },
1236
+ limit: { type: "number", description: "Maximum emails to return (default 30)" }
1237
+ },
1238
+ required: ["file_path"],
1239
+ },
1240
+ },
1241
+ {
1242
+ name: "calendar_recurring",
1243
+ description: "List recurring events (events that appear multiple times). Shows upcoming occurrences.",
1244
+ inputSchema: {
1245
+ type: "object",
1246
+ properties: {
1247
+ limit: { type: "number", description: "Maximum recurring events to return (default 30)" }
1248
+ },
1249
+ },
1250
+ },
1251
+
1252
+ // ============ CONTACTS TOOLS ============
1253
+
1254
+ {
1255
+ name: "contacts_search",
1256
+ description: "Search your contacts by name, email, phone, or organization. Returns matching contacts with all their details.",
1257
+ inputSchema: {
1258
+ type: "object",
1259
+ properties: {
1260
+ query: { type: "string", description: "Search query (e.g., 'John', 'Acme Corp', 'john@example.com')" },
1261
+ limit: { type: "number", description: "Maximum results (default 30)" }
1262
+ },
1263
+ required: ["query"],
1264
+ },
1265
+ },
1266
+ {
1267
+ name: "contacts_lookup",
1268
+ description: "Look up a specific contact by email, phone number, or name. Returns full contact details including all emails and phone numbers.",
1269
+ inputSchema: {
1270
+ type: "object",
1271
+ properties: {
1272
+ identifier: { type: "string", description: "Email address, phone number, or name to look up" }
1273
+ },
1274
+ required: ["identifier"],
1275
+ },
1276
+ },
1277
+ {
1278
+ name: "person_search",
1279
+ description: "Search ALL communication with a specific person across Mail, Messages, and Calendar. Automatically finds their emails and phone numbers from Contacts to search all sources.",
1280
+ inputSchema: {
1281
+ type: "object",
1282
+ properties: {
1283
+ name: { type: "string", description: "Person's name to search for (will resolve to all their email addresses and phone numbers)" },
1284
+ limit: { type: "number", description: "Maximum results per source (default 10)" }
1285
+ },
1286
+ required: ["name"],
1287
+ },
1288
+ },
1289
+ ],
1290
+ }));
1291
+
1292
+ // Handle tool calls
1293
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1294
+ const { name, arguments: args } = request.params;
1295
+
1296
+ try {
1297
+ let result;
1298
+
1299
+ switch (name) {
1300
+ // Smart search (agentic)
1301
+ case "smart_search":
1302
+ result = await smartSearch(args.query, {
1303
+ limit: args?.limit || 5,
1304
+ synthesize: args?.synthesize !== false
1305
+ });
1306
+ break;
1307
+
1308
+ // Email tools
1309
+ case "mail_search":
1310
+ result = await mailSearch(args.query, {
1311
+ limit: args?.limit || 30,
1312
+ daysBack: args?.days_back || 0,
1313
+ sender: args?.sender || null,
1314
+ recipient: args?.recipient || null,
1315
+ hasAttachment: args?.has_attachment ?? null,
1316
+ mailbox: args?.mailbox || null,
1317
+ sentOnly: args?.sent_only ?? null,
1318
+ flaggedOnly: args?.flagged_only || false,
1319
+ includeJunk: args?.include_junk || false,
1320
+ sortBy: args?.sort_by || "relevance"
1321
+ });
1322
+ break;
1323
+
1324
+ case "mail_recent":
1325
+ result = await mailRecent(args?.limit || 30, args?.days_back || 7, args?.unread_only || false, args?.include_junk || false);
1326
+ break;
1327
+
1328
+ case "mail_date":
1329
+ result = await mailDate(args.date, args?.include_junk || false);
1330
+ break;
1331
+
1332
+ case "mail_read":
1333
+ result = readFullEmail(args.file_path);
1334
+ break;
1335
+
1336
+ // Messages tools
1337
+ case "messages_search":
1338
+ result = await messagesSearch(args.query, {
1339
+ limit: args?.limit || 30,
1340
+ daysBack: args?.days_back || 0,
1341
+ contact: args?.contact || null,
1342
+ groupChatOnly: args?.group_chat_only || false,
1343
+ groupChatName: args?.group_chat_name || null,
1344
+ hasAttachment: args?.has_attachment ?? null,
1345
+ sortBy: args?.sort_by || "relevance"
1346
+ });
1347
+ break;
1348
+
1349
+ case "messages_recent":
1350
+ result = await messagesRecent(args?.limit || 30, args?.days_back || 1);
1351
+ break;
1352
+
1353
+ case "messages_conversation":
1354
+ result = await messagesConversation(args.contact, args?.limit || 50);
1355
+ break;
1356
+
1357
+ // Calendar tools
1358
+ case "calendar_search":
1359
+ result = await calendarSearch(args.query, {
1360
+ limit: args?.limit || 30,
1361
+ daysBack: args?.days_back || 0,
1362
+ daysAhead: args?.days_ahead || 0,
1363
+ calendarName: args?.calendar_name || null,
1364
+ allDayOnly: args?.all_day_only || false,
1365
+ sortBy: args?.sort_by || "relevance"
1366
+ });
1367
+ break;
1368
+
1369
+ case "calendar_date":
1370
+ result = await calendarDate(args.date);
1371
+ break;
1372
+
1373
+ case "calendar_free_time":
1374
+ result = await calendarFreeTime(args.date, {
1375
+ startHour: args?.start_hour || 9,
1376
+ endHour: args?.end_hour || 17,
1377
+ calendarName: args?.calendar_name || null
1378
+ });
1379
+ break;
1380
+
1381
+ // ============ NEW TOOLS - PHASE 1 ============
1382
+
1383
+ // Mail tools
1384
+ case "mail_senders":
1385
+ result = formatSendersResults(await getFrequentSenders(args?.limit || 30, args?.days_back || 0, args?.include_junk || false));
1386
+ break;
1387
+
1388
+ case "rebuild_index":
1389
+ // Check if indexing is already in progress in this session
1390
+ if (indexingInProgress) {
1391
+ result = "⏳ Indexing is already in progress. Please wait for it to complete before starting a rebuild.";
1392
+ break;
1393
+ }
1394
+
1395
+ // Acquire lock to prevent parallel rebuilds across multiple MCP instances
1396
+ if (!acquireLock()) {
1397
+ result = "Another indexing operation is already in progress in a different session. Please wait for it to complete.";
1398
+ break;
1399
+ }
1400
+
1401
+ // Start rebuild in background and return immediately
1402
+ indexingInProgress = true;
1403
+ const rebuildSources = args?.sources || ["emails", "messages", "calendar"];
1404
+
1405
+ // Fire and forget - don't await
1406
+ rebuildIndex(rebuildSources).then((rebuildResult) => {
1407
+ sessionIndexComplete = true;
1408
+ isFirstEverRun = false;
1409
+ indexingInProgress = false;
1410
+ releaseLock();
1411
+ console.error("Index rebuild completed:", JSON.stringify({
1412
+ cleared: rebuildResult.cleared,
1413
+ indexed: Object.fromEntries(
1414
+ Object.entries(rebuildResult.indexed).map(([k, v]) => [k, v?.added || 0])
1415
+ ),
1416
+ errors: rebuildResult.errors.length
1417
+ }));
1418
+ }).catch(e => {
1419
+ console.error("Index rebuild error:", e.message);
1420
+ indexingInProgress = false;
1421
+ releaseLock();
1422
+ });
1423
+
1424
+ 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.`;
1425
+ break;
1426
+
1427
+ case "audit_index":
1428
+ {
1429
+ const auditSources = args?.sources || ["emails", "messages", "calendar"];
1430
+ const maxItems = args?.max_items !== undefined ? args.max_items : 100;
1431
+
1432
+ console.error(`Starting audit for: ${auditSources.join(", ")}`);
1433
+ const auditResults = await auditAll({ sources: auditSources, maxItems });
1434
+ result = formatAuditReport(auditResults);
1435
+ }
1436
+ break;
1437
+
1438
+ // Messages tools
1439
+ case "messages_contacts":
1440
+ result = formatMessageContactsResults(getMessageContacts(args?.limit || 50));
1441
+ break;
1442
+
1443
+ // Calendar tools
1444
+ case "calendar_upcoming":
1445
+ result = formatUpcomingEventsResults(getUpcomingEvents(args?.limit || 30));
1446
+ break;
1447
+
1448
+ // ============ NEW TOOLS - PHASE 2 ============
1449
+
1450
+ case "calendar_week":
1451
+ result = formatWeekEventsResults(getWeekEvents(args?.week_offset || 0));
1452
+ break;
1453
+
1454
+ // ============ NEW TOOLS - PHASE 3 ============
1455
+
1456
+ case "mail_thread":
1457
+ result = formatEmailThreadResults(await getEmailThread(args.file_path, args?.limit || 30));
1458
+ break;
1459
+
1460
+ case "calendar_recurring":
1461
+ result = formatRecurringEventsResults(getRecurringEvents(args?.limit || 30));
1462
+ break;
1463
+
1464
+ // ============ CONTACTS TOOLS ============
1465
+
1466
+ case "contacts_search":
1467
+ result = formatContactsSearchResults(searchContacts(args.query, args?.limit || 30));
1468
+ break;
1469
+
1470
+ case "contacts_lookup":
1471
+ result = formatContactLookupResult(lookupContact(args.identifier));
1472
+ break;
1473
+
1474
+ case "person_search":
1475
+ result = await personSearch(args.name, args?.limit || 10);
1476
+ break;
1477
+
1478
+ default:
1479
+ throw new Error(`Unknown tool: ${name}`);
1480
+ }
1481
+
1482
+ return { content: [{ type: "text", text: result }] };
1483
+ } catch (error) {
1484
+ return {
1485
+ content: [{ type: "text", text: `Error: ${error.message}` }],
1486
+ isError: true,
1487
+ };
1488
+ }
1489
+ });
1490
+
1491
+ // Start the server
1492
+ async function main() {
1493
+ // Kill any zombie processes from previous sessions
1494
+ cleanupZombieProcesses();
1495
+
1496
+ const transport = new StdioServerTransport();
1497
+ await server.connect(transport);
1498
+ console.error("Apple Tools MCP server running (v2.0.0)");
1499
+ // Background indexing runs automatically on startup and every INDEX_INTERVAL
1500
+ }
1501
+
1502
+ main().catch(console.error);