apple-tools-mcp 3.0.8 → 3.0.9
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/contacts.js +13 -1
- package/index.js +3 -3
- package/indexer.js +10 -8
- package/lib/messageContact.js +106 -0
- package/package.json +1 -1
- package/search.js +22 -9
package/contacts.js
CHANGED
|
@@ -104,7 +104,7 @@ function findContactsDatabase() {
|
|
|
104
104
|
* Normalize phone number for consistent matching
|
|
105
105
|
* Strips all non-digit characters except leading +
|
|
106
106
|
*/
|
|
107
|
-
function normalizePhone(phone) {
|
|
107
|
+
export function normalizePhone(phone) {
|
|
108
108
|
if (!phone) return "";
|
|
109
109
|
// Keep leading + for international, then only digits
|
|
110
110
|
const hasPlus = phone.startsWith("+");
|
|
@@ -373,6 +373,18 @@ export function resolveByName(name) {
|
|
|
373
373
|
return matches;
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Resolve a name to contacts by exact full name, first name, or nickname
|
|
378
|
+
* (case-insensitive). No partial matching.
|
|
379
|
+
* @param {string} name - Name to look up
|
|
380
|
+
* @returns {object[]} Matching contacts (may be empty)
|
|
381
|
+
*/
|
|
382
|
+
export function resolveByExactName(name) {
|
|
383
|
+
if (!name) return [];
|
|
384
|
+
loadContacts(); // Ensure contacts are loaded
|
|
385
|
+
return nameToContact.get(name.toLowerCase().trim()) || [];
|
|
386
|
+
}
|
|
387
|
+
|
|
376
388
|
/**
|
|
377
389
|
* Get all identifiers (emails and phones) for a contact
|
|
378
390
|
* @param {number} contactId - Contact ID
|
package/index.js
CHANGED
|
@@ -1185,7 +1185,7 @@ function createServer() {
|
|
|
1185
1185
|
query: { type: "string", description: "Natural language search (e.g., 'dinner plans', 'about the trip', 'address')" },
|
|
1186
1186
|
limit: { type: "number", description: "Maximum results (default 30)" },
|
|
1187
1187
|
days_back: { type: "number", description: "Only messages from last N days (0 = all time)" },
|
|
1188
|
-
contact: { type: "string", description: "Filter by contact name or phone number" },
|
|
1188
|
+
contact: { type: "string", description: "Filter by contact: exact Contacts name (full name, first name, or nickname, e.g. 'Dad'), phone number in any format, or email. Includes my replies in that 1:1 chat." },
|
|
1189
1189
|
group_chat_only: { type: "boolean", description: "Only show messages from group chats" },
|
|
1190
1190
|
group_chat_name: { type: "string", description: "Filter by specific group chat name" },
|
|
1191
1191
|
has_attachment: { type: "boolean", description: "Filter to messages with attachments (photos, files)" },
|
|
@@ -1196,7 +1196,7 @@ function createServer() {
|
|
|
1196
1196
|
},
|
|
1197
1197
|
{
|
|
1198
1198
|
name: "messages_recent",
|
|
1199
|
-
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'.",
|
|
1199
|
+
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'. My own messages show a To: line naming the contact or group chat they were sent to.",
|
|
1200
1200
|
inputSchema: {
|
|
1201
1201
|
type: "object",
|
|
1202
1202
|
properties: {
|
|
@@ -1211,7 +1211,7 @@ function createServer() {
|
|
|
1211
1211
|
inputSchema: {
|
|
1212
1212
|
type: "object",
|
|
1213
1213
|
properties: {
|
|
1214
|
-
contact: { type: "string", description: "
|
|
1214
|
+
contact: { type: "string", description: "Exact Contacts name (full name, first name, or nickname, e.g. 'Dad'), phone number in any format, or email. Returns both sides of the 1:1 thread, including my replies." },
|
|
1215
1215
|
limit: { type: "number", description: "Maximum messages to return (default 50)" }
|
|
1216
1216
|
},
|
|
1217
1217
|
required: ["contact"],
|
package/indexer.js
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
import { safeSqlite3Json, safeOsascript, safeFind } from "./lib/shell.js";
|
|
17
17
|
import { indexUnavailableMessage } from "./lib/indexGate.js";
|
|
18
18
|
import { createLanceTableCache, LANCE_CONNECT_OPTIONS } from "./lib/lancedbTables.js";
|
|
19
|
+
import { createMessageContactMatcher } from "./lib/messageContact.js";
|
|
20
|
+
import { resolveByExactName, normalizePhone } from "./contacts.js";
|
|
19
21
|
|
|
20
22
|
// Re-export contact functions for use by other modules
|
|
21
23
|
export {
|
|
@@ -23,6 +25,8 @@ export {
|
|
|
23
25
|
resolveEmail,
|
|
24
26
|
resolvePhone,
|
|
25
27
|
resolveByName,
|
|
28
|
+
resolveByExactName,
|
|
29
|
+
normalizePhone,
|
|
26
30
|
lookupContact,
|
|
27
31
|
searchContacts,
|
|
28
32
|
getContactIdentifiers,
|
|
@@ -1538,7 +1542,7 @@ export async function getRecentMessages(limit = 10, daysBack = 1) {
|
|
|
1538
1542
|
try {
|
|
1539
1543
|
const cutoff = Date.now() - (daysBack * 24 * 60 * 60 * 1000);
|
|
1540
1544
|
const results = await tables.messages.query()
|
|
1541
|
-
.select(["id", "date", "dateTimestamp", "sender", "text", "chatId", "isGroupChat"])
|
|
1545
|
+
.select(["id", "date", "dateTimestamp", "sender", "text", "chatId", "chatIdentifier", "chatName", "isGroupChat"])
|
|
1542
1546
|
.toArray();
|
|
1543
1547
|
|
|
1544
1548
|
const filtered = results
|
|
@@ -1559,17 +1563,15 @@ export async function getConversation(contact, limit = 50) {
|
|
|
1559
1563
|
if (!tables.messages) return [];
|
|
1560
1564
|
|
|
1561
1565
|
try {
|
|
1562
|
-
const
|
|
1566
|
+
const matcher = createMessageContactMatcher(contact, { resolveExactName: resolveByExactName, normalizePhone });
|
|
1567
|
+
if (!matcher) return [];
|
|
1563
1568
|
const results = await tables.messages.query()
|
|
1564
1569
|
.select(["id", "date", "dateTimestamp", "sender", "text", "chatId", "chatIdentifier"])
|
|
1565
1570
|
.toArray();
|
|
1566
1571
|
|
|
1567
|
-
//
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
const chatId = (r.chatIdentifier || "").toLowerCase();
|
|
1571
|
-
return sender.includes(contactLower) || chatId.includes(contactLower);
|
|
1572
|
-
});
|
|
1572
|
+
// Sender or chat handle matches the contact (name resolved to its handles);
|
|
1573
|
+
// from-me rows match through the 1:1 chat's handle
|
|
1574
|
+
const filtered = results.filter(r => matcher.matches(r));
|
|
1573
1575
|
|
|
1574
1576
|
// Sort chronologically (oldest first for conversation view)
|
|
1575
1577
|
return filtered
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contact matching and peer naming for indexed Messages rows.
|
|
3
|
+
*
|
|
4
|
+
* Rows carry raw handles (`sender` is a phone/email or "Me"; `chatIdentifier`
|
|
5
|
+
* is the other party's handle in a 1:1 chat, or "chatNNN" for a group). Tool
|
|
6
|
+
* output shows contact names, so callers pass names back ("Dad"). These helpers
|
|
7
|
+
* map that name to handles and tell a reader who a from-me row was sent to.
|
|
8
|
+
*
|
|
9
|
+
* Lookups are injected so the logic is testable without the AddressBook.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const MIN_PHONE_DIGITS = 7;
|
|
13
|
+
|
|
14
|
+
function looksLikeEmail(value) {
|
|
15
|
+
return value.includes("@");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function looksLikePhone(value) {
|
|
19
|
+
return value.replace(/\D/g, "").length >= MIN_PHONE_DIGITS;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build a predicate that matches message rows to a caller-supplied contact.
|
|
24
|
+
*
|
|
25
|
+
* Keeps the old raw substring match on sender / chatIdentifier, and adds:
|
|
26
|
+
* - phone input matched on normalized digits (605-360-2675 == +16053602675)
|
|
27
|
+
* - email input matched exactly (case-insensitive)
|
|
28
|
+
* - a name matched exactly (full name, first name, or nickname) against
|
|
29
|
+
* Contacts, then every phone and email on those contacts. No fuzzy name
|
|
30
|
+
* matching: a short name must not pull in other people's threads.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} contact - Name, phone, or email
|
|
33
|
+
* @param {object} deps
|
|
34
|
+
* @param {(name: string) => object[]} deps.resolveExactName - contacts with that exact name
|
|
35
|
+
* @param {(phone: string) => string} deps.normalizePhone
|
|
36
|
+
* @returns {{ matches: (row: object) => boolean, isName: boolean, resolvedCount: number } | null}
|
|
37
|
+
*/
|
|
38
|
+
export function createMessageContactMatcher(contact, { resolveExactName, normalizePhone }) {
|
|
39
|
+
const raw = String(contact || "").trim().toLowerCase();
|
|
40
|
+
if (!raw) return null;
|
|
41
|
+
|
|
42
|
+
const phones = new Set();
|
|
43
|
+
const emails = new Set();
|
|
44
|
+
const isName = !looksLikeEmail(raw) && !looksLikePhone(raw);
|
|
45
|
+
let resolvedCount = 0;
|
|
46
|
+
|
|
47
|
+
if (looksLikeEmail(raw)) {
|
|
48
|
+
emails.add(raw);
|
|
49
|
+
} else if (looksLikePhone(raw)) {
|
|
50
|
+
const normalized = normalizePhone(raw);
|
|
51
|
+
if (normalized) phones.add(normalized);
|
|
52
|
+
} else {
|
|
53
|
+
const people = resolveExactName(raw) || [];
|
|
54
|
+
resolvedCount = people.length;
|
|
55
|
+
for (const person of people) {
|
|
56
|
+
for (const p of person.phones || []) {
|
|
57
|
+
const normalized = p.normalized || normalizePhone(p.phone || "");
|
|
58
|
+
if (normalized) phones.add(normalized);
|
|
59
|
+
}
|
|
60
|
+
for (const e of person.emails || []) {
|
|
61
|
+
if (e.email) emails.add(e.email.toLowerCase());
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const fieldMatches = (value) => {
|
|
67
|
+
const lower = String(value || "").toLowerCase();
|
|
68
|
+
if (!lower || lower === "me" || lower === "unknown") return false;
|
|
69
|
+
if (lower.includes(raw)) return true;
|
|
70
|
+
if (emails.has(lower)) return true;
|
|
71
|
+
if (phones.size > 0 && !looksLikeEmail(lower)) {
|
|
72
|
+
const normalized = normalizePhone(lower);
|
|
73
|
+
if (normalized && phones.has(normalized)) return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
matches: (row) => fieldMatches(row.sender) || fieldMatches(row.chatIdentifier),
|
|
80
|
+
isName,
|
|
81
|
+
resolvedCount
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Name the other side of a from-me row, so "Me" lines say who they went to.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} row - Indexed message row (chatIdentifier, chatName, isGroupChat)
|
|
89
|
+
* @param {object} deps
|
|
90
|
+
* @param {(phone: string) => object|null} deps.resolvePhone
|
|
91
|
+
* @param {(email: string) => object|null} deps.resolveEmail
|
|
92
|
+
* @param {(contact: object) => string} deps.formatContact
|
|
93
|
+
* @returns {string|null} e.g. "Dad (+16053602675)", "group chat \"Family\"", or null
|
|
94
|
+
*/
|
|
95
|
+
export function describeMessagePeer(row, { resolvePhone, resolveEmail, formatContact }) {
|
|
96
|
+
const chatIdentifier = row.chatIdentifier || "";
|
|
97
|
+
if (row.isGroupChat) {
|
|
98
|
+
if (row.chatName) return `group chat "${row.chatName}"`;
|
|
99
|
+
return chatIdentifier ? `group chat ${chatIdentifier}` : "group chat";
|
|
100
|
+
}
|
|
101
|
+
if (!chatIdentifier) return null;
|
|
102
|
+
const person = looksLikeEmail(chatIdentifier)
|
|
103
|
+
? resolveEmail(chatIdentifier)
|
|
104
|
+
: resolvePhone(chatIdentifier);
|
|
105
|
+
return person ? `${formatContact(person)} (${chatIdentifier})` : chatIdentifier;
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apple-tools-mcp",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.9",
|
|
4
4
|
"description": "MCP server for semantic search and write actions across Apple Mail, Messages, Calendar, and Contacts. Speaks stdio (local) or bearer-token-authenticated Streamable HTTP (LAN / Tailscale).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
package/search.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as chrono from "chrono-node";
|
|
2
2
|
import { safeOsascript } from "./lib/shell.js";
|
|
3
3
|
import { safeMatch, validateSearchQuery, toUnixMillis } from "./lib/validators.js";
|
|
4
|
-
import { embed, getOpenTable, getRecentEmails, getEmailsByDateRange, getRecentMessages, getConversation, getEventsOnDate, resolveEmail, resolvePhone, formatContact } from "./indexer.js";
|
|
4
|
+
import { embed, getOpenTable, getRecentEmails, getEmailsByDateRange, getRecentMessages, getConversation, getEventsOnDate, resolveEmail, resolvePhone, resolveByExactName, normalizePhone, formatContact } from "./indexer.js";
|
|
5
|
+
import { createMessageContactMatcher, describeMessagePeer } from "./lib/messageContact.js";
|
|
5
6
|
import { indexUnavailableMessage } from "./lib/indexGate.js";
|
|
6
7
|
|
|
7
8
|
// ============ CACHING ============
|
|
@@ -1024,12 +1025,8 @@ export async function searchMessages(query, options = {}) {
|
|
|
1024
1025
|
}
|
|
1025
1026
|
|
|
1026
1027
|
if (contact) {
|
|
1027
|
-
const
|
|
1028
|
-
results = results.filter(r =>
|
|
1029
|
-
const sender = (r.sender || "").toLowerCase();
|
|
1030
|
-
const chatId = (r.chatIdentifier || "").toLowerCase();
|
|
1031
|
-
return sender.includes(contactLower) || chatId.includes(contactLower);
|
|
1032
|
-
});
|
|
1028
|
+
const matcher = createMessageContactMatcher(contact, { resolveExactName: resolveByExactName, normalizePhone });
|
|
1029
|
+
if (matcher) results = results.filter(r => matcher.matches(r));
|
|
1033
1030
|
}
|
|
1034
1031
|
|
|
1035
1032
|
if (groupChatOnly) {
|
|
@@ -1057,7 +1054,11 @@ export async function searchMessages(query, options = {}) {
|
|
|
1057
1054
|
if (results.length === 0) {
|
|
1058
1055
|
let filterMsg = "";
|
|
1059
1056
|
if (daysBack > 0) filterMsg += ` in the last ${daysBack} days`;
|
|
1060
|
-
if (contact)
|
|
1057
|
+
if (contact) {
|
|
1058
|
+
filterMsg += ` with ${contact}`;
|
|
1059
|
+
const matcher = createMessageContactMatcher(contact, { resolveExactName: resolveByExactName, normalizePhone });
|
|
1060
|
+
if (matcher?.isName && matcher.resolvedCount === 0) filterMsg += ` (no contact named "${contact}")`;
|
|
1061
|
+
}
|
|
1061
1062
|
if (groupChatOnly) filterMsg += " in group chats";
|
|
1062
1063
|
if (groupChatName) filterMsg += ` in "${groupChatName}"`;
|
|
1063
1064
|
if (hasAttachment) filterMsg += " with attachments";
|
|
@@ -1082,6 +1083,7 @@ export async function searchMessages(query, options = {}) {
|
|
|
1082
1083
|
dateTimestamp: toUnixMillis(row.dateTimestamp) || null,
|
|
1083
1084
|
sender: sender,
|
|
1084
1085
|
senderContact: senderContact, // Resolved contact name (if found)
|
|
1086
|
+
to: sender === "Me" ? messagePeer(row) : null,
|
|
1085
1087
|
text: row.text || "",
|
|
1086
1088
|
chatName: row.chatName || "",
|
|
1087
1089
|
isGroupChat: row.isGroupChat || false,
|
|
@@ -1105,6 +1107,11 @@ export async function searchMessages(query, options = {}) {
|
|
|
1105
1107
|
}
|
|
1106
1108
|
}
|
|
1107
1109
|
|
|
1110
|
+
// Who a from-me message was sent to (contact name + handle, or the group chat)
|
|
1111
|
+
function messagePeer(row) {
|
|
1112
|
+
return describeMessagePeer(row, { resolvePhone, resolveEmail, formatContact });
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1108
1115
|
// Get recent messages without semantic search
|
|
1109
1116
|
export async function getRecentMessageResults(limit = 10, daysBack = 1) {
|
|
1110
1117
|
try {
|
|
@@ -1125,6 +1132,7 @@ export async function getRecentMessageResults(limit = 10, daysBack = 1) {
|
|
|
1125
1132
|
dateTimestamp: toUnixMillis(row.dateTimestamp) || null,
|
|
1126
1133
|
sender: sender,
|
|
1127
1134
|
senderContact: senderContact,
|
|
1135
|
+
to: sender === "Me" ? messagePeer(row) : null,
|
|
1128
1136
|
text: row.text || "",
|
|
1129
1137
|
isGroupChat: row.isGroupChat || false
|
|
1130
1138
|
};
|
|
@@ -1153,7 +1161,11 @@ export async function getConversationResults(contact, limit = 50) {
|
|
|
1153
1161
|
}));
|
|
1154
1162
|
|
|
1155
1163
|
if (formattedResults.length === 0) {
|
|
1156
|
-
|
|
1164
|
+
const matcher = createMessageContactMatcher(contact, { resolveExactName: resolveByExactName, normalizePhone });
|
|
1165
|
+
const hint = matcher?.isName && matcher.resolvedCount === 0
|
|
1166
|
+
? ` (no contact named "${contact}"; try a phone number or email)`
|
|
1167
|
+
: "";
|
|
1168
|
+
return { success: true, results: [], message: `No conversation found with ${contact}${hint}` };
|
|
1157
1169
|
}
|
|
1158
1170
|
|
|
1159
1171
|
return { success: true, results: formattedResults, contact };
|
|
@@ -1173,6 +1185,7 @@ export function formatMessageResults(searchResult) {
|
|
|
1173
1185
|
const senderDisplay = r.senderContact ? `${r.senderContact} (${r.sender})` : r.sender;
|
|
1174
1186
|
result += `\nDate: ${r.date}\nFrom: ${senderDisplay}`;
|
|
1175
1187
|
if (r.isGroupChat) result += " (Group)";
|
|
1188
|
+
if (r.to) result += `\nTo: ${r.to}`;
|
|
1176
1189
|
result += `\nMessage: ${r.text}`;
|
|
1177
1190
|
return result + "\n---";
|
|
1178
1191
|
}).join("\n");
|