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 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: "Contact name or phone number" },
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 contactLower = contact.toLowerCase();
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
- // Find messages where sender or chatIdentifier contains the contact
1568
- const filtered = results.filter(r => {
1569
- const sender = (r.sender || "").toLowerCase();
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.8",
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 contactLower = contact.toLowerCase();
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) filterMsg += ` with ${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
- return { success: true, results: [], message: `No conversation found with ${contact}` };
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");