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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Peter Coates
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # apple-tools-mcp
2
+
3
+ An MCP (Model Context Protocol) server that provides semantic search across Apple Mail, Messages, and Calendar on macOS. Use natural language to search your emails, iMessages, and calendar events directly from Claude.
4
+
5
+ ## Features
6
+
7
+ - **Semantic Search**: Find emails, messages, and events using natural language queries
8
+ - **Vector Indexing**: Uses LanceDB for fast similarity search with local embeddings
9
+ - **Privacy-First**: All processing happens locally on your Mac - no data leaves your machine
10
+ - **Smart Deduplication**: Handles IMAP duplicates, prioritizing INBOX over Junk/Trash
11
+ - **Date Intelligence**: Understands queries like "last week", "yesterday", "March 2024"
12
+
13
+ ## Requirements
14
+
15
+ - **macOS** (Ventura 13.0 or later recommended)
16
+ - **Node.js** 18.0 or later
17
+ - **Claude Desktop** app
18
+ - **Full Disk Access** permission for the Node.js binary
19
+
20
+ ## Installation
21
+
22
+ ### 1. Install the package
23
+
24
+ ```bash
25
+ npm install -g apple-tools-mcp
26
+ ```
27
+
28
+ ### 2. Grant Full Disk Access
29
+
30
+ The MCP server needs access to read your Mail, Messages, and Calendar databases.
31
+
32
+ 1. Open **System Settings** → **Privacy & Security** → **Full Disk Access**
33
+ 2. Click the **+** button
34
+ 3. Navigate to your Node.js binary:
35
+ - For Homebrew: `/opt/homebrew/bin/node`
36
+ - For nvm: `~/.nvm/versions/node/v[VERSION]/bin/node`
37
+ - To find yours: `which node`
38
+ 4. Enable the toggle for Node.js
39
+
40
+ ### 3. Configure Claude Desktop
41
+
42
+ Add to your Claude Desktop config file:
43
+
44
+ **Location:** `~/Library/Application Support/Claude/claude_desktop_config.json`
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "apple-tools": {
50
+ "command": "npx",
51
+ "args": ["-y", "apple-tools-mcp"]
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ ### 4. Restart Claude Desktop
58
+
59
+ Quit and reopen Claude Desktop to load the MCP server.
60
+
61
+ ## Building the Index
62
+
63
+ On first use, the server will automatically build a vector index of your recent emails, messages, and calendar events. This may take a few minutes depending on the volume of data.
64
+
65
+ You can manually rebuild the index:
66
+
67
+ ```bash
68
+ # Index last 30 days (default)
69
+ npx apple-tools-mcp build-index
70
+
71
+ # Index more history
72
+ APPLE_TOOLS_INDEX_DAYS_BACK=90 npx apple-tools-mcp build-index
73
+ ```
74
+
75
+ The index is stored in `~/.apple-tools-mcp/vector-index/`.
76
+
77
+ ## Available Tools
78
+
79
+ Once configured, Claude can use these tools:
80
+
81
+ | Tool | Description |
82
+ |------|-------------|
83
+ | `search_emails` | Search emails by content, sender, subject |
84
+ | `search_messages` | Search iMessages and SMS |
85
+ | `search_calendar` | Search calendar events |
86
+ | `search_all` | Search across all sources |
87
+ | `get_email` | Get full email by ID |
88
+ | `get_message` | Get full message thread |
89
+ | `get_calendar_event` | Get event details |
90
+
91
+ ## Example Queries
92
+
93
+ Ask Claude things like:
94
+
95
+ - "Find emails from John about the quarterly report"
96
+ - "What messages did I get from Mom last week?"
97
+ - "When is my next dentist appointment?"
98
+ - "Search for emails about the AWS bill from November"
99
+ - "Find all calendar events with Zoom links"
100
+
101
+ ## Privacy & Security
102
+
103
+ - **Local Processing**: All embeddings are generated locally using Xenova/Transformers
104
+ - **No Cloud Services**: No data is sent to external servers
105
+ - **Read-Only**: The server only reads data, never modifies your Mail/Messages/Calendar
106
+ - **Your Data**: The vector index is stored locally in your home directory
107
+
108
+ ## Troubleshooting
109
+
110
+ ### "Authorization denied" errors
111
+
112
+ Ensure Node.js has Full Disk Access (see Installation step 2).
113
+
114
+ ### Empty search results
115
+
116
+ 1. Check that the index was built: `ls ~/.apple-tools-mcp/vector-index/`
117
+ 2. Rebuild the index if needed: `npx apple-tools-mcp build-index`
118
+
119
+ ### Server not appearing in Claude
120
+
121
+ 1. Verify your config file syntax is valid JSON
122
+ 2. Restart Claude Desktop completely (Cmd+Q, then reopen)
123
+ 3. Check Claude's MCP logs for errors
124
+
125
+ ## Development
126
+
127
+ ```bash
128
+ # Clone the repo
129
+ git clone https://github.com/sfls1397/apple-tools-mcp.git
130
+ cd apple-tools-mcp
131
+
132
+ # Install dependencies
133
+ npm install
134
+
135
+ # Run tests
136
+ npm test
137
+
138
+ # Build index with debug output
139
+ npm run build-index
140
+
141
+ # Run audit to check index health
142
+ npm run audit
143
+ ```
144
+
145
+ ## Contributing
146
+
147
+ Contributions are welcome! Please:
148
+
149
+ 1. Fork the repository
150
+ 2. Create a feature branch
151
+ 3. Make your changes
152
+ 4. Run the tests: `npm test`
153
+ 5. Submit a pull request
154
+
155
+ ## License
156
+
157
+ MIT License - see [LICENSE](LICENSE) for details.
158
+
159
+ ## Acknowledgments
160
+
161
+ - Built with the [Model Context Protocol SDK](https://github.com/modelcontextprotocol/sdk)
162
+ - Vector search powered by [LanceDB](https://lancedb.com/)
163
+ - Local embeddings via [Xenova/Transformers](https://github.com/xenova/transformers.js)
package/contacts.js ADDED
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Contact Resolution Service
3
+ *
4
+ * Provides unified contact lookup across email addresses, phone numbers, and names.
5
+ * Uses macOS AddressBook database to resolve identifiers to full contact records.
6
+ */
7
+
8
+ import fs from "fs";
9
+ import path from "path";
10
+ import { safeSqlite3, safeSqlite3Json } from "./lib/shell.js";
11
+
12
+ // Database location - iCloud synced contacts are in Sources subdirectory
13
+ const ADDRESSBOOK_DIR = path.join(process.env.HOME, "Library", "Application Support", "AddressBook");
14
+ const SOURCES_DIR = path.join(ADDRESSBOOK_DIR, "Sources");
15
+
16
+ // In-memory lookup maps for fast resolution
17
+ let contactsLoaded = false;
18
+ let contacts = []; // Array of all contact records
19
+ const emailToContact = new Map(); // email (lowercase) -> contact
20
+ const phoneToContact = new Map(); // normalized phone -> contact
21
+ const nameToContact = new Map(); // "firstname lastname" (lowercase) -> contact[]
22
+
23
+ // Cache settings
24
+ const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
25
+ let lastLoadTime = 0;
26
+
27
+ // Memory bounds - prevent excessive memory usage with very large contact databases
28
+ const MAX_CONTACTS = 50000; // Maximum contacts to load
29
+ const MAX_EMAILS_PER_CONTACT = 10; // Maximum email addresses per contact
30
+ const MAX_PHONES_PER_CONTACT = 10; // Maximum phone numbers per contact
31
+
32
+ /**
33
+ * Find the contacts database file (handles iCloud sync location)
34
+ */
35
+ function findContactsDatabase() {
36
+ // First check Sources directory for iCloud-synced contacts
37
+ if (fs.existsSync(SOURCES_DIR)) {
38
+ const sources = fs.readdirSync(SOURCES_DIR);
39
+ for (const source of sources) {
40
+ const dbPath = path.join(SOURCES_DIR, source, "AddressBook-v22.abcddb");
41
+ if (fs.existsSync(dbPath)) {
42
+ // Check if it has actual data (not empty)
43
+ try {
44
+ const result = safeSqlite3(dbPath, "SELECT COUNT(*) FROM ZABCDRECORD", { json: false, timeout: 5000 });
45
+ const count = parseInt(result.trim());
46
+ if (count > 0) {
47
+ return dbPath;
48
+ }
49
+ } catch (e) {
50
+ // Continue to next source
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ // Fallback to main AddressBook database
57
+ const mainDb = path.join(ADDRESSBOOK_DIR, "AddressBook-v22.abcddb");
58
+ if (fs.existsSync(mainDb)) {
59
+ return mainDb;
60
+ }
61
+
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Normalize phone number for consistent matching
67
+ * Strips all non-digit characters except leading +
68
+ */
69
+ function normalizePhone(phone) {
70
+ if (!phone) return "";
71
+ // Keep leading + for international, then only digits
72
+ const hasPlus = phone.startsWith("+");
73
+ const digits = phone.replace(/\D/g, "");
74
+ // For US numbers, normalize to 10 digits (remove leading 1)
75
+ const wasUSNumber = digits.length === 11 && digits.startsWith("1");
76
+ const normalized = wasUSNumber
77
+ ? digits.slice(1)
78
+ : digits;
79
+ // Only keep + if it wasn't a US number (didn't remove leading 1)
80
+ return (hasPlus && !wasUSNumber) ? `+${normalized}` : normalized;
81
+ }
82
+
83
+ /**
84
+ * Load all contacts from the AddressBook database
85
+ */
86
+ export function loadContacts() {
87
+ const now = Date.now();
88
+
89
+ // Return cached if still valid
90
+ if (contactsLoaded && (now - lastLoadTime) < CACHE_TTL) {
91
+ return contacts;
92
+ }
93
+
94
+ const dbPath = findContactsDatabase();
95
+ if (!dbPath) {
96
+ console.error("Contacts database not found");
97
+ return [];
98
+ }
99
+
100
+ try {
101
+ // Query all contacts with their basic info (with limit for memory safety)
102
+ const contactQuery = `
103
+ SELECT
104
+ Z_PK as id,
105
+ ZFIRSTNAME as firstName,
106
+ ZLASTNAME as lastName,
107
+ ZNICKNAME as nickname,
108
+ ZORGANIZATION as organization,
109
+ ZDEPARTMENT as department,
110
+ ZJOBTITLE as jobTitle
111
+ FROM ZABCDRECORD
112
+ WHERE ZFIRSTNAME IS NOT NULL OR ZLASTNAME IS NOT NULL OR ZORGANIZATION IS NOT NULL
113
+ LIMIT ${MAX_CONTACTS}
114
+ `;
115
+
116
+ const rawContacts = safeSqlite3Json(dbPath, contactQuery, { timeout: 10000 });
117
+
118
+ if (rawContacts.length >= MAX_CONTACTS) {
119
+ console.error(`Warning: Contact limit reached (${MAX_CONTACTS}). Some contacts may not be searchable.`);
120
+ }
121
+
122
+ // Query all email addresses
123
+ const emailQuery = `
124
+ SELECT
125
+ ZOWNER as contactId,
126
+ ZADDRESS as email,
127
+ ZLABEL as label
128
+ FROM ZABCDEMAILADDRESS
129
+ WHERE ZADDRESS IS NOT NULL
130
+ `;
131
+
132
+ const emails = safeSqlite3Json(dbPath, emailQuery, { timeout: 10000 });
133
+
134
+ // Query all phone numbers
135
+ const phoneQuery = `
136
+ SELECT
137
+ ZOWNER as contactId,
138
+ ZFULLNUMBER as phone,
139
+ ZLABEL as label
140
+ FROM ZABCDPHONENUMBER
141
+ WHERE ZFULLNUMBER IS NOT NULL
142
+ `;
143
+
144
+ const phones = safeSqlite3Json(dbPath, phoneQuery, { timeout: 10000 });
145
+
146
+ // Group emails and phones by contact ID (with per-contact limits)
147
+ const emailsByContact = new Map();
148
+ for (const e of emails) {
149
+ if (!emailsByContact.has(e.contactId)) {
150
+ emailsByContact.set(e.contactId, []);
151
+ }
152
+ const contactEmails = emailsByContact.get(e.contactId);
153
+ // Limit emails per contact to prevent memory issues
154
+ if (contactEmails.length < MAX_EMAILS_PER_CONTACT) {
155
+ contactEmails.push({
156
+ email: e.email,
157
+ label: e.label || "other"
158
+ });
159
+ }
160
+ }
161
+
162
+ const phonesByContact = new Map();
163
+ for (const p of phones) {
164
+ if (!phonesByContact.has(p.contactId)) {
165
+ phonesByContact.set(p.contactId, []);
166
+ }
167
+ const contactPhones = phonesByContact.get(p.contactId);
168
+ // Limit phones per contact to prevent memory issues
169
+ if (contactPhones.length < MAX_PHONES_PER_CONTACT) {
170
+ contactPhones.push({
171
+ phone: p.phone,
172
+ normalized: normalizePhone(p.phone),
173
+ label: p.label || "other"
174
+ });
175
+ }
176
+ }
177
+
178
+ // Build complete contact records and lookup maps
179
+ contacts = [];
180
+ emailToContact.clear();
181
+ phoneToContact.clear();
182
+ nameToContact.clear();
183
+
184
+ for (const c of rawContacts) {
185
+ const contact = {
186
+ id: c.id,
187
+ firstName: c.firstName || "",
188
+ lastName: c.lastName || "",
189
+ nickname: c.nickname || "",
190
+ organization: c.organization || "",
191
+ department: c.department || "",
192
+ jobTitle: c.jobTitle || "",
193
+ emails: emailsByContact.get(c.id) || [],
194
+ phones: phonesByContact.get(c.id) || [],
195
+ displayName: formatDisplayName(c)
196
+ };
197
+
198
+ contacts.push(contact);
199
+
200
+ // Build email lookup
201
+ for (const e of contact.emails) {
202
+ const emailLower = e.email.toLowerCase();
203
+ emailToContact.set(emailLower, contact);
204
+ }
205
+
206
+ // Build phone lookup (using normalized phone)
207
+ for (const p of contact.phones) {
208
+ if (p.normalized) {
209
+ phoneToContact.set(p.normalized, contact);
210
+ }
211
+ }
212
+
213
+ // Build name lookup
214
+ const fullName = `${contact.firstName} ${contact.lastName}`.trim().toLowerCase();
215
+ if (fullName) {
216
+ if (!nameToContact.has(fullName)) {
217
+ nameToContact.set(fullName, []);
218
+ }
219
+ nameToContact.get(fullName).push(contact);
220
+ }
221
+
222
+ // Also index by first name only for fuzzy matching
223
+ if (contact.firstName) {
224
+ const firstLower = contact.firstName.toLowerCase();
225
+ if (!nameToContact.has(firstLower)) {
226
+ nameToContact.set(firstLower, []);
227
+ }
228
+ nameToContact.get(firstLower).push(contact);
229
+ }
230
+
231
+ // Index by nickname
232
+ if (contact.nickname) {
233
+ const nickLower = contact.nickname.toLowerCase();
234
+ if (!nameToContact.has(nickLower)) {
235
+ nameToContact.set(nickLower, []);
236
+ }
237
+ nameToContact.get(nickLower).push(contact);
238
+ }
239
+ }
240
+
241
+ contactsLoaded = true;
242
+ lastLoadTime = now;
243
+ console.error(`Contacts: Loaded ${contacts.length} contacts with ${emailToContact.size} emails and ${phoneToContact.size} phones`);
244
+
245
+ return contacts;
246
+ } catch (e) {
247
+ console.error("Error loading contacts:", e.message);
248
+ return [];
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Format display name from contact record
254
+ */
255
+ function formatDisplayName(contact) {
256
+ const parts = [];
257
+ if (contact.firstName) parts.push(contact.firstName);
258
+ if (contact.lastName) parts.push(contact.lastName);
259
+ if (parts.length === 0 && contact.organization) {
260
+ return contact.organization;
261
+ }
262
+ return parts.join(" ");
263
+ }
264
+
265
+ /**
266
+ * Resolve an email address to a contact record
267
+ * @param {string} email - Email address to look up
268
+ * @returns {object|null} Contact record or null if not found
269
+ */
270
+ export function resolveEmail(email) {
271
+ if (!email) return null;
272
+ loadContacts(); // Ensure contacts are loaded
273
+ return emailToContact.get(email.toLowerCase()) || null;
274
+ }
275
+
276
+ /**
277
+ * Resolve a phone number to a contact record
278
+ * @param {string} phone - Phone number to look up (any format)
279
+ * @returns {object|null} Contact record or null if not found
280
+ */
281
+ export function resolvePhone(phone) {
282
+ if (!phone) return null;
283
+ loadContacts(); // Ensure contacts are loaded
284
+ const normalized = normalizePhone(phone);
285
+ return phoneToContact.get(normalized) || null;
286
+ }
287
+
288
+ /**
289
+ * Resolve a name to matching contact records (fuzzy match)
290
+ * @param {string} name - Name to search for
291
+ * @returns {object[]} Array of matching contacts (may be empty)
292
+ */
293
+ export function resolveByName(name) {
294
+ if (!name) return [];
295
+ loadContacts(); // Ensure contacts are loaded
296
+
297
+ const nameLower = name.toLowerCase().trim();
298
+
299
+ // Exact match first
300
+ const exact = nameToContact.get(nameLower);
301
+ if (exact && exact.length > 0) {
302
+ return exact;
303
+ }
304
+
305
+ // Partial match - search all contacts
306
+ const matches = [];
307
+ for (const contact of contacts) {
308
+ const fullName = `${contact.firstName} ${contact.lastName}`.toLowerCase();
309
+ const orgName = contact.organization?.toLowerCase() || "";
310
+ const nick = contact.nickname?.toLowerCase() || "";
311
+
312
+ if (fullName.includes(nameLower) ||
313
+ orgName.includes(nameLower) ||
314
+ nick.includes(nameLower) ||
315
+ nameLower.includes(contact.firstName?.toLowerCase() || "---") ||
316
+ nameLower.includes(contact.lastName?.toLowerCase() || "---")) {
317
+ matches.push(contact);
318
+ }
319
+ }
320
+
321
+ return matches;
322
+ }
323
+
324
+ /**
325
+ * Get all identifiers (emails and phones) for a contact
326
+ * @param {number} contactId - Contact ID
327
+ * @returns {object} { emails: string[], phones: string[] }
328
+ */
329
+ export function getContactIdentifiers(contactId) {
330
+ loadContacts();
331
+ const contact = contacts.find(c => c.id === contactId);
332
+ if (!contact) {
333
+ return { emails: [], phones: [] };
334
+ }
335
+ return {
336
+ emails: contact.emails.map(e => e.email),
337
+ phones: contact.phones.map(p => p.phone)
338
+ };
339
+ }
340
+
341
+ /**
342
+ * Search contacts by query (name, email, phone, or organization)
343
+ * @param {string} query - Search query
344
+ * @param {number} limit - Maximum results
345
+ * @returns {object[]} Matching contacts
346
+ */
347
+ export function searchContacts(query, limit = 30) {
348
+ loadContacts();
349
+
350
+ if (!query) {
351
+ return contacts.slice(0, limit);
352
+ }
353
+
354
+ const queryLower = query.toLowerCase();
355
+ const matches = [];
356
+
357
+ for (const contact of contacts) {
358
+ // Check all searchable fields
359
+ const searchText = [
360
+ contact.firstName,
361
+ contact.lastName,
362
+ contact.nickname,
363
+ contact.organization,
364
+ contact.department,
365
+ contact.jobTitle,
366
+ ...contact.emails.map(e => e.email),
367
+ ...contact.phones.map(p => p.phone)
368
+ ].filter(Boolean).join(" ").toLowerCase();
369
+
370
+ if (searchText.includes(queryLower)) {
371
+ matches.push(contact);
372
+ if (matches.length >= limit) break;
373
+ }
374
+ }
375
+
376
+ return matches;
377
+ }
378
+
379
+ /**
380
+ * Look up a contact by any identifier (email, phone, or name)
381
+ * @param {string} identifier - Email, phone, or name
382
+ * @returns {object|null} Contact record or null
383
+ */
384
+ export function lookupContact(identifier) {
385
+ if (!identifier) return null;
386
+
387
+ // Try email first (most specific)
388
+ let contact = resolveEmail(identifier);
389
+ if (contact) return contact;
390
+
391
+ // Try phone
392
+ contact = resolvePhone(identifier);
393
+ if (contact) return contact;
394
+
395
+ // Try name (return first match)
396
+ const nameMatches = resolveByName(identifier);
397
+ if (nameMatches.length > 0) {
398
+ return nameMatches[0];
399
+ }
400
+
401
+ return null;
402
+ }
403
+
404
+ /**
405
+ * Format contact for display
406
+ * @param {object} contact - Contact record
407
+ * @returns {string} Formatted contact string
408
+ */
409
+ export function formatContact(contact) {
410
+ if (!contact) return "Unknown";
411
+
412
+ let result = contact.displayName;
413
+ if (contact.organization && contact.displayName !== contact.organization) {
414
+ result += ` (${contact.organization})`;
415
+ }
416
+ return result;
417
+ }
418
+
419
+ /**
420
+ * Get contact stats
421
+ * @returns {object} Stats about loaded contacts
422
+ */
423
+ export function getContactStats() {
424
+ loadContacts();
425
+ return {
426
+ total: contacts.length,
427
+ totalContacts: contacts.length,
428
+ withEmail: [...emailToContact.values()].length,
429
+ withPhone: [...phoneToContact.values()].length,
430
+ uniqueEmails: emailToContact.size,
431
+ uniquePhones: phoneToContact.size
432
+ };
433
+ }