Skip to main content
Glama
index.js51.3 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import fs from "fs"; import path from "path"; import { validateEmailPath, stripHtmlTags } from "./lib/validators.js"; // Lock file to prevent duplicate indexing processes const LOCK_FILE = path.join(process.env.HOME, ".apple-tools-mcp", "indexer.lock"); const LOCK_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes - if lock is older, assume hung process function acquireLock() { try { // Ensure directory exists first const lockDir = path.dirname(LOCK_FILE); if (!fs.existsSync(lockDir)) { fs.mkdirSync(lockDir, { recursive: true }); } // Check for existing lock file if (fs.existsSync(LOCK_FILE)) { const lockData = fs.readFileSync(LOCK_FILE, "utf8"); const [pidStr, timestampStr] = lockData.split(':'); const pid = parseInt(pidStr); const timestamp = parseInt(timestampStr) || Date.now(); const lockAge = Date.now() - timestamp; // If we already hold the lock, return true if (pid === process.pid) { return true; } try { process.kill(pid, 0); // Check if process exists (signal 0 = no-op) // Process exists - check if lock is stale (hung process) if (lockAge > LOCK_TIMEOUT_MS) { console.error(`Lock file is ${Math.round(lockAge / 60000)} minutes old. Assuming hung process (PID ${pid}). Removing stale lock.`); fs.unlinkSync(LOCK_FILE); } else { console.error(`Another indexing instance running (PID ${pid}). Skipping indexing.`); return false; } } catch { // Process doesn't exist, stale lock file - remove it console.error(`Removing stale lock file (PID ${pid} not running)`); fs.unlinkSync(LOCK_FILE); } } // Use atomic 'wx' flag to create lock file exclusively // This prevents TOCTOU race condition - will throw EEXIST if file was created between check and write try { fs.writeFileSync(LOCK_FILE, `${process.pid}:${Date.now()}`, { flag: 'wx' }); return true; } catch (err) { if (err.code === 'EEXIST') { // Another process won the race console.error("Another process acquired lock during race. Skipping indexing."); return false; } throw err; // Re-throw unexpected errors } } catch (e) { console.error("Lock file error:", e.message); return false; // On error, fail safe - don't proceed } } function releaseLock() { try { if (fs.existsSync(LOCK_FILE)) { const lockData = fs.readFileSync(LOCK_FILE, "utf8"); const [pidStr] = lockData.split(':'); const pid = parseInt(pidStr); if (pid === process.pid) { fs.unlinkSync(LOCK_FILE); console.error(`Released lock file (PID ${process.pid})`); } } } catch (err) { // Log error but don't throw - we're likely shutting down console.error(`Error releasing lock: ${err.message}`); } } // Kill any zombie MCP processes on startup (except this one) function cleanupZombieProcesses() { try { const { execSync } = require('child_process'); // Find all apple-tools-mcp index.js processes const psOutput = execSync('ps aux | grep "apple-tools-mcp/index.js" | grep -v grep || true', { encoding: 'utf-8' }); const lines = psOutput.trim().split('\n').filter(l => l); for (const line of lines) { const parts = line.trim().split(/\s+/); const pid = parseInt(parts[1]); // Skip this process if (pid === process.pid) continue; // Check if process is still running and kill it try { process.kill(pid, 0); // Check if exists console.error(`Killing zombie MCP process: ${pid}`); process.kill(pid, 'SIGTERM'); } catch { // Process already dead } } } catch (e) { // Ignore errors - cleanup is best-effort } } // Clean up lock and timer on exit process.on("exit", () => { stopBackgroundIndexing(); releaseLock(); }); process.on("SIGINT", () => { stopBackgroundIndexing(); releaseLock(); process.exit(); }); process.on("SIGTERM", () => { stopBackgroundIndexing(); releaseLock(); process.exit(); }); process.on("SIGHUP", () => { stopBackgroundIndexing(); releaseLock(); process.exit(); }); // Handle uncaught errors - cleanup before crashing process.on("uncaughtException", (err) => { console.error("Uncaught exception:", err); stopBackgroundIndexing(); releaseLock(); process.exit(1); }); process.on("unhandledRejection", (reason, promise) => { console.error("Unhandled rejection at:", promise, "reason:", reason); stopBackgroundIndexing(); releaseLock(); process.exit(1); }); // Exit when stdin closes (Claude client disconnected) process.stdin.on("close", () => { console.error("Client disconnected. Exiting."); stopBackgroundIndexing(); releaseLock(); process.exit(0); }); // Vector search imports import { indexAll, isIndexReady, rebuildIndex, getFrequentSenders, getMessageContacts, getUpcomingEvents, getWeekEvents, getEmailThread, getRecurringEvents, // Contacts functions loadContacts, searchContacts, lookupContact, getContactIdentifiers } from "./indexer.js"; import { searchEmails, formatEmailResults, getRecentEmailResults, getEmailDateResults, searchMessages, formatMessageResults, getRecentMessageResults, getConversationResults, formatConversationResults, searchCalendar, formatCalendarResults, getCalendarDateResults, calculateFreeTime, formatFreeTimeResults, prewarmTables, formatSendersResults, formatMessageContactsResults, formatUpcomingEventsResults, formatWeekEventsResults, formatEmailThreadResults, formatRecurringEventsResults } from "./search.js"; import { auditAll, formatAuditReport } from "./lib/audit.js"; // Track indexing status let indexingInProgress = false; let sessionIndexComplete = false; // Track if this session's indexing is done let isFirstEverRun = true; // True if no index exists yet let lastIndexTime = 0; let lastProgressTime = 0; // Track when we last made progress (for hung detection) // Allow environment variable to override default 5-minute interval const INDEX_INTERVAL = parseInt(process.env.INDEX_INTERVAL_MS || (5 * 60 * 1000)); let indexTimer = null; let progressCheckTimer = null; // Check if this is the first ever run (no index exists) async function checkIfFirstRun() { const emailsReady = await isIndexReady("emails"); const messagesReady = await isIndexReady("messages"); const calendarReady = await isIndexReady("calendar"); // If any index exists, this isn't the first run return !(emailsReady || messagesReady || calendarReady); } // Get appropriate status message based on indexing state function getIndexingMessage() { if (isFirstEverRun) { return "Building initial index. This may take several minutes on first run. Please try again shortly."; } return "Indexing new data. Please try again in a moment."; } // Timeout wrapper for promises function withTimeout(promise, timeoutMs, operation = "Operation") { return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs) ) ]); } // Run a single indexing cycle (called by background timer) function runIndexCycle() { if (indexingInProgress) { console.error("Indexing already in progress, skipping cycle"); return; } // Safety net: check lock before indexing if (!acquireLock()) { console.error("Another instance is indexing. Skipping."); return; } indexingInProgress = true; const startMsg = isFirstEverRun ? "Building initial index..." : "Indexing new data..."; console.error(startMsg); // Initialize progress tracking - we're starting work now lastProgressTime = Date.now(); // Progress-based timeout: Check every minute if we're making progress // If no progress for 10 minutes, assume the process is hung const PROGRESS_CHECK_INTERVAL_MS = 60 * 1000; // Check every minute const MAX_NO_PROGRESS_MS = 10 * 60 * 1000; // Kill if no progress for 10 minutes progressCheckTimer = setInterval(() => { const timeSinceProgress = Date.now() - lastProgressTime; if (timeSinceProgress > MAX_NO_PROGRESS_MS) { console.error(`⚠️ No indexing progress for ${Math.round(timeSinceProgress / 60000)} minutes. Terminating hung process.`); clearInterval(progressCheckTimer); progressCheckTimer = null; indexingInProgress = false; sessionIndexComplete = true; releaseLock(); } }, PROGRESS_CHECK_INTERVAL_MS); // Progress callback to track that indexing is making forward progress const reportProgress = (stage) => { lastProgressTime = Date.now(); // Progress reported, no need to log each batch }; indexAll(reportProgress) .then(async () => { // Clear progress monitor if (progressCheckTimer) { clearInterval(progressCheckTimer); progressCheckTimer = null; } lastIndexTime = Date.now(); lastProgressTime = Date.now(); indexingInProgress = false; sessionIndexComplete = true; isFirstEverRun = false; // After successful index, no longer first run console.error("Indexing complete."); releaseLock(); // Allow other instances to index // Pre-warm tables to eliminate first-query latency await prewarmTables(); }).catch(e => { // Clear progress monitor if (progressCheckTimer) { clearInterval(progressCheckTimer); progressCheckTimer = null; } console.error("Indexing error:", e.message); indexingInProgress = false; sessionIndexComplete = true; // Mark complete even on error so queries can proceed releaseLock(); // Allow other instances to index }); } // DEPRECATED: Legacy function kept for backward compatibility // New implementation uses background timer instead of on-demand triggering function triggerIndexIfNeeded() { // No-op: indexing now runs on background timer // This function is kept to avoid breaking any external dependencies } // Start continuous background indexing function startBackgroundIndexing() { // Run indexing immediately on startup runIndexCycle(); // Then schedule every INDEX_INTERVAL milliseconds indexTimer = setInterval(() => { runIndexCycle(); }, INDEX_INTERVAL); console.error(`Background indexing started (interval: ${INDEX_INTERVAL / 1000}s)`); } // Stop background indexing and clean up timers function stopBackgroundIndexing() { if (indexTimer) { clearInterval(indexTimer); indexTimer = null; } if (progressCheckTimer) { clearInterval(progressCheckTimer); progressCheckTimer = null; } console.error("Background indexing stopped"); } // Initialize and start indexing async function initializeIndexing() { isFirstEverRun = await checkIfFirstRun(); // Try to acquire lock - if another instance is running, exit if (!acquireLock()) { console.error("Another apple-tools-mcp instance is running. Exiting."); process.exit(0); } // Start background indexing startBackgroundIndexing(); } // Start indexing immediately on server startup initializeIndexing(); // ============ SEMANTIC SEARCH FUNCTIONS ============ async function mailSearch(query, options = {}) { if (!query) { return "Error: query parameter is required for mail_search"; } if (!sessionIndexComplete) { return getIndexingMessage(); } const ready = await isIndexReady("emails"); if (!ready) { return "Email index not available. Please try again shortly."; } const result = await searchEmails(query, options); return formatEmailResults(result); } async function mailRecent(limit = 30, daysBack = 7, unreadOnly = false, includeJunk = false) { if (!sessionIndexComplete) { return getIndexingMessage(); } const ready = await isIndexReady("emails"); if (!ready) { return "Email index not available. Please try again shortly."; } const result = await getRecentEmailResults(limit, daysBack, unreadOnly, includeJunk); return formatEmailResults(result); } async function mailDate(date, includeJunk = false) { if (!sessionIndexComplete) { return getIndexingMessage(); } const ready = await isIndexReady("emails"); if (!ready) { return "Email index not available. Please try again shortly."; } const result = await getEmailDateResults(date, includeJunk); return formatEmailResults(result); } async function messagesSearch(query, options = {}) { if (!sessionIndexComplete) { return getIndexingMessage(); } const ready = await isIndexReady("messages"); if (!ready) { return "Messages index not available. Please try again shortly."; } const result = await searchMessages(query, options); return formatMessageResults(result); } async function messagesRecent(limit = 10, daysBack = 1) { if (!sessionIndexComplete) { return getIndexingMessage(); } const ready = await isIndexReady("messages"); if (!ready) { return "Messages index not available. Please try again shortly."; } const result = await getRecentMessageResults(limit, daysBack); return formatMessageResults(result); } async function messagesConversation(contact, limit = 50) { if (!sessionIndexComplete) { return getIndexingMessage(); } const ready = await isIndexReady("messages"); if (!ready) { return "Messages index not available. Please try again shortly."; } const result = await getConversationResults(contact, limit); return formatConversationResults(result); } async function calendarSearch(query, options = {}) { if (!sessionIndexComplete) { return getIndexingMessage(); } const ready = await isIndexReady("calendar"); if (!ready) { return "Calendar index not available. Please try again shortly."; } const result = await searchCalendar(query, options); return formatCalendarResults(result); } async function calendarDate(date) { if (!sessionIndexComplete) { return getIndexingMessage(); } const ready = await isIndexReady("calendar"); if (!ready) { return "Calendar index not available. Please try again shortly."; } const result = await getCalendarDateResults(date); return formatCalendarResults(result); } async function calendarFreeTime(date, options = {}) { if (!sessionIndexComplete) { return getIndexingMessage(); } const ready = await isIndexReady("calendar"); if (!ready) { return "Calendar index not available. Please try again shortly."; } const result = await calculateFreeTime(date, options); return formatFreeTimeResults(result); } // Mail directory for path validation const MAIL_DIR = path.join(process.env.HOME, "Library", "Mail"); // Read full email content from file path function readFullEmail(filePath) { try { // Validate file path to prevent path traversal attacks let validatedPath; try { validatedPath = validateEmailPath(filePath, MAIL_DIR); } catch (e) { return `Invalid file path: ${e.message}. Use a path from mail_search results.`; } // Verify file exists if (!fs.existsSync(validatedPath)) { return "Email file not found."; } const content = fs.readFileSync(validatedPath, 'utf-8'); // Parse email headers and body const fromMatch = content.match(/^From:\s*(.+)$/m); const toMatch = content.match(/^To:\s*(.+)$/m); const subjectMatch = content.match(/^Subject:\s*(.+)$/m); const dateMatch = content.match(/^Date:\s*(.+)$/m); // Find body after headers const headerEnd = content.search(/\r?\n\r?\n/); let body = headerEnd > 0 ? content.substring(headerEnd + 2) : content; // Clean up body - remove HTML if present using safe method if (body.includes('<html') || body.includes('<HTML')) { // Use safe HTML stripping to prevent ReDoS body = stripHtmlTags(body); // Decode common HTML entities body = body.replace(/&nbsp;/g, ' '); body = body.replace(/&amp;/g, '&'); body = body.replace(/&lt;/g, '<'); body = body.replace(/&gt;/g, '>'); } return `From: ${fromMatch?.[1]?.trim() || 'Unknown'} To: ${toMatch?.[1]?.trim() || 'Unknown'} Subject: ${subjectMatch?.[1]?.trim() || 'No subject'} Date: ${dateMatch?.[1]?.trim() || 'Unknown'} ${body.substring(0, 10000)}`; } catch (error) { return `Error reading email: ${error.message}`; } } // ============ SMART SEARCH (AGENTIC RAG) ============ // Detect which data sources to search based on query intent function detectSources(query) { const q = query.toLowerCase(); const sources = []; // Calendar indicators if (/\b(when|schedule|calendar|event|meeting|appointment|today|tomorrow|next \w+day|this week|free time|available|busy)\b/.test(q)) { sources.push('calendar'); } // Messages indicators if (/\b(said|message|text|chat|conversation|imessage|sms|texted|replied)\b/.test(q)) { sources.push('messages'); } // Mail indicators if (/\b(email|mail|sent|inbox|from .+ about|subject|attachment|forward|reply)\b/.test(q)) { sources.push('mail'); } // Default to all if no clear indicators return sources.length > 0 ? sources : ['mail', 'messages', 'calendar']; } // Format smart search results from multiple sources function formatSmartSearchResults(results, synthesizedGroups = null) { const sections = []; // If we have synthesized timeline groups, show those first if (synthesizedGroups && synthesizedGroups.length > 0) { sections.push("=== Timeline View (Related Items Grouped) ===\n"); for (const group of synthesizedGroups.slice(0, 5)) { sections.push(`📅 ${group.timeWindow} (${group.totalItems} items)`); if (group.calendar.length > 0) { sections.push(` Calendar: ${group.calendar.map(c => c.title).join(', ')}`); } if (group.mail.length > 0) { sections.push(` Emails: ${group.mail.map(m => m.subject).join(', ')}`); } if (group.messages.length > 0) { sections.push(` Messages: ${group.messages.length} from ${[...new Set(group.messages.map(m => m.sender))].join(', ')}`); } sections.push(""); } sections.push("=== Detailed Results ===\n"); } if (results.mail && results.mail.success && results.mail.results.length > 0) { sections.push("📧 EMAILS:"); for (const r of results.mail.results) { sections.push(` [${r.rank}] Score: ${r.score}`); sections.push(` From: ${r.from}`); sections.push(` Subject: ${r.subject}`); sections.push(` Date: ${r.date}`); sections.push(` File: ${r.filePath}`); } sections.push(""); } if (results.messages && results.messages.success && results.messages.results.length > 0) { sections.push("💬 MESSAGES:"); for (const r of results.messages.results) { sections.push(` [${r.rank}] Score: ${r.score}`); sections.push(` From: ${r.sender}${r.isGroupChat ? ' (Group)' : ''}`); sections.push(` Date: ${r.date}`); sections.push(` Text: ${r.text.substring(0, 100)}...`); } sections.push(""); } if (results.calendar && results.calendar.success && results.calendar.results.length > 0) { sections.push("📅 CALENDAR:"); for (const r of results.calendar.results) { sections.push(` [${r.rank}] Score: ${r.score}`); sections.push(` Event: ${r.title}${r.isAllDay ? ' (All Day)' : ''}`); sections.push(` Start: ${r.start}`); sections.push(` Calendar: ${r.calendar}`); if (r.location) sections.push(` Location: ${r.location}`); } sections.push(""); } if (sections.length === 0) { return "No results found across Mail, Messages, or Calendar."; } return sections.join("\n"); } // Smart search - routes to appropriate sources and optionally synthesizes results async function smartSearch(query, options = {}) { const { limit = 5, synthesize = true } = options; const sources = detectSources(query); const results = {}; // Search relevant sources in parallel const searches = []; if (sources.includes('calendar')) { searches.push( (async () => { const ready = await isIndexReady("calendar"); if (ready) { results.calendar = await searchCalendar(query, { limit, daysBack: 30, daysAhead: 30 }); } })() ); } if (sources.includes('messages')) { searches.push( (async () => { const ready = await isIndexReady("messages"); if (ready) { results.messages = await searchMessages(query, { limit, daysBack: 30 }); } })() ); } if (sources.includes('mail')) { searches.push( (async () => { const ready = await isIndexReady("emails"); if (ready) { results.mail = await searchEmails(query, { limit, daysBack: 30 }); } })() ); } await Promise.all(searches); // Synthesize results into timeline if multiple sources returned data let synthesizedGroups = null; if (synthesize) { const hasMultipleSources = (results.mail?.results?.length > 0 ? 1 : 0) + (results.messages?.results?.length > 0 ? 1 : 0) + (results.calendar?.results?.length > 0 ? 1 : 0) > 1; if (hasMultipleSources) { synthesizedGroups = synthesizeResults( results.mail?.results || [], results.messages?.results || [], results.calendar?.results || [] ); } } return formatSmartSearchResults(results, synthesizedGroups); } // Synthesize results from multiple sources into time-based groups function synthesizeResults(mailResults, messageResults, calendarResults) { const WINDOW_MS = 60 * 60 * 1000; // 1 hour window const timeline = new Map(); // Helper to find/create time bucket function getBucket(timestamp) { if (!timestamp || isNaN(timestamp)) return null; const rounded = Math.floor(timestamp / WINDOW_MS) * WINDOW_MS; if (!timeline.has(rounded)) { timeline.set(rounded, { mail: [], messages: [], calendar: [] }); } return timeline.get(rounded); } // Add mail results for (const r of mailResults) { const ts = r.dateTimestamp || new Date(r.date).getTime(); const bucket = getBucket(ts); if (bucket) bucket.mail.push(r); } // Add message results for (const r of messageResults) { const ts = r.dateTimestamp || new Date(r.date).getTime(); const bucket = getBucket(ts); if (bucket) bucket.messages.push(r); } // Add calendar results for (const r of calendarResults) { const ts = r.startTimestamp || new Date(r.start).getTime(); const bucket = getBucket(ts); if (bucket) bucket.calendar.push(r); } // Format grouped results const groups = Array.from(timeline.entries()) .filter(([_, g]) => g.mail.length + g.messages.length + g.calendar.length > 0) .sort((a, b) => b[0] - a[0]) // Newest first .map(([ts, group]) => ({ timeWindow: new Date(ts).toLocaleString(), ...group, totalItems: group.mail.length + group.messages.length + group.calendar.length })); return groups; } // ============ CONTACTS FORMATTING ============ function formatContactsSearchResults(contacts) { if (!contacts || contacts.length === 0) { return "No contacts found matching your search."; } let output = `Found ${contacts.length} contacts:\n\n`; for (const c of contacts) { output += `• ${c.displayName}`; if (c.organization) output += ` (${c.organization})`; output += "\n"; if (c.emails.length > 0) { output += ` Emails: ${c.emails.map(e => e.email).join(", ")}\n`; } if (c.phones.length > 0) { output += ` Phones: ${c.phones.map(p => p.phone).join(", ")}\n`; } output += "\n"; } return output; } function formatContactLookupResult(contact) { if (!contact) { return "Contact not found. Try searching with contacts_search for partial matches."; } let output = `Contact: ${contact.displayName}\n`; output += "─".repeat(40) + "\n"; if (contact.organization) output += `Organization: ${contact.organization}\n`; if (contact.department) output += `Department: ${contact.department}\n`; if (contact.jobTitle) output += `Job Title: ${contact.jobTitle}\n`; if (contact.nickname) output += `Nickname: ${contact.nickname}\n`; if (contact.emails.length > 0) { output += "\nEmail Addresses:\n"; for (const e of contact.emails) { output += ` • ${e.email} (${e.label})\n`; } } if (contact.phones.length > 0) { output += "\nPhone Numbers:\n"; for (const p of contact.phones) { output += ` • ${p.phone} (${p.label})\n`; } } return output; } // ============ PERSON SEARCH (CROSS-SOURCE) ============ async function personSearch(name, limit = 10) { // First, try to find the contact to get all their identifiers const contacts = searchContacts(name, 5); if (contacts.length === 0) { // No contact found, fall back to name-based search return await smartSearch(name, { limit, synthesize: true }); } // Get the best matching contact const contact = contacts[0]; const emails = contact.emails.map(e => e.email); const phones = contact.phones.map(p => p.phone); const results = { contact: { name: contact.displayName, organization: contact.organization, emails, phones }, mail: null, messages: null, calendar: null }; // Search all sources in parallel using the contact's identifiers const searches = []; // Mail search - search by all email addresses if (emails.length > 0) { searches.push( (async () => { const ready = await isIndexReady("emails"); if (ready) { // Search for emails from any of the contact's addresses for (const email of emails.slice(0, 3)) { // Limit to first 3 emails const emailResults = await searchEmails(contact.displayName, { limit, sender: email, daysBack: 365 // Last year }); if (emailResults.success && emailResults.results.length > 0) { if (!results.mail) results.mail = emailResults; else results.mail.results.push(...emailResults.results); } } } })() ); } // Messages search - search by phone numbers if (phones.length > 0) { searches.push( (async () => { const ready = await isIndexReady("messages"); if (ready) { for (const phone of phones.slice(0, 3)) { // Limit to first 3 phones const msgResults = await searchMessages(contact.displayName, { limit, contact: phone, daysBack: 365 }); if (msgResults.success && msgResults.results.length > 0) { if (!results.messages) results.messages = msgResults; else results.messages.results.push(...msgResults.results); } } } })() ); } // Calendar search - search by name searches.push( (async () => { const ready = await isIndexReady("calendar"); if (ready) { results.calendar = await searchCalendar(contact.displayName, { limit, daysBack: 90, daysAhead: 365 }); } })() ); await Promise.all(searches); // Format the results return formatPersonSearchResults(results); } function formatPersonSearchResults(results) { const sections = []; // Contact info header sections.push(`👤 ${results.contact.name}`); if (results.contact.organization) { sections.push(` Organization: ${results.contact.organization}`); } if (results.contact.emails.length > 0) { sections.push(` Emails: ${results.contact.emails.join(", ")}`); } if (results.contact.phones.length > 0) { sections.push(` Phones: ${results.contact.phones.join(", ")}`); } sections.push(""); sections.push("=".repeat(50)); sections.push(""); // Email results if (results.mail && results.mail.results && results.mail.results.length > 0) { sections.push(`📧 EMAILS (${results.mail.results.length}):`); for (const r of results.mail.results.slice(0, 10)) { sections.push(` • ${r.subject}`); sections.push(` ${r.date} | ${r.from}`); } sections.push(""); } else { sections.push("📧 EMAILS: None found"); sections.push(""); } // Message results if (results.messages && results.messages.results && results.messages.results.length > 0) { sections.push(`💬 MESSAGES (${results.messages.results.length}):`); for (const r of results.messages.results.slice(0, 10)) { sections.push(` • ${r.text.substring(0, 80)}${r.text.length > 80 ? "..." : ""}`); sections.push(` ${r.date}`); } sections.push(""); } else { sections.push("💬 MESSAGES: None found"); sections.push(""); } // Calendar results if (results.calendar && results.calendar.results && results.calendar.results.length > 0) { sections.push(`📅 CALENDAR (${results.calendar.results.length}):`); for (const r of results.calendar.results.slice(0, 10)) { sections.push(` • ${r.title}`); sections.push(` ${r.start} | ${r.calendar}`); } sections.push(""); } else { sections.push("📅 CALENDAR: None found"); sections.push(""); } return sections.join("\n"); } // ============ MCP SERVER SETUP ============ const server = new Server( { name: "apple-tools-mcp", version: "2.0.0" }, { capabilities: { tools: {} } } ); // Define available tools server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ // ============ SMART SEARCH (AGENTIC) ============ { name: "smart_search", 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.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Natural language search query (e.g., 'meeting with John', 'budget discussion', 'what happened yesterday')" }, limit: { type: "number", description: "Max results per source (default 5)" }, synthesize: { type: "boolean", description: "Group results by time proximity (default true)" } }, required: ["query"], }, }, // ============ EMAIL TOOLS ============ { name: "mail_search", 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.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Natural language search (e.g., 'invoices', 'meeting notes', 'from John about project')" }, limit: { type: "number", description: "Maximum results (default 30)" }, days_back: { type: "number", description: "Only emails from last N days (0 = all time)" }, sender: { type: "string", description: "Filter by sender name or email address" }, recipient: { type: "string", description: "Filter by recipient name or email address" }, has_attachment: { type: "boolean", description: "Filter to only emails with attachments (true) or without (false)" }, mailbox: { type: "string", description: "Filter by mailbox name (e.g., 'INBOX', 'Archive', 'Sent Messages')" }, sent_only: { type: "boolean", description: "true = only sent emails, false = only received emails, omit for all" }, flagged_only: { type: "boolean", description: "Only show flagged/starred emails" }, include_junk: { type: "boolean", description: "Include emails from Junk/Trash folders (excluded by default)" }, sort_by: { type: "string", enum: ["relevance", "date"], description: "Sort by relevance (default) or date (newest first)" } }, required: ["query"], }, }, { name: "mail_recent", 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'.", inputSchema: { type: "object", properties: { limit: { type: "number", description: "Maximum results (default 30)" }, days_back: { type: "number", description: "Only emails from last N days (default 7)" }, unread_only: { type: "boolean", description: "Only show unread emails (queries Mail.app for read status)" }, include_junk: { type: "boolean", description: "Include emails from Junk/Trash folders (excluded by default)" } }, }, }, { name: "mail_date", 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.", inputSchema: { type: "object", properties: { date: { type: "string", description: "Date to retrieve emails (e.g., 'today', 'yesterday', 'Nov 13', '2025-01-15')" }, include_junk: { type: "boolean", description: "Include emails from Junk/Trash folders (excluded by default)" } }, required: ["date"], }, }, { name: "mail_read", description: "Read full email content. Use the file_path from mail_search or mail_recent results.", inputSchema: { type: "object", properties: { file_path: { type: "string", description: "File path from mail_search results" }, }, required: ["file_path"], }, }, // ============ MESSAGES TOOLS ============ { name: "messages_search", description: "Semantic search for iMessages/SMS using AI embeddings. Finds messages by meaning. Supports filtering by contact, group chats, specific group name, and attachments.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Natural language search (e.g., 'dinner plans', 'about the trip', 'address')" }, limit: { type: "number", description: "Maximum results (default 30)" }, days_back: { type: "number", description: "Only messages from last N days (0 = all time)" }, contact: { type: "string", description: "Filter by contact name or phone number" }, group_chat_only: { type: "boolean", description: "Only show messages from group chats" }, group_chat_name: { type: "string", description: "Filter by specific group chat name" }, has_attachment: { type: "boolean", description: "Filter to messages with attachments (photos, files)" }, sort_by: { type: "string", enum: ["relevance", "date"], description: "Sort by relevance (default) or date (newest first)" } }, required: ["query"], }, }, { name: "messages_recent", 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'.", inputSchema: { type: "object", properties: { limit: { type: "number", description: "Maximum results (default 30)" }, days_back: { type: "number", description: "Only messages from last N days (default 1)" } }, }, }, { name: "messages_conversation", description: "Get full conversation history with a specific contact. Shows messages in chronological order.", inputSchema: { type: "object", properties: { contact: { type: "string", description: "Contact name or phone number" }, limit: { type: "number", description: "Maximum messages to return (default 50)" } }, required: ["contact"], }, }, // ============ CALENDAR TOOLS ============ { name: "calendar_search", description: "Semantic search for calendar events using AI embeddings. Finds events by meaning. Supports filtering by calendar name and all-day events.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Natural language search (e.g., 'meetings', 'doctor appointments', 'lunch')" }, limit: { type: "number", description: "Maximum results (default 30)" }, days_back: { type: "number", description: "Include events from last N days (0 = none)" }, days_ahead: { type: "number", description: "Include events in next N days (0 = none). Use for 'today', 'this week', etc." }, calendar_name: { type: "string", description: "Filter to specific calendar (e.g., 'Work', 'Personal')" }, all_day_only: { type: "boolean", description: "Only show all-day events" }, sort_by: { type: "string", enum: ["relevance", "date"], description: "Sort by relevance (default) or date (chronological)" } }, required: ["query"], }, }, { name: "calendar_date", description: "Get all events on a specific date. Supports natural language dates like 'today', 'tomorrow', 'next Tuesday', 'Jan 15'.", inputSchema: { type: "object", properties: { date: { type: "string", description: "Date to check (e.g., 'today', 'tomorrow', 'next Monday', '2025-01-15')" } }, required: ["date"], }, }, { name: "calendar_free_time", description: "Find free time slots on a specific date. Analyzes calendar to find available time windows.", inputSchema: { type: "object", properties: { date: { type: "string", description: "Date to check (e.g., 'today', 'tomorrow', 'next Monday')" }, start_hour: { type: "number", description: "Start of working hours (default 9 = 9 AM)" }, end_hour: { type: "number", description: "End of working hours (default 17 = 5 PM)" }, calendar_name: { type: "string", description: "Only consider events from this calendar" } }, required: ["date"], }, }, // ============ NEW TOOLS - PHASE 1 ============ // Mail tools { name: "mail_senders", description: "List most frequent email senders. Helps identify who you communicate with most.", inputSchema: { type: "object", properties: { limit: { type: "number", description: "Maximum senders to return (default 30)" }, days_back: { type: "number", description: "Only count emails from last N days (0 = all time)" }, include_junk: { type: "boolean", description: "Include senders from Junk/Trash folders (excluded by default)" } }, }, }, { name: "rebuild_index", 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.", inputSchema: { type: "object", properties: { sources: { type: "array", items: { type: "string", enum: ["emails", "messages", "calendar"] }, description: "Which sources to rebuild. Defaults to all sources if not specified." } }, }, }, { name: "audit_index", 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.", inputSchema: { type: "object", properties: { sources: { type: "array", items: { type: "string", enum: ["emails", "messages", "calendar"] }, description: "Data sources to audit (default: all)" }, max_items: { type: "number", description: "Max items to list per category (default: 100, use 0 for unlimited)" } }, }, }, // Messages tools { name: "messages_contacts", description: "List all contacts you've messaged, sorted by most recent. Shows message count and last message date.", inputSchema: { type: "object", properties: { limit: { type: "number", description: "Maximum contacts to return (default 50)" } }, }, }, // Calendar tools { name: "calendar_upcoming", description: "Get next N upcoming events across all calendars. Simpler than calendar_search for quick schedule overview.", inputSchema: { type: "object", properties: { limit: { type: "number", description: "Maximum events to return (default 10)" } }, }, }, // ============ NEW TOOLS - PHASE 2 ============ { name: "calendar_week", description: "Get all events for the current week or a future week. Shows events grouped by day.", inputSchema: { type: "object", properties: { week_offset: { type: "number", description: "0 = this week, 1 = next week, 2 = week after, etc. (default 0)" } }, }, }, // ============ NEW TOOLS - PHASE 3 ============ { name: "mail_thread", description: "Get all emails in a conversation thread. Finds related emails by matching subject lines.", inputSchema: { type: "object", properties: { file_path: { type: "string", description: "File path to any email in the thread" }, limit: { type: "number", description: "Maximum emails to return (default 30)" } }, required: ["file_path"], }, }, { name: "calendar_recurring", description: "List recurring events (events that appear multiple times). Shows upcoming occurrences.", inputSchema: { type: "object", properties: { limit: { type: "number", description: "Maximum recurring events to return (default 30)" } }, }, }, // ============ CONTACTS TOOLS ============ { name: "contacts_search", description: "Search your contacts by name, email, phone, or organization. Returns matching contacts with all their details.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query (e.g., 'John', 'Acme Corp', 'john@example.com')" }, limit: { type: "number", description: "Maximum results (default 30)" } }, required: ["query"], }, }, { name: "contacts_lookup", description: "Look up a specific contact by email, phone number, or name. Returns full contact details including all emails and phone numbers.", inputSchema: { type: "object", properties: { identifier: { type: "string", description: "Email address, phone number, or name to look up" } }, required: ["identifier"], }, }, { name: "person_search", 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.", inputSchema: { type: "object", properties: { name: { type: "string", description: "Person's name to search for (will resolve to all their email addresses and phone numbers)" }, limit: { type: "number", description: "Maximum results per source (default 10)" } }, required: ["name"], }, }, ], })); // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { let result; switch (name) { // Smart search (agentic) case "smart_search": result = await smartSearch(args.query, { limit: args?.limit || 5, synthesize: args?.synthesize !== false }); break; // Email tools case "mail_search": result = await mailSearch(args.query, { limit: args?.limit || 30, daysBack: args?.days_back || 0, sender: args?.sender || null, recipient: args?.recipient || null, hasAttachment: args?.has_attachment ?? null, mailbox: args?.mailbox || null, sentOnly: args?.sent_only ?? null, flaggedOnly: args?.flagged_only || false, includeJunk: args?.include_junk || false, sortBy: args?.sort_by || "relevance" }); break; case "mail_recent": result = await mailRecent(args?.limit || 30, args?.days_back || 7, args?.unread_only || false, args?.include_junk || false); break; case "mail_date": result = await mailDate(args.date, args?.include_junk || false); break; case "mail_read": result = readFullEmail(args.file_path); break; // Messages tools case "messages_search": result = await messagesSearch(args.query, { limit: args?.limit || 30, daysBack: args?.days_back || 0, contact: args?.contact || null, groupChatOnly: args?.group_chat_only || false, groupChatName: args?.group_chat_name || null, hasAttachment: args?.has_attachment ?? null, sortBy: args?.sort_by || "relevance" }); break; case "messages_recent": result = await messagesRecent(args?.limit || 30, args?.days_back || 1); break; case "messages_conversation": result = await messagesConversation(args.contact, args?.limit || 50); break; // Calendar tools case "calendar_search": result = await calendarSearch(args.query, { limit: args?.limit || 30, daysBack: args?.days_back || 0, daysAhead: args?.days_ahead || 0, calendarName: args?.calendar_name || null, allDayOnly: args?.all_day_only || false, sortBy: args?.sort_by || "relevance" }); break; case "calendar_date": result = await calendarDate(args.date); break; case "calendar_free_time": result = await calendarFreeTime(args.date, { startHour: args?.start_hour || 9, endHour: args?.end_hour || 17, calendarName: args?.calendar_name || null }); break; // ============ NEW TOOLS - PHASE 1 ============ // Mail tools case "mail_senders": result = formatSendersResults(await getFrequentSenders(args?.limit || 30, args?.days_back || 0, args?.include_junk || false)); break; case "rebuild_index": // Check if indexing is already in progress in this session if (indexingInProgress) { result = "⏳ Indexing is already in progress. Please wait for it to complete before starting a rebuild."; break; } // Acquire lock to prevent parallel rebuilds across multiple MCP instances if (!acquireLock()) { result = "Another indexing operation is already in progress in a different session. Please wait for it to complete."; break; } // Start rebuild in background and return immediately indexingInProgress = true; const rebuildSources = args?.sources || ["emails", "messages", "calendar"]; // Fire and forget - don't await rebuildIndex(rebuildSources).then((rebuildResult) => { sessionIndexComplete = true; isFirstEverRun = false; indexingInProgress = false; releaseLock(); console.error("Index rebuild completed:", JSON.stringify({ cleared: rebuildResult.cleared, indexed: Object.fromEntries( Object.entries(rebuildResult.indexed).map(([k, v]) => [k, v?.added || 0]) ), errors: rebuildResult.errors.length })); }).catch(e => { console.error("Index rebuild error:", e.message); indexingInProgress = false; releaseLock(); }); 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.`; break; case "audit_index": { const auditSources = args?.sources || ["emails", "messages", "calendar"]; const maxItems = args?.max_items !== undefined ? args.max_items : 100; console.error(`Starting audit for: ${auditSources.join(", ")}`); const auditResults = await auditAll({ sources: auditSources, maxItems }); result = formatAuditReport(auditResults); } break; // Messages tools case "messages_contacts": result = formatMessageContactsResults(getMessageContacts(args?.limit || 50)); break; // Calendar tools case "calendar_upcoming": result = formatUpcomingEventsResults(getUpcomingEvents(args?.limit || 30)); break; // ============ NEW TOOLS - PHASE 2 ============ case "calendar_week": result = formatWeekEventsResults(getWeekEvents(args?.week_offset || 0)); break; // ============ NEW TOOLS - PHASE 3 ============ case "mail_thread": result = formatEmailThreadResults(await getEmailThread(args.file_path, args?.limit || 30)); break; case "calendar_recurring": result = formatRecurringEventsResults(getRecurringEvents(args?.limit || 30)); break; // ============ CONTACTS TOOLS ============ case "contacts_search": result = formatContactsSearchResults(searchContacts(args.query, args?.limit || 30)); break; case "contacts_lookup": result = formatContactLookupResult(lookupContact(args.identifier)); break; case "person_search": result = await personSearch(args.name, args?.limit || 10); break; default: throw new Error(`Unknown tool: ${name}`); } return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true, }; } }); // Start the server async function main() { // Kill any zombie processes from previous sessions cleanupZombieProcesses(); const transport = new StdioServerTransport(); await server.connect(transport); console.error("Apple Tools MCP server running (v2.0.0)"); // Background indexing runs automatically on startup and every INDEX_INTERVAL } main().catch(console.error);

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/sfls1397/Apple-Tools-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server