apple-tools-mcp 2.0.1 → 2.0.3
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 +54 -13
- package/bin/apple-tools-indexer.js +15 -0
- package/bin/apple-tools-mcp.js +7 -0
- package/index.js +26 -7
- package/indexer.js +4 -1
- package/lib/appleScript.js +90 -26
- package/lib/calendarWrite.js +39 -0
- package/lib/contactsWrite.js +191 -4
- package/lib/mailWrite.js +296 -26
- package/lib/messagesWrite.js +6 -2
- package/lib/permissions.js +360 -0
- package/lib/processMode.js +50 -2
- package/lib/shell.js +27 -0
- package/lib/writeRouting.js +76 -6
- package/lib/writeTools.js +9 -6
- package/package.json +7 -3
- package/scripts/postinstall.js +32 -0
- package/scripts/smoke-writes.js +4 -1
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-run / upgrade permissions command.
|
|
3
|
+
*
|
|
4
|
+
* Probes Contacts, Calendar, Mail, and Messages under process.execPath so
|
|
5
|
+
* macOS can pop Allow dialogs for that node binary. The user clicks Allow;
|
|
6
|
+
* this command cannot grant silently.
|
|
7
|
+
*
|
|
8
|
+
* Always runs in this process (never via the write bridge): the point is to
|
|
9
|
+
* attach the dialogs to process.execPath. If the invoked CLI / shebang
|
|
10
|
+
* points at a different node, print a WARN — Allows attach to execPath.
|
|
11
|
+
* Mail and Messages use the existing live Apple Events helpers — dry_run of
|
|
12
|
+
* mail_send / messages_send never talks to those apps and does not count.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from "fs";
|
|
16
|
+
import path from "path";
|
|
17
|
+
import { classifyAppleScriptError } from "./appleScript.js";
|
|
18
|
+
import { probeMailAutomation } from "./mailWrite.js";
|
|
19
|
+
import { probeMessagesAutomation } from "./messagesWrite.js";
|
|
20
|
+
import { probeContactsAutomation } from "./contactsWrite.js";
|
|
21
|
+
import { probeCalendarAutomation } from "./calendarWrite.js";
|
|
22
|
+
import { detectLaunchAgentContext, hostAutomationAdvice } from "./writeRouting.js";
|
|
23
|
+
|
|
24
|
+
export const REQUIRED_SURFACES = ["Contacts", "Calendar", "Mail", "Messages"];
|
|
25
|
+
|
|
26
|
+
/** Mini ship-gate host example — not a universal path. */
|
|
27
|
+
export const EXAMPLE_MINI_NODE = "/Users/petercoates/.local/node/bin/node";
|
|
28
|
+
|
|
29
|
+
/** MacBook Claude nvm example — not Homebrew. */
|
|
30
|
+
export const EXAMPLE_MACBOOK_NVM_NODE = "/Users/petercoates/.nvm/versions/node/v22.21.1/bin/node";
|
|
31
|
+
|
|
32
|
+
export const GRANT_GRANTED = "granted";
|
|
33
|
+
export const GRANT_MISSING = "missing";
|
|
34
|
+
export const GRANT_ERROR = "error";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} [execPath=process.execPath]
|
|
38
|
+
* @returns {{ execPath: string, miniExample: string, macbookExample: string }}
|
|
39
|
+
*/
|
|
40
|
+
export function describeProbeBinary(execPath = process.execPath) {
|
|
41
|
+
return {
|
|
42
|
+
execPath: String(execPath || ""),
|
|
43
|
+
miniExample: EXAMPLE_MINI_NODE,
|
|
44
|
+
macbookExample: EXAMPLE_MACBOOK_NVM_NODE
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve a path for comparison. Symlinks collapse to the real file when
|
|
50
|
+
* possible so `.../bin/node` and its target are treated as the same binary.
|
|
51
|
+
*/
|
|
52
|
+
export function normalizeNodePath(filePath, { realpathSync = fs.realpathSync } = {}) {
|
|
53
|
+
if (!filePath) return "";
|
|
54
|
+
const resolved = path.resolve(String(filePath));
|
|
55
|
+
try {
|
|
56
|
+
return realpathSync(resolved);
|
|
57
|
+
} catch {
|
|
58
|
+
return resolved;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* First-line `#!` interpreter of an invoked CLI script, if any.
|
|
64
|
+
* @returns {{ kind: "env"|"absolute", target: string, raw: string }|null}
|
|
65
|
+
*/
|
|
66
|
+
export function readShebangTarget(scriptPath, { readFileSync = fs.readFileSync } = {}) {
|
|
67
|
+
if (!scriptPath) return null;
|
|
68
|
+
try {
|
|
69
|
+
const line = String(readFileSync(scriptPath, "utf8")).split(/\r?\n/, 1)[0] || "";
|
|
70
|
+
if (!line.startsWith("#!")) return null;
|
|
71
|
+
const raw = line.slice(2).trim();
|
|
72
|
+
const parts = raw.split(/\s+/).filter(Boolean);
|
|
73
|
+
if (parts.length === 0) return null;
|
|
74
|
+
if (parts[0] === "/usr/bin/env" || parts[0].endsWith("/env")) {
|
|
75
|
+
return { kind: "env", target: parts[1] || "node", raw };
|
|
76
|
+
}
|
|
77
|
+
return { kind: "absolute", target: parts[0], raw };
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Detect when the invoked `apple-tools-mcp` path / shebang / argv[0] is a
|
|
85
|
+
* different node than process.execPath. Allows attach to execPath.
|
|
86
|
+
*/
|
|
87
|
+
export function detectExecPathMismatch({
|
|
88
|
+
execPath = process.execPath,
|
|
89
|
+
argv = process.argv,
|
|
90
|
+
realpathSync = fs.realpathSync,
|
|
91
|
+
existsSync = fs.existsSync,
|
|
92
|
+
readFileSync = fs.readFileSync
|
|
93
|
+
} = {}) {
|
|
94
|
+
const deps = { realpathSync, existsSync, readFileSync };
|
|
95
|
+
const execNorm = normalizeNodePath(execPath, deps);
|
|
96
|
+
const argv0 = argv && argv[0] ? String(argv[0]) : "";
|
|
97
|
+
const invokedCli = argv && argv[1] ? String(argv[1]) : "";
|
|
98
|
+
const argv0Norm = argv0 ? normalizeNodePath(argv0, deps) : "";
|
|
99
|
+
|
|
100
|
+
const reasons = [];
|
|
101
|
+
let siblingNode = "";
|
|
102
|
+
let shebangTarget = "";
|
|
103
|
+
|
|
104
|
+
if (argv0Norm && execNorm && argv0Norm !== execNorm) {
|
|
105
|
+
reasons.push("argv0");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (invokedCli) {
|
|
109
|
+
const sibling = path.join(path.dirname(path.resolve(invokedCli)), "node");
|
|
110
|
+
if (existsSync(sibling)) {
|
|
111
|
+
siblingNode = normalizeNodePath(sibling, deps);
|
|
112
|
+
if (siblingNode && execNorm && siblingNode !== execNorm) {
|
|
113
|
+
reasons.push("sibling");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const shebang = readShebangTarget(invokedCli, deps);
|
|
117
|
+
if (shebang && shebang.kind === "absolute" && shebang.target) {
|
|
118
|
+
shebangTarget = normalizeNodePath(shebang.target, deps);
|
|
119
|
+
if (shebangTarget && execNorm && shebangTarget !== execNorm) {
|
|
120
|
+
reasons.push("shebang");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
mismatch: reasons.length > 0,
|
|
127
|
+
reasons,
|
|
128
|
+
execPath: execNorm || String(execPath || ""),
|
|
129
|
+
argv0: argv0Norm || argv0,
|
|
130
|
+
invokedCli,
|
|
131
|
+
siblingNode,
|
|
132
|
+
shebangTarget
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Loud WARN: Allows attach to execPath, not the CLI path the user typed.
|
|
138
|
+
*/
|
|
139
|
+
export function formatExecPathMismatchWarn(info) {
|
|
140
|
+
if (!info || !info.mismatch) return [];
|
|
141
|
+
const cli = info.invokedCli || "apple-tools-mcp";
|
|
142
|
+
return [
|
|
143
|
+
"",
|
|
144
|
+
"WARN: Allow dialogs attach to process.execPath, not the apple-tools-mcp path you typed.",
|
|
145
|
+
` Invoked CLI: ${cli}`,
|
|
146
|
+
` process.argv[0]: ${info.argv0 || "(unknown)"}`,
|
|
147
|
+
info.siblingNode ? ` Node next to CLI: ${info.siblingNode}` : null,
|
|
148
|
+
info.shebangTarget ? ` CLI shebang target: ${info.shebangTarget}` : null,
|
|
149
|
+
` process.execPath: ${info.execPath} ← Allows attach HERE`,
|
|
150
|
+
"Re-run with that exact node so execPath matches the binary you intend:",
|
|
151
|
+
` ${info.execPath} ${cli} permissions`,
|
|
152
|
+
" or: $(which node) $(which apple-tools-mcp) permissions",
|
|
153
|
+
' or: node "$(dirname "$(which node)")/../lib/node_modules/apple-tools-mcp/index.js" permissions',
|
|
154
|
+
""
|
|
155
|
+
].filter((line) => line !== null);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Map a probe result to the grant report vocabulary.
|
|
160
|
+
* TCC / timeout / attribution on a live Apple Event is a missing Allow.
|
|
161
|
+
*
|
|
162
|
+
* @param {{ ok?: boolean, kind?: string|null, message?: string, error?: string }} result
|
|
163
|
+
* @returns {"granted"|"missing"|"error"}
|
|
164
|
+
*/
|
|
165
|
+
export function classifyGrantStatus(result) {
|
|
166
|
+
if (result && result.ok === true) return GRANT_GRANTED;
|
|
167
|
+
const text = String((result && (result.message || result.error)) || "");
|
|
168
|
+
const kind = (result && result.kind) || classifyAppleScriptError(text);
|
|
169
|
+
if (kind === "tcc" || kind === "timeout" || kind === "attribution") {
|
|
170
|
+
return GRANT_MISSING;
|
|
171
|
+
}
|
|
172
|
+
if (kind === "app_not_running") {
|
|
173
|
+
return GRANT_ERROR;
|
|
174
|
+
}
|
|
175
|
+
if (/tcc|automation deny|not authorized|not permitted|not allowed|timed out|etimedout|responsible-process|attribution/i.test(text)) {
|
|
176
|
+
return GRANT_MISSING;
|
|
177
|
+
}
|
|
178
|
+
return GRANT_ERROR;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @param {Record<string, string>} grants
|
|
183
|
+
* @returns {string[]}
|
|
184
|
+
*/
|
|
185
|
+
export function formatGrantReport(grants) {
|
|
186
|
+
return REQUIRED_SURFACES.map((name) => `${name} = ${grants[name] || GRANT_ERROR}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Fail closed: any required surface that is not granted is a non-zero exit.
|
|
191
|
+
*
|
|
192
|
+
* @param {Record<string, string>} grants
|
|
193
|
+
* @returns {number}
|
|
194
|
+
*/
|
|
195
|
+
export function exitCodeForGrants(grants) {
|
|
196
|
+
return REQUIRED_SURFACES.every((name) => grants[name] === GRANT_GRANTED) ? 0 : 1;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Advisory Full Disk Access touch. Read tools need FDA on this node; it is
|
|
201
|
+
* not one of the four required Automation grants and never fails the command.
|
|
202
|
+
*
|
|
203
|
+
* @returns {{ status: "readable"|"missing"|"skipped", message: string }}
|
|
204
|
+
*/
|
|
205
|
+
export function probeFullDiskAccess({
|
|
206
|
+
home = process.env.HOME,
|
|
207
|
+
accessFn = fs.accessSync
|
|
208
|
+
} = {}) {
|
|
209
|
+
if (!home) {
|
|
210
|
+
return { status: "skipped", message: "HOME is unset; skipped Full Disk Access probe" };
|
|
211
|
+
}
|
|
212
|
+
const targets = [
|
|
213
|
+
path.join(home, "Library", "Mail"),
|
|
214
|
+
path.join(home, "Library", "Messages", "chat.db"),
|
|
215
|
+
path.join(home, "Library", "Application Support", "AddressBook")
|
|
216
|
+
];
|
|
217
|
+
let sawPath = false;
|
|
218
|
+
for (const target of targets) {
|
|
219
|
+
try {
|
|
220
|
+
accessFn(target, fs.constants.R_OK);
|
|
221
|
+
sawPath = true;
|
|
222
|
+
} catch (e) {
|
|
223
|
+
const code = e && e.code ? e.code : "";
|
|
224
|
+
if (code === "EPERM" || code === "EACCES") {
|
|
225
|
+
return {
|
|
226
|
+
status: "missing",
|
|
227
|
+
message: `Full Disk Access looks missing for this node (${code} reading ${path.basename(target)}). Reads need FDA on node; that is separate from Automation.`
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (code === "ENOENT") {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
status: "skipped",
|
|
235
|
+
message: `Full Disk Access probe skipped (${code || "error"} on ${path.basename(target)})`
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (!sawPath) {
|
|
240
|
+
return { status: "skipped", message: "Mail / Messages / AddressBook paths are not present; skipped Full Disk Access probe" };
|
|
241
|
+
}
|
|
242
|
+
return { status: "readable", message: "Mail / Messages / AddressBook paths are readable (Full Disk Access looks present)" };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function defaultPermissionsProbes() {
|
|
246
|
+
return {
|
|
247
|
+
Contacts: probeContactsAutomation,
|
|
248
|
+
Calendar: probeCalendarAutomation,
|
|
249
|
+
Mail: probeMailAutomation,
|
|
250
|
+
Messages: probeMessagesAutomation
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function bannerLines(binary, version) {
|
|
255
|
+
return [
|
|
256
|
+
`Apple Tools MCP permissions (v${version})`,
|
|
257
|
+
"=".repeat(60),
|
|
258
|
+
`Probing node binary: ${binary.execPath}`,
|
|
259
|
+
"This is process.execPath — the node that is running this command.",
|
|
260
|
+
`Mini example: ${binary.miniExample}`,
|
|
261
|
+
`MacBook example: ${binary.macbookExample} (Claude nvm; not Homebrew)`,
|
|
262
|
+
"Invoke this command with the product node so Allow dialogs attach to it.",
|
|
263
|
+
"",
|
|
264
|
+
"Open System Settings → Privacy & Security → Automation on this Mac.",
|
|
265
|
+
"When macOS asks, click Allow for THIS node — not the MCP host app.",
|
|
266
|
+
"Do not add node via + in the Contacts or Calendars privacy lists.",
|
|
267
|
+
"dry_run of mail_send / messages_send does not count: this command uses real Apple Events.",
|
|
268
|
+
"Nothing is sent to third parties. Mail composes and discards a temporary outgoing message.",
|
|
269
|
+
"Messages only enumerates accounts. Contacts creates and deletes a throwaway person in-script.",
|
|
270
|
+
"Calendar lists calendars only — no events are created."
|
|
271
|
+
];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Run the four live probes and print a grant report.
|
|
276
|
+
*
|
|
277
|
+
* @param {object} [options]
|
|
278
|
+
* @param {string} [options.execPath]
|
|
279
|
+
* @param {string[]} [options.argv]
|
|
280
|
+
* @param {string} [options.version]
|
|
281
|
+
* @param {(msg: string) => void} [options.stdout]
|
|
282
|
+
* @param {Record<string, () => object|Promise<object>>} [options.probes]
|
|
283
|
+
* @param {() => { status: string, message: string }} [options.fdaProbe]
|
|
284
|
+
* @returns {Promise<number>}
|
|
285
|
+
*/
|
|
286
|
+
export async function runPermissionsCommand({
|
|
287
|
+
execPath = process.execPath,
|
|
288
|
+
argv = process.argv,
|
|
289
|
+
version = "",
|
|
290
|
+
stdout = console.log,
|
|
291
|
+
probes,
|
|
292
|
+
fdaProbe = probeFullDiskAccess,
|
|
293
|
+
realpathSync = fs.realpathSync,
|
|
294
|
+
existsSync = fs.existsSync,
|
|
295
|
+
readFileSync = fs.readFileSync
|
|
296
|
+
} = {}) {
|
|
297
|
+
const binary = describeProbeBinary(execPath);
|
|
298
|
+
const mismatch = detectExecPathMismatch({
|
|
299
|
+
execPath,
|
|
300
|
+
argv,
|
|
301
|
+
realpathSync,
|
|
302
|
+
existsSync,
|
|
303
|
+
readFileSync
|
|
304
|
+
});
|
|
305
|
+
const launchAgent = detectLaunchAgentContext({ existsSync });
|
|
306
|
+
const hostAdvice = hostAutomationAdvice({ launchAgent, execPath });
|
|
307
|
+
const probeFns = { ...defaultPermissionsProbes(), ...(probes || {}) };
|
|
308
|
+
const log = (msg) => {
|
|
309
|
+
stdout(msg);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
for (const line of bannerLines(binary, version)) {
|
|
313
|
+
log(line);
|
|
314
|
+
}
|
|
315
|
+
for (const line of formatExecPathMismatchWarn(mismatch)) {
|
|
316
|
+
log(line);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const grants = {};
|
|
320
|
+
for (const surface of REQUIRED_SURFACES) {
|
|
321
|
+
log("");
|
|
322
|
+
log(`--- ${surface} ---`);
|
|
323
|
+
log(`Next dialog: click Allow for node → ${surface} (if asked). Already-granted surfaces stay quiet.`);
|
|
324
|
+
let result;
|
|
325
|
+
try {
|
|
326
|
+
result = await Promise.resolve(probeFns[surface]());
|
|
327
|
+
} catch (e) {
|
|
328
|
+
result = { ok: false, message: e && e.message ? e.message : String(e), kind: "unknown" };
|
|
329
|
+
}
|
|
330
|
+
const status = classifyGrantStatus(result);
|
|
331
|
+
grants[surface] = status;
|
|
332
|
+
log(`[${status}] ${surface}: ${result && result.message ? result.message : ""}`);
|
|
333
|
+
if (status !== GRANT_GRANTED) {
|
|
334
|
+
log(` ${hostAdvice}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const fda = fdaProbe();
|
|
339
|
+
log("");
|
|
340
|
+
log(`--- Full Disk Access (advisory) ---`);
|
|
341
|
+
log(`[${fda.status}] ${fda.message}`);
|
|
342
|
+
|
|
343
|
+
log("");
|
|
344
|
+
log("=".repeat(60));
|
|
345
|
+
log("Grant report");
|
|
346
|
+
for (const line of formatGrantReport(grants)) {
|
|
347
|
+
log(` ${line}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const code = exitCodeForGrants(grants);
|
|
351
|
+
if (code === 0) {
|
|
352
|
+
log("Result: PASS — Contacts, Calendar, Mail, and Messages are granted for this node.");
|
|
353
|
+
log("Safe to re-run; already-granted surfaces report OK without another click.");
|
|
354
|
+
} else {
|
|
355
|
+
log("Result: INCOMPLETE — one or more required grants are missing or errored.");
|
|
356
|
+
log(hostAdvice);
|
|
357
|
+
log("Click Allow for the missing surfaces and re-run this command. Exit is non-zero (fail closed).");
|
|
358
|
+
}
|
|
359
|
+
return code;
|
|
360
|
+
}
|
package/lib/processMode.js
CHANGED
|
@@ -2,12 +2,56 @@
|
|
|
2
2
|
* Process mode detection for apple-tools-mcp.
|
|
3
3
|
*
|
|
4
4
|
* Canonical indexer entrypoint: `node index.js --mode=indexer`
|
|
5
|
-
* Convenience bin: `apple-tools-indexer` (
|
|
5
|
+
* Convenience bin: `apple-tools-indexer` (`bin/apple-tools-indexer.js`;
|
|
6
|
+
* detected via argv[1] or `--mode=indexer`).
|
|
7
|
+
* Permissions CLI: `apple-tools-mcp permissions` / `--mode=permissions`.
|
|
6
8
|
* MCP stdio remains the default when neither is present.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import path from "path";
|
|
10
12
|
|
|
13
|
+
/**
|
|
14
|
+
* First positional user argument, skipping flags (`--foo` / `--foo=bar`).
|
|
15
|
+
* Used so `node index.js permissions` and `apple-tools-mcp permissions`
|
|
16
|
+
* both resolve as the permissions CLI.
|
|
17
|
+
*
|
|
18
|
+
* @param {string[]} argv
|
|
19
|
+
* @returns {string|null}
|
|
20
|
+
*/
|
|
21
|
+
function firstPositionalArg(argv) {
|
|
22
|
+
const rest = Array.isArray(argv) ? argv.slice(2) : [];
|
|
23
|
+
for (let i = 0; i < rest.length; i++) {
|
|
24
|
+
const arg = rest[i];
|
|
25
|
+
if (arg === "--mode") {
|
|
26
|
+
i += 1;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (typeof arg === "string" && arg.startsWith("-")) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
return arg || null;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string[]} [argv=process.argv]
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
export function isPermissionsMode(argv = process.argv) {
|
|
42
|
+
if (!Array.isArray(argv) || argv.length === 0) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (argv.includes("--mode=permissions")) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
const modeIdx = argv.indexOf("--mode");
|
|
49
|
+
if (modeIdx !== -1 && argv[modeIdx + 1] === "permissions") {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return firstPositionalArg(argv) === "permissions";
|
|
53
|
+
}
|
|
54
|
+
|
|
11
55
|
/**
|
|
12
56
|
* @param {string[]} [argv=process.argv]
|
|
13
57
|
* @returns {boolean}
|
|
@@ -16,6 +60,10 @@ export function isIndexerMode(argv = process.argv) {
|
|
|
16
60
|
if (!Array.isArray(argv) || argv.length === 0) {
|
|
17
61
|
return false;
|
|
18
62
|
}
|
|
63
|
+
// permissions is a short-lived CLI on the same bin; it wins over indexer.
|
|
64
|
+
if (isPermissionsMode(argv)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
19
67
|
if (argv.includes("--mode=indexer")) {
|
|
20
68
|
return true;
|
|
21
69
|
}
|
|
@@ -24,5 +72,5 @@ export function isIndexerMode(argv = process.argv) {
|
|
|
24
72
|
return true;
|
|
25
73
|
}
|
|
26
74
|
const entry = argv[1] ? path.basename(argv[1]) : "";
|
|
27
|
-
return entry === "apple-tools-indexer";
|
|
75
|
+
return entry === "apple-tools-indexer" || entry === "apple-tools-indexer.js";
|
|
28
76
|
}
|
package/lib/shell.js
CHANGED
|
@@ -310,6 +310,33 @@ export function safeFind(searchPath, options = {}) {
|
|
|
310
310
|
* @param {object} options - spawnSync options
|
|
311
311
|
* @returns {object} { stdout, stderr, status }
|
|
312
312
|
*/
|
|
313
|
+
/**
|
|
314
|
+
* Launch a first-party app with `open -a` (argv only, never a shell).
|
|
315
|
+
* Contacts cold-start under launchd does not reliably auto-launch from
|
|
316
|
+
* `tell application "Contacts"`; `open -a Contacts` is the host-proven path.
|
|
317
|
+
*/
|
|
318
|
+
export const OPEN_APP_ALLOWLIST = Object.freeze(["Contacts", "Mail", "Messages"]);
|
|
319
|
+
|
|
320
|
+
export function safeOpenApp(appName, options = {}) {
|
|
321
|
+
const { timeout = 15000, spawn = spawnSync } = options;
|
|
322
|
+
if (!OPEN_APP_ALLOWLIST.includes(appName)) {
|
|
323
|
+
throw new Error("open -a app name is not allowed");
|
|
324
|
+
}
|
|
325
|
+
const result = spawn("open", ["-a", appName], {
|
|
326
|
+
encoding: "utf-8",
|
|
327
|
+
timeout,
|
|
328
|
+
shell: false
|
|
329
|
+
});
|
|
330
|
+
if (result.error) {
|
|
331
|
+
throw result.error;
|
|
332
|
+
}
|
|
333
|
+
if (result.status !== 0) {
|
|
334
|
+
const stderr = String(result.stderr || "").trim();
|
|
335
|
+
throw new Error(stderr || `open -a ${appName} exited ${result.status}`);
|
|
336
|
+
}
|
|
337
|
+
return String(result.stdout || "").trim();
|
|
338
|
+
}
|
|
339
|
+
|
|
313
340
|
export function safeSpawn(command, args = [], options = {}) {
|
|
314
341
|
const {
|
|
315
342
|
timeout = 30000,
|
package/lib/writeRouting.js
CHANGED
|
@@ -12,10 +12,16 @@
|
|
|
12
12
|
* - An MCP stdio process delegates to the daemon when the write bridge is up.
|
|
13
13
|
* - If delegation is impossible, it runs locally and, on a TCC denial,
|
|
14
14
|
* explains the host constraint instead of reporting a generic failure.
|
|
15
|
+
* - Mini LaunchAgent / write-bridge hosts may be told to use the daemon.
|
|
16
|
+
* MacBook / Terminal / permissions CLI (no writer.sock) must not be told
|
|
17
|
+
* to start apple-tools-indexer — that path is Mini-only.
|
|
15
18
|
*
|
|
16
19
|
* These helpers are pure so the routing policy is testable off-macOS.
|
|
17
20
|
*/
|
|
18
21
|
|
|
22
|
+
import fs from "fs";
|
|
23
|
+
import { defaultSocketPath } from "./writeBridge.js";
|
|
24
|
+
|
|
19
25
|
/**
|
|
20
26
|
* Writes that macOS gates behind per-app privacy (TCC) rather than plain
|
|
21
27
|
* file permissions. All of them benefit from running in the daemon.
|
|
@@ -58,12 +64,76 @@ export function planAfterDelegation({ delivered, response }) {
|
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
67
|
+
* Mini always-on indexer / write bridge is present (writer.sock or this
|
|
68
|
+
* process is the daemon). Absent on MacBook / short-lived Terminal CLI.
|
|
63
69
|
*/
|
|
64
|
-
export function
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
export function detectLaunchAgentContext({
|
|
71
|
+
indexerMode = false,
|
|
72
|
+
bridgeAvailable = false,
|
|
73
|
+
socketPath,
|
|
74
|
+
existsSync = fs.existsSync
|
|
75
|
+
} = {}) {
|
|
76
|
+
if (indexerMode || bridgeAvailable) return true;
|
|
77
|
+
const sock = socketPath || defaultSocketPath();
|
|
78
|
+
try {
|
|
79
|
+
return Boolean(sock && existsSync(sock));
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* MacBook / Terminal / permissions CLI — no always-on indexer.
|
|
87
|
+
* Allows attach to the printed process.execPath.
|
|
88
|
+
*/
|
|
89
|
+
export function terminalAutomationAdvice(execPath = process.execPath) {
|
|
90
|
+
const printed = execPath ? ` (${execPath})` : "";
|
|
91
|
+
return (
|
|
92
|
+
`Run this from Terminal.app (short-lived CLI / MacBook host — no always-on indexer). ` +
|
|
93
|
+
`Click Allow for the printed process.execPath${printed}. ` +
|
|
94
|
+
`Check System Settings → Privacy & Security → Automation for that node → Contacts, Calendar, Mail, and Messages. ` +
|
|
95
|
+
`Do not start apple-tools-indexer.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Mini host with a live write-bridge / LaunchAgent.
|
|
101
|
+
*/
|
|
102
|
+
export function miniLaunchAgentAdvice() {
|
|
103
|
+
return (
|
|
104
|
+
"This Mac has a Mini write-bridge / LaunchAgent. Grant the daemon's node Full Disk Access (reads) and " +
|
|
105
|
+
"Allow that node in System Settings → Privacy & Security → Automation for Mail.app, Messages.app, Contacts.app, and Calendar.app. " +
|
|
106
|
+
"Do not add node via + in the Contacts or Calendars privacy lists."
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function hostAutomationAdvice({
|
|
111
|
+
launchAgent = false,
|
|
112
|
+
execPath = process.execPath
|
|
113
|
+
} = {}) {
|
|
114
|
+
return launchAgent ? miniLaunchAgentAdvice() : terminalAutomationAdvice(execPath);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Advice appended when a local write is denied by TCC.
|
|
119
|
+
* No writer.sock / LaunchAgent → Terminal + printed execPath only.
|
|
120
|
+
*/
|
|
121
|
+
export function tccFallbackAdvice({
|
|
122
|
+
bridgeAvailable,
|
|
123
|
+
execPath = process.execPath,
|
|
124
|
+
launchAgent
|
|
125
|
+
} = {}) {
|
|
126
|
+
const mini = launchAgent === undefined ? Boolean(bridgeAvailable) : Boolean(launchAgent);
|
|
127
|
+
if (mini && bridgeAvailable) {
|
|
128
|
+
return (
|
|
129
|
+
"The indexer daemon was reachable but the write was still denied; grant the daemon's node binary Full Disk Access (reads) and " +
|
|
130
|
+
"Allow node in System Settings > Privacy & Security > Automation for Mail.app, Messages.app, Contacts.app, and Calendar.app. " +
|
|
131
|
+
"A hang or timeout on Mail compose is TCC / Automation denied, not Mail.app missing. " +
|
|
132
|
+
"Do not add node via + in the Contacts or Calendars privacy lists — those panes often have no Add button."
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (mini) {
|
|
136
|
+
return miniLaunchAgentAdvice();
|
|
67
137
|
}
|
|
68
|
-
return
|
|
138
|
+
return terminalAutomationAdvice(execPath);
|
|
69
139
|
}
|
package/lib/writeTools.js
CHANGED
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
import { contactsAdd, contactsEdit, contactsRemove } from "./contactsWrite.js";
|
|
33
33
|
import { planWriteRoute, planAfterDelegation, tccFallbackAdvice } from "./writeRouting.js";
|
|
34
34
|
import { requestWriteViaBridge, probeSocket, defaultSocketPath } from "./writeBridge.js";
|
|
35
|
-
import {
|
|
35
|
+
import { needsHostTccAdvice } from "./appleScript.js";
|
|
36
36
|
|
|
37
37
|
const CONFIRM_PROPS = {
|
|
38
38
|
dry_run: { type: "boolean", description: "Preview only: report what would happen and change nothing (default false)" },
|
|
@@ -48,7 +48,7 @@ export const WRITE_TOOL_DEFINITIONS = [
|
|
|
48
48
|
// ============ MAIL WRITES ============
|
|
49
49
|
{
|
|
50
50
|
name: "mail_send",
|
|
51
|
-
description: "Send an email through Mail.app. Sending to more than one recipient in total (to + cc + bcc) requires confirm=true. Use dry_run=true to preview.",
|
|
51
|
+
description: "Send an email through Mail.app. Sending to more than one recipient in total (to + cc + bcc) requires confirm=true. Use dry_run=true to preview. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; the tool checks Sent before failing. Clients must Sent-check before retrying a timed-out send.",
|
|
52
52
|
inputSchema: {
|
|
53
53
|
type: "object",
|
|
54
54
|
properties: {
|
|
@@ -82,7 +82,7 @@ export const WRITE_TOOL_DEFINITIONS = [
|
|
|
82
82
|
},
|
|
83
83
|
{
|
|
84
84
|
name: "mail_reply",
|
|
85
|
-
description: "Reply to an existing email. reply_all=true fans out to every original recipient and requires confirm=true.",
|
|
85
|
+
description: "Reply to an existing email. reply_all=true fans out to every original recipient and requires confirm=true. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; the tool checks Sent before failing. Clients must Sent-check before retrying a timed-out send.",
|
|
86
86
|
inputSchema: {
|
|
87
87
|
type: "object",
|
|
88
88
|
properties: {
|
|
@@ -97,7 +97,7 @@ export const WRITE_TOOL_DEFINITIONS = [
|
|
|
97
97
|
},
|
|
98
98
|
{
|
|
99
99
|
name: "mail_forward",
|
|
100
|
-
description: "Forward an existing email to new recipients. More than one recipient requires confirm=true.",
|
|
100
|
+
description: "Forward an existing email to new recipients. More than one recipient requires confirm=true. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; the tool checks Sent before failing. Clients must Sent-check before retrying a timed-out send.",
|
|
101
101
|
inputSchema: {
|
|
102
102
|
type: "object",
|
|
103
103
|
properties: {
|
|
@@ -403,9 +403,12 @@ export async function dispatchWriteTool(name, args = {}, deps = {}) {
|
|
|
403
403
|
result &&
|
|
404
404
|
result.ok === false &&
|
|
405
405
|
result.suppressTccAdvice !== true &&
|
|
406
|
-
|
|
406
|
+
needsHostTccAdvice(result.message)
|
|
407
407
|
) {
|
|
408
|
-
return {
|
|
408
|
+
return {
|
|
409
|
+
...result,
|
|
410
|
+
message: `${result.message} ${tccFallbackAdvice({ bridgeAvailable, execPath: process.execPath })}`
|
|
411
|
+
};
|
|
409
412
|
}
|
|
410
413
|
return result;
|
|
411
414
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apple-tools-mcp",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "MCP server for semantic search and write actions across Apple Mail, Messages, Calendar, and Contacts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
8
|
-
"apple-tools-mcp": "
|
|
9
|
-
"apple-tools-indexer": "
|
|
8
|
+
"apple-tools-mcp": "bin/apple-tools-mcp.js",
|
|
9
|
+
"apple-tools-indexer": "bin/apple-tools-indexer.js"
|
|
10
10
|
},
|
|
11
11
|
"author": "Peter Coates",
|
|
12
12
|
"license": "MIT",
|
|
@@ -45,14 +45,18 @@
|
|
|
45
45
|
"search.js",
|
|
46
46
|
"contacts.js",
|
|
47
47
|
"lib/",
|
|
48
|
+
"bin/",
|
|
48
49
|
"scripts/audit-index.js",
|
|
49
50
|
"scripts/smoke-writes.js",
|
|
51
|
+
"scripts/postinstall.js",
|
|
50
52
|
"README.md",
|
|
51
53
|
"LICENSE"
|
|
52
54
|
],
|
|
53
55
|
"scripts": {
|
|
54
56
|
"start": "node index.js",
|
|
55
57
|
"indexer": "node index.js --mode=indexer",
|
|
58
|
+
"permissions": "node index.js permissions",
|
|
59
|
+
"postinstall": "node scripts/postinstall.js",
|
|
56
60
|
"build-index": "node -e \"import('./indexer.js').then(i=>i.rebuildIndex()).catch(e=>{console.error(e.message);process.exit(1)})\"",
|
|
57
61
|
"audit": "node scripts/audit-index.js",
|
|
58
62
|
"smoke:writes": "node scripts/smoke-writes.js",
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Print-only reminder after npm install / upgrade.
|
|
4
|
+
*
|
|
5
|
+
* Must not run GUI / TCC probes unattended: a LaunchAgent or headless
|
|
6
|
+
* npm cannot click Allow. The operator runs `apple-tools-mcp permissions`
|
|
7
|
+
* on the host UI.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function postinstallReminderText() {
|
|
11
|
+
return [
|
|
12
|
+
"apple-tools-mcp: after first install or upgrade, run permissions on the Mac UI",
|
|
13
|
+
"(with System Settings → Privacy & Security → Automation open):",
|
|
14
|
+
"",
|
|
15
|
+
" $(which node) $(which apple-tools-mcp) permissions",
|
|
16
|
+
" apple-tools-mcp permissions",
|
|
17
|
+
" npx apple-tools-mcp permissions",
|
|
18
|
+
"",
|
|
19
|
+
"Use the same node binary the product uses (process.execPath).",
|
|
20
|
+
"A shebang `apple-tools-mcp` can start a different node than the path you typed;",
|
|
21
|
+
"Allows attach to execPath. Re-run with that exact node if the command WARNs.",
|
|
22
|
+
"This reminder does not grant anything and does not run the probes."
|
|
23
|
+
].join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function printPostinstallReminder(write = console.log) {
|
|
27
|
+
write(postinstallReminderText());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (process.argv[1] && /postinstall\.js$/.test(process.argv[1])) {
|
|
31
|
+
printPostinstallReminder();
|
|
32
|
+
}
|