apple-tools-mcp 3.0.9 → 3.2.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
 
@@ -454,3 +455,23 @@ MIT License. See [LICENSE](LICENSE).
454
455
  - [Model Context Protocol SDK](https://github.com/modelcontextprotocol/sdk)
455
456
  - [LanceDB](https://lancedb.com/)
456
457
  - [Hugging Face Transformers.js](https://github.com/huggingface/transformers.js)
458
+
459
+ ### Privacy checks for contributors
460
+
461
+ Install Gitleaks, then enable the committed hook once in each clone:
462
+
463
+ ```sh
464
+ git config core.hooksPath .githooks
465
+ npm run scan:package
466
+ ```
467
+
468
+ The hook scans staged changes and fails closed if Gitleaks is unavailable. CI scans
469
+ all fetched Git history and the exact `npm pack --dry-run` file list. Release CI
470
+ repeats the package scan before npm Trusted Publishing. Credential rules are
471
+ extended with private home paths, relay email, private hostnames and phone shapes.
472
+ Use neutral example paths and fictional 555 numbers in fixtures. A narrow
473
+ exception covers the deliberately fake bearer token in authentication unit tests.
474
+
475
+ Host-specific settings remain in `~/.apple-tools-mcp/config.json`; upgrading the
476
+ global package preserves them. Permission examples derive from the current home
477
+ and runtime Node binary, so no personal paths belong in the package.
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
  {
@@ -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, {
@@ -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
+ }
@@ -23,7 +23,7 @@ function looksLikePhone(value) {
23
23
  * Build a predicate that matches message rows to a caller-supplied contact.
24
24
  *
25
25
  * Keeps the old raw substring match on sender / chatIdentifier, and adds:
26
- * - phone input matched on normalized digits (605-360-2675 == +16053602675)
26
+ * - phone input matched on normalized digits 202-555-0101 == +12025550101)
27
27
  * - email input matched exactly (case-insensitive)
28
28
  * - a name matched exactly (full name, first name, or nickname) against
29
29
  * Contacts, then every phone and email on those contacts. No fuzzy name
@@ -90,7 +90,7 @@ export function createMessageContactMatcher(contact, { resolveExactName, normali
90
90
  * @param {(phone: string) => object|null} deps.resolvePhone
91
91
  * @param {(email: string) => object|null} deps.resolveEmail
92
92
  * @param {(contact: object) => string} deps.formatContact
93
- * @returns {string|null} e.g. "Dad (+16053602675)", "group chat \"Family\"", or null
93
+ * @returns {string|null} e.g. "Dad (+12025550101)", "group chat \"Family\"", or null
94
94
  */
95
95
  export function describeMessagePeer(row, { resolvePhone, resolveEmail, formatContact }) {
96
96
  const chatIdentifier = row.chatIdentifier || "";
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import fs from "fs";
18
+ import os from "os";
18
19
  import path from "path";
19
20
  import { classifyAppleScriptError, SYSTEM_EVENTS_STICKY_DENY_GUIDANCE } from "./appleScript.js";
20
21
  import { probeMailAutomation, probeSystemEventsAutomation } from "./mailWrite.js";
@@ -26,10 +27,10 @@ import { detectLaunchAgentContext, hostAutomationAdvice } from "./writeRouting.j
26
27
  export const REQUIRED_SURFACES = ["Contacts", "Calendar", "Mail", "Messages", "System Events"];
27
28
 
28
29
  /** Mini ship-gate host example — not a universal path. */
29
- export const EXAMPLE_MINI_NODE = "/Users/petercoates/.local/node/bin/node";
30
+ export const EXAMPLE_MINI_NODE = path.join(os.homedir(), ".local", "node", "bin", "node");
30
31
 
31
- /** MacBook Claude nvm example — not Homebrew. */
32
- export const EXAMPLE_MACBOOK_NVM_NODE = "/Users/petercoates/.nvm/versions/node/v22.21.1/bin/node";
32
+ /** Current runtime binary; follows the selected Node installation on any host. */
33
+ export const EXAMPLE_MACBOOK_NVM_NODE = process.execPath;
33
34
 
34
35
  export const GRANT_GRANTED = "granted";
35
36
  export const GRANT_MISSING = "missing";
@@ -264,7 +265,7 @@ function bannerLines(binary, version) {
264
265
  `Probing node binary: ${binary.execPath}`,
265
266
  "This is process.execPath — the node that is running this command.",
266
267
  `Mini example: ${binary.miniExample}`,
267
- `MacBook example: ${binary.macbookExample} (Claude nvm; not Homebrew)`,
268
+ `Runtime example: ${binary.macbookExample} (current Node installation)`,
268
269
  "Invoke this command with the product node so Allow dialogs attach to it.",
269
270
  "",
270
271
  "Open System Settings → Privacy & Security → Automation on this Mac.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "3.0.9",
3
+ "version": "3.2.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",
@@ -87,7 +87,8 @@
87
87
  "perf:regression": "vitest run tests/perf/regression.perf.test.js",
88
88
  "perf:lancedb": "vitest run tests/perf/lancedb.perf.test.js",
89
89
  "perf:background": "vitest run tests/perf/background-indexing.perf.test.js",
90
- "perf:dates": "vitest run tests/perf/date-parsing.perf.test.js"
90
+ "perf:dates": "vitest run tests/perf/date-parsing.perf.test.js",
91
+ "scan:package": "node scripts/scan-package.js"
91
92
  },
92
93
  "dependencies": {
93
94
  "@huggingface/transformers": "^4.3.0",