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 +21 -0
- package/index.js +16 -0
- package/lib/mailLinks.js +294 -0
- package/lib/messageContact.js +2 -2
- package/lib/permissions.js +5 -4
- package/package.json +3 -2
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, {
|
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/lib/messageContact.js
CHANGED
|
@@ -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
|
|
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 (+
|
|
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 || "";
|
package/lib/permissions.js
CHANGED
|
@@ -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 = "
|
|
30
|
+
export const EXAMPLE_MINI_NODE = path.join(os.homedir(), ".local", "node", "bin", "node");
|
|
30
31
|
|
|
31
|
-
/**
|
|
32
|
-
export const EXAMPLE_MACBOOK_NVM_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
|
-
`
|
|
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
|
|
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",
|