apple-tools-mcp 3.0.9 → 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/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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "3.0.9",
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",