apple-tools-mcp 3.0.8 → 3.1.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/README.md CHANGED
@@ -259,6 +259,7 @@ Your client can also call `audit_index`.
259
259
  | `mail_senders` | Most frequent senders. |
260
260
  | `mail_thread` | Every email in a conversation. |
261
261
  | `mail_find` | Exact lookup in Mail's own database by subject prefix or text, sender, recipient, mailbox, and date. Returns every copy with its Message-ID. Does not wait on the index. |
262
+ | `mail_links` | The links in one email (by its `mail_find` id), decoded, with each link's visible text. Use it for the URL behind a button, such as a sign-in link; `mail_read` drops link targets. Does not wait on the index. |
262
263
 
263
264
  ### Messages
264
265
 
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
@@ -41,6 +41,7 @@ import { startWriteBridgeServer, defaultSocketPath } from "./lib/writeBridge.js"
41
41
  import { closeEventKitSession, ensureEventKitSession } from "./lib/eventKitSession.js";
42
42
  import { EVENTKIT_JXA_HELPERS } from "./lib/calendarWrite.js";
43
43
  import { mailFind, MAILBOX_KINDS, MAIL_FIND_MAX_LIMIT } from "./lib/mailFind.js";
44
+ import { mailLinks } from "./lib/mailLinks.js";
44
45
 
45
46
  const PACKAGE_VERSION = JSON.parse(
46
47
  fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")
@@ -1174,6 +1175,17 @@ function createServer() {
1174
1175
  },
1175
1176
  },
1176
1177
  },
1178
+ {
1179
+ name: "mail_links",
1180
+ description: "Links in one email, decoded: each http(s) link with its visible text, from the HTML anchors (quoted-printable and base64 decoded, & unescaped) and bare URLs in the plain-text part. Use the id from mail_find. mail_read strips HTML, so it loses link targets; use this when you need the URL behind a button such as a sign-in or confirm link. Reads Mail's message file directly and never waits on the index. downloaded:false means Mail has not saved the file yet.",
1181
+ inputSchema: {
1182
+ type: "object",
1183
+ properties: {
1184
+ id: { type: "number", description: "Message id from mail_find (Mail's Envelope Index id)" }
1185
+ },
1186
+ required: ["id"],
1187
+ },
1188
+ },
1177
1189
 
1178
1190
  // ============ MESSAGES TOOLS ============
1179
1191
  {
@@ -1185,7 +1197,7 @@ function createServer() {
1185
1197
  query: { type: "string", description: "Natural language search (e.g., 'dinner plans', 'about the trip', 'address')" },
1186
1198
  limit: { type: "number", description: "Maximum results (default 30)" },
1187
1199
  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" },
1200
+ 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
1201
  group_chat_only: { type: "boolean", description: "Only show messages from group chats" },
1190
1202
  group_chat_name: { type: "string", description: "Filter by specific group chat name" },
1191
1203
  has_attachment: { type: "boolean", description: "Filter to messages with attachments (photos, files)" },
@@ -1196,7 +1208,7 @@ function createServer() {
1196
1208
  },
1197
1209
  {
1198
1210
  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'.",
1211
+ 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
1212
  inputSchema: {
1201
1213
  type: "object",
1202
1214
  properties: {
@@ -1211,7 +1223,7 @@ function createServer() {
1211
1223
  inputSchema: {
1212
1224
  type: "object",
1213
1225
  properties: {
1214
- contact: { type: "string", description: "Contact name or phone number" },
1226
+ 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
1227
  limit: { type: "number", description: "Maximum messages to return (default 50)" }
1216
1228
  },
1217
1229
  required: ["contact"],
@@ -1480,6 +1492,10 @@ function createServer() {
1480
1492
  result = mailFind(args || {});
1481
1493
  break;
1482
1494
 
1495
+ case "mail_links":
1496
+ result = mailLinks(args || {});
1497
+ break;
1498
+
1483
1499
  // Messages tools
1484
1500
  case "messages_search":
1485
1501
  result = await messagesSearch(args.query, {
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,294 @@
1
+ /**
2
+ * mail_links: the links in one message, decoded, by its Envelope Index id.
3
+ *
4
+ * mail_read strips HTML, which drops every link target, and it reads the
5
+ * .emlx without MIME decoding, so a quoted-printable URL comes back broken
6
+ * across soft line breaks. Sign-in emails (magic links, confirm links) put
7
+ * the one thing a caller needs behind an <a href>. This reads the message
8
+ * file Mail keeps for the id mail_find returns, decodes its MIME parts
9
+ * (multipart, quoted-printable, base64, charset), and returns each http(s)
10
+ * link with its visible text.
11
+ *
12
+ * Read-only. Needs the same Full Disk Access as mail_find and the indexer.
13
+ */
14
+
15
+ import fs from "fs";
16
+ import path from "path";
17
+ import { envelopeIndexPath, parseMailboxUrl, ENVELOPE_INDEX_ACCESS_GUIDANCE } from "./mailFind.js";
18
+ import { safeSqlite3Json } from "./shell.js";
19
+ import { stripHtmlTags } from "./validators.js";
20
+
21
+ export const MAIL_LINKS_MAX = 200;
22
+ const MAX_MESSAGE_BYTES = 20 * 1024 * 1024;
23
+ const MAX_HTML_CHARS = 2_000_000;
24
+ const MAX_MIME_DEPTH = 8;
25
+
26
+ /** The RFC822 bytes inside an .emlx (first line is the byte count; a plist trails the message). */
27
+ export function emlxMessageBytes(buf) {
28
+ const nl = buf.indexOf(0x0a);
29
+ const count = nl > 0 ? Number.parseInt(buf.subarray(0, nl).toString("ascii").trim(), 10) : NaN;
30
+ if (!Number.isFinite(count) || count <= 0) return buf;
31
+ return buf.subarray(nl + 1, Math.min(buf.length, nl + 1 + count));
32
+ }
33
+
34
+ function splitHeaderBody(buf) {
35
+ const crlf = buf.indexOf("\r\n\r\n");
36
+ const lf = buf.indexOf("\n\n");
37
+ if (crlf >= 0 && (lf < 0 || crlf < lf)) return [buf.subarray(0, crlf).toString("latin1"), buf.subarray(crlf + 4)];
38
+ if (lf >= 0) return [buf.subarray(0, lf).toString("latin1"), buf.subarray(lf + 2)];
39
+ return [buf.toString("latin1"), Buffer.alloc(0)];
40
+ }
41
+
42
+ function parseHeaders(text) {
43
+ const headers = new Map();
44
+ for (const line of text.replace(/\r?\n[ \t]+/g, " ").split(/\r?\n/)) {
45
+ const i = line.indexOf(":");
46
+ if (i > 0) headers.set(line.slice(0, i).trim().toLowerCase(), line.slice(i + 1).trim());
47
+ }
48
+ return headers;
49
+ }
50
+
51
+ /** `text/html; charset="utf-8"` → { type: "text/html", params: { charset: "utf-8" } } */
52
+ export function parseContentType(value) {
53
+ const [type, ...rest] = String(value || "text/plain").split(";");
54
+ const params = {};
55
+ for (const p of rest) {
56
+ const i = p.indexOf("=");
57
+ if (i > 0) params[p.slice(0, i).trim().toLowerCase()] = p.slice(i + 1).trim().replace(/^"(.*)"$/, "$1");
58
+ }
59
+ return { type: type.trim().toLowerCase(), params };
60
+ }
61
+
62
+ export function decodeQuotedPrintable(buf) {
63
+ const s = buf.toString("latin1").replace(/=\r?\n/g, "");
64
+ const out = Buffer.alloc(s.length);
65
+ let n = 0;
66
+ for (let i = 0; i < s.length; i++) {
67
+ if (s[i] === "=" && /^[0-9A-Fa-f]{2}$/.test(s.slice(i + 1, i + 3))) {
68
+ out[n++] = Number.parseInt(s.slice(i + 1, i + 3), 16);
69
+ i += 2;
70
+ } else {
71
+ out[n++] = s.charCodeAt(i) & 0xff;
72
+ }
73
+ }
74
+ return out.subarray(0, n);
75
+ }
76
+
77
+ function decodeText(buf, encoding, charset) {
78
+ const enc = String(encoding || "").toLowerCase();
79
+ const bytes =
80
+ enc === "quoted-printable"
81
+ ? decodeQuotedPrintable(buf)
82
+ : enc === "base64"
83
+ ? Buffer.from(buf.toString("latin1").replace(/[^A-Za-z0-9+/=]/g, ""), "base64")
84
+ : buf;
85
+ try {
86
+ return new TextDecoder(charset || "utf-8").decode(bytes);
87
+ } catch {
88
+ return new TextDecoder("utf-8").decode(bytes);
89
+ }
90
+ }
91
+
92
+ /** Text leaves of a MIME message: [{ type, text }] for text/html and text/plain parts. */
93
+ export function mimeTextParts(buf, depth = 0) {
94
+ const [headerText, body] = splitHeaderBody(buf);
95
+ const headers = parseHeaders(headerText);
96
+ const { type, params } = parseContentType(headers.get("content-type"));
97
+ if (type.startsWith("multipart/") && params.boundary && depth < MAX_MIME_DEPTH) {
98
+ const marker = Buffer.from(`--${params.boundary}`, "latin1");
99
+ const parts = [];
100
+ let start = body.indexOf(marker);
101
+ while (start >= 0) {
102
+ const afterMarker = start + marker.length;
103
+ if (body.subarray(afterMarker, afterMarker + 2).toString("latin1") === "--") break;
104
+ const next = body.indexOf(marker, afterMarker);
105
+ let chunk = body.subarray(afterMarker, next >= 0 ? next : body.length);
106
+ chunk = chunk.subarray(chunk[0] === 0x0d && chunk[1] === 0x0a ? 2 : chunk[0] === 0x0a ? 1 : 0);
107
+ parts.push(...mimeTextParts(chunk, depth + 1));
108
+ start = next;
109
+ }
110
+ return parts;
111
+ }
112
+ if (type !== "text/html" && type !== "text/plain") return [];
113
+ return [{ type, text: decodeText(body, headers.get("content-transfer-encoding"), params.charset) }];
114
+ }
115
+
116
+ export function decodeHtmlEntities(s) {
117
+ return String(s)
118
+ .replace(/&#x([0-9a-f]{1,6});/gi, (_, h) => safeCodePoint(Number.parseInt(h, 16)))
119
+ .replace(/&#([0-9]{1,7});/g, (_, d) => safeCodePoint(Number.parseInt(d, 10)))
120
+ .replace(/&quot;/gi, '"')
121
+ .replace(/&#39;|&apos;/gi, "'")
122
+ .replace(/&lt;/gi, "<")
123
+ .replace(/&gt;/gi, ">")
124
+ .replace(/&nbsp;/gi, " ")
125
+ .replace(/&amp;/gi, "&");
126
+ }
127
+
128
+ function safeCodePoint(n) {
129
+ return Number.isInteger(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : "";
130
+ }
131
+
132
+ function isHttpUrl(url) {
133
+ return /^https?:\/\/[^\s]+$/i.test(url);
134
+ }
135
+
136
+ function attrValue(tag, name) {
137
+ const lower = tag.toLowerCase();
138
+ let from = 0;
139
+ for (;;) {
140
+ const i = lower.indexOf(name, from);
141
+ if (i < 0) return null;
142
+ from = i + name.length;
143
+ if (i > 0 && !/\s/.test(tag[i - 1])) continue;
144
+ let j = from;
145
+ while (j < tag.length && /\s/.test(tag[j])) j++;
146
+ if (tag[j] !== "=") continue;
147
+ j++;
148
+ while (j < tag.length && /\s/.test(tag[j])) j++;
149
+ const q = tag[j];
150
+ if (q === '"' || q === "'") {
151
+ const end = tag.indexOf(q, j + 1);
152
+ return end < 0 ? null : tag.slice(j + 1, end);
153
+ }
154
+ let end = j;
155
+ while (end < tag.length && !/[\s>]/.test(tag[end])) end++;
156
+ return tag.slice(j, end);
157
+ }
158
+ }
159
+
160
+ /** <a href> links in HTML as { text, url }, scanned without regex backtracking. */
161
+ export function htmlLinks(html) {
162
+ const s = String(html || "").slice(0, MAX_HTML_CHARS);
163
+ const lower = s.toLowerCase();
164
+ const links = [];
165
+ let pos = 0;
166
+ while (links.length < MAIL_LINKS_MAX) {
167
+ const open = lower.indexOf("<a", pos);
168
+ if (open < 0) break;
169
+ pos = open + 2;
170
+ if (!/[\s>]/.test(s[open + 2] || "")) continue;
171
+ let tagEnd = open + 2;
172
+ let quote = null;
173
+ for (; tagEnd < s.length; tagEnd++) {
174
+ const c = s[tagEnd];
175
+ if (quote) {
176
+ if (c === quote) quote = null;
177
+ } else if (c === '"' || c === "'") {
178
+ quote = c;
179
+ } else if (c === ">") {
180
+ break;
181
+ }
182
+ }
183
+ if (tagEnd >= s.length) break;
184
+ const close = lower.indexOf("</a", tagEnd);
185
+ const nextOpen = lower.indexOf("<a", tagEnd);
186
+ // An unclosed anchor's text runs to the next anchor (or the end).
187
+ const textEnd = close >= 0 && (nextOpen < 0 || close < nextOpen) ? close : nextOpen >= 0 ? nextOpen : s.length;
188
+ const inner = s.slice(tagEnd + 1, textEnd);
189
+ pos = textEnd === close ? close + 3 : textEnd;
190
+ const href = attrValue(s.slice(open, tagEnd), "href");
191
+ const url = href == null ? "" : decodeHtmlEntities(href.trim());
192
+ if (!isHttpUrl(url)) continue;
193
+ links.push({ text: decodeHtmlEntities(stripHtmlTags(inner)).replace(/\s+/g, " ").trim(), url });
194
+ }
195
+ return links;
196
+ }
197
+
198
+ /** Bare http(s) URLs in plain text. */
199
+ export function textLinks(text) {
200
+ const found = String(text || "").slice(0, MAX_HTML_CHARS).match(/https?:\/\/[^\s<>"'`)\]]+/g) || [];
201
+ return found.slice(0, MAIL_LINKS_MAX).map((url) => ({ text: "", url: url.replace(/[.,;:!?]+$/, "") }));
202
+ }
203
+
204
+ /** Links from a decoded message: HTML anchors first, then plain-text URLs not already listed. */
205
+ export function messageLinks(rfc822) {
206
+ const parts = mimeTextParts(rfc822);
207
+ const links = [];
208
+ const seen = new Set();
209
+ const add = (l) => {
210
+ if (links.length >= MAIL_LINKS_MAX || seen.has(`${l.text}\n${l.url}`)) return;
211
+ seen.add(`${l.text}\n${l.url}`);
212
+ links.push(l);
213
+ };
214
+ for (const p of parts) if (p.type === "text/html") htmlLinks(p.text).forEach(add);
215
+ const urls = new Set(links.map((l) => l.url));
216
+ for (const p of parts) if (p.type === "text/plain") textLinks(p.text).filter((l) => !urls.has(l.url)).forEach(add);
217
+ return links;
218
+ }
219
+
220
+ /** Mail's sharded folder for a message id: 73690 → Data/3/7/Messages (digits of id/1000, reversed). */
221
+ export function messageDataDir(id) {
222
+ const thousands = Math.floor(id / 1000);
223
+ const shards = thousands > 0 ? String(thousands).split("").reverse() : [];
224
+ return path.join("Data", ...shards, "Messages");
225
+ }
226
+
227
+ /**
228
+ * Where Mail keeps `id`'s message file for mailbox `mailboxUrl`.
229
+ * `file` is the .emlx (or .partial.emlx) path, or null. `mailboxFound` says
230
+ * whether the mailbox folder itself was found, so a caller can tell "not
231
+ * saved yet" (worth retrying) from "cannot locate this mailbox" (not).
232
+ * Account mailboxes live under V<n>/<account id>/; On My Mac mailboxes
233
+ * without an account id live under V<n>/Mailboxes/.
234
+ */
235
+ export function locateEmlx(id, mailboxUrl, { indexPath } = {}) {
236
+ const box = parseMailboxUrl(mailboxUrl);
237
+ const db = indexPath || envelopeIndexPath();
238
+ if (!box || !db) return { file: null, mailboxFound: false };
239
+ const versionDir = path.dirname(path.dirname(db));
240
+ const base = box.accountId ? path.join(versionDir, box.accountId) : path.join(versionDir, "Mailboxes");
241
+ const segments = box.mailboxPath.split("/").filter(Boolean);
242
+ if (!segments.length || segments.some((p) => p === "." || p === "..")) return { file: null, mailboxFound: false };
243
+ const mboxDir = path.join(base, ...segments.map((p) => `${p}.mbox`));
244
+ let stores;
245
+ try {
246
+ stores = fs.readdirSync(mboxDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.endsWith(".mbox"));
247
+ } catch {
248
+ return { file: null, mailboxFound: false };
249
+ }
250
+ const rel = messageDataDir(id);
251
+ for (const store of stores) {
252
+ for (const name of [`${id}.emlx`, `${id}.partial.emlx`]) {
253
+ const file = path.join(mboxDir, store.name, rel, name);
254
+ if (fs.existsSync(file)) return { file, mailboxFound: true };
255
+ }
256
+ }
257
+ return { file: null, mailboxFound: true };
258
+ }
259
+
260
+ export function validateLinksArgs(args = {}) {
261
+ const id = Number(args.id);
262
+ if (!Number.isInteger(id) || id <= 0 || id > 1e12) return { error: "id must be a positive integer (the id from mail_find)" };
263
+ return { id };
264
+ }
265
+
266
+ /** mail_links tool entry point. Returns JSON text; throws on invalid args or an unreadable index. */
267
+ export function mailLinks(args = {}, { query = safeSqlite3Json, indexPath, readFile = fs.readFileSync, locate = locateEmlx } = {}) {
268
+ const { id, error } = validateLinksArgs(args);
269
+ if (error) throw new Error(`mail_links: ${error}`);
270
+ const db = indexPath || envelopeIndexPath();
271
+ if (!db) throw new Error(ENVELOPE_INDEX_ACCESS_GUIDANCE);
272
+ let rows;
273
+ try {
274
+ rows = query(db, `SELECT m.ROWID AS id, mb.url AS mailbox_url FROM messages m JOIN mailboxes mb ON mb.ROWID = m.mailbox WHERE m.ROWID = ${id}`, {
275
+ readonly: true,
276
+ timeout: 20000
277
+ });
278
+ } catch (e) {
279
+ if (/unable to open|authoriz|not permitted|denied/i.test(String((e && e.message) || e))) throw new Error(ENVELOPE_INDEX_ACCESS_GUIDANCE);
280
+ throw e;
281
+ }
282
+ if (!rows.length) return JSON.stringify({ id, found: false, note: "No message with that id in Mail's database." }, null, 2);
283
+ const { file, mailboxFound } = locate(id, rows[0].mailbox_url, { indexPath: db });
284
+ if (!file && !mailboxFound) {
285
+ return JSON.stringify({ id, found: true, located: false, note: "Could not find this message's mailbox folder in Mail's data; retrying will not help." }, null, 2);
286
+ }
287
+ if (!file) {
288
+ return JSON.stringify({ id, found: true, downloaded: false, note: "Mail has not saved this message's file yet; try again shortly." }, null, 2);
289
+ }
290
+ const buf = readFile(file);
291
+ if (buf.length > MAX_MESSAGE_BYTES) throw new Error("mail_links: message file is too large to read");
292
+ const links = messageLinks(emlxMessageBytes(buf));
293
+ return JSON.stringify({ id, found: true, downloaded: true, count: links.length, links }, null, 2);
294
+ }
@@ -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.1.0",
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");