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 +1 -0
- package/index.js +16 -0
- package/lib/mailLinks.js +294 -0
- package/package.json +1 -1
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, {
|
package/lib/mailLinks.js
ADDED
|
@@ -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(/"/gi, '"')
|
|
121
|
+
.replace(/'|'/gi, "'")
|
|
122
|
+
.replace(/</gi, "<")
|
|
123
|
+
.replace(/>/gi, ">")
|
|
124
|
+
.replace(/ /gi, " ")
|
|
125
|
+
.replace(/&/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
|
|
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",
|