apple-tools-mcp 3.0.3 → 3.0.5
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/index.js +2 -1
- package/lib/mailWrite.js +29 -8
- package/lib/shell.js +23 -1
- package/lib/writeTools.js +55 -1
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
isWriteTool,
|
|
33
33
|
dispatchWriteTool,
|
|
34
34
|
executeWriteToolLocally,
|
|
35
|
+
loggedBridgeHandler,
|
|
35
36
|
mcpWriteResult
|
|
36
37
|
} from "./lib/writeTools.js";
|
|
37
38
|
import { startWriteBridgeServer, defaultSocketPath } from "./lib/writeBridge.js";
|
|
@@ -112,7 +113,7 @@ async function startWriteBridge() {
|
|
|
112
113
|
try {
|
|
113
114
|
writeBridge = await startWriteBridgeServer({
|
|
114
115
|
socketPath: WRITE_SOCKET_PATH,
|
|
115
|
-
handler: (tool, args) => executeWriteToolLocally(tool, args),
|
|
116
|
+
handler: loggedBridgeHandler((tool, args) => executeWriteToolLocally(tool, args), (msg) => console.error(msg)),
|
|
116
117
|
log: (msg) => console.error(msg)
|
|
117
118
|
});
|
|
118
119
|
ensureEventKitSession({ helpers: EVENTKIT_JXA_HELPERS });
|
package/lib/mailWrite.js
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
isHardTccDenial
|
|
32
32
|
} from "./appleScript.js";
|
|
33
33
|
import { isIndexerMode } from "./processMode.js";
|
|
34
|
-
import {
|
|
34
|
+
import { safeSpawnDetached } from "./shell.js";
|
|
35
35
|
import {
|
|
36
36
|
planWrite,
|
|
37
37
|
plannedWriteResult,
|
|
@@ -161,16 +161,25 @@ export const BODY_PASTE_MISDIRECTED_SENTINEL = "BODY_PASTE_MISDIRECTED";
|
|
|
161
161
|
export const BODY_FOCUS_FAILED_SENTINEL = "BODY_FOCUS_FAILED";
|
|
162
162
|
/** Focus request ran, but the re-read AXFocusedUIElement was not the chosen body. */
|
|
163
163
|
export const BODY_FOCUS_UNPROVEN_SENTINEL = "BODY_FOCUS_UNPROVEN";
|
|
164
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* System Events sees 0 Mail windows while Mail AppleScript still lists the compose:
|
|
166
|
+
* the screen saver is up (loginwindow owns the screen) or the display is asleep.
|
|
167
|
+
*/
|
|
165
168
|
export const DISPLAY_ASLEEP_GUI_UNAVAILABLE_SENTINEL = "DISPLAY_ASLEEP_GUI_UNAVAILABLE";
|
|
166
|
-
|
|
169
|
+
/** Outlasts the whole compose osascript (MAIL_COMPOSE_TIMEOUT_MS) so the assertion is live while it runs. */
|
|
170
|
+
export const DISPLAY_WAKE_SECONDS = 30;
|
|
167
171
|
export const DISPLAY_WAKE_POLL_ATTEMPTS = 20;
|
|
168
172
|
|
|
169
|
-
/**
|
|
170
|
-
|
|
171
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Declare user activity so a sleeping display wakes before GUI scripting. caffeinate runs
|
|
175
|
+
* detached for its own -t: a blocking spawn would either SIGKILL it early (3.0.3's 5s timeout
|
|
176
|
+
* cut the 15s assertion at ~4s) or release it before compose starts. This does not dismiss a
|
|
177
|
+
* running screen saver; the compose script stops that itself. Best-effort; never throws.
|
|
178
|
+
*/
|
|
179
|
+
export function wakeDisplay({ spawnDetached = safeSpawnDetached, platform = process.platform } = {}) {
|
|
180
|
+
if (platform !== "darwin" || (process.env.VITEST && spawnDetached === safeSpawnDetached)) return false;
|
|
172
181
|
try {
|
|
173
|
-
return
|
|
182
|
+
return spawnDetached("/usr/bin/caffeinate", ["-u", "-t", String(DISPLAY_WAKE_SECONDS)]) != null;
|
|
174
183
|
} catch {
|
|
175
184
|
return false;
|
|
176
185
|
}
|
|
@@ -634,6 +643,16 @@ on atmAxValueIsRecipientToken(atmVal)
|
|
|
634
643
|
return false
|
|
635
644
|
end atmAxValueIsRecipientToken
|
|
636
645
|
|
|
646
|
+
-- A running screen saver keeps loginwindow in front and hides every Mail window from
|
|
647
|
+
-- System Events; caffeinate's user-activity assertion does not dismiss it.
|
|
648
|
+
on atmStopScreenSaver()
|
|
649
|
+
try
|
|
650
|
+
tell application "System Events"
|
|
651
|
+
if running of screen saver preferences then stop current screen saver
|
|
652
|
+
end tell
|
|
653
|
+
end try
|
|
654
|
+
end atmStopScreenSaver
|
|
655
|
+
|
|
637
656
|
on atmFillMailBody(bodyText, needle, titleMarker, msg)
|
|
638
657
|
try
|
|
639
658
|
tell application "Mail" to activate
|
|
@@ -644,10 +663,12 @@ on atmFillMailBody(bodyText, needle, titleMarker, msg)
|
|
|
644
663
|
set atmSaveBh to -1
|
|
645
664
|
with timeout of ${seSecs} seconds
|
|
646
665
|
tell application "System Events"
|
|
666
|
+
my atmStopScreenSaver()
|
|
647
667
|
tell process "Mail"
|
|
648
668
|
set frontmost to true
|
|
649
669
|
repeat ${DISPLAY_WAKE_POLL_ATTEMPTS} times
|
|
650
670
|
if (count of (every window)) > 0 then exit repeat
|
|
671
|
+
my atmStopScreenSaver()
|
|
651
672
|
delay 0.5
|
|
652
673
|
end repeat
|
|
653
674
|
set atmSeWindowCount to count of (every window)
|
|
@@ -1316,7 +1337,7 @@ function failure(action, summary, result, secrets) {
|
|
|
1316
1337
|
return `${action} failed — attempted to ${summary}. ${MAIL_GUI_SCRIPTING_GUIDANCE}`;
|
|
1317
1338
|
}
|
|
1318
1339
|
if (err.includes("display_asleep_gui_unavailable")) {
|
|
1319
|
-
return `${action} failed — attempted to ${summary}. The display is asleep, so System Events cannot see Mail's compose window (GUI unavailable even after
|
|
1340
|
+
return `${action} failed — attempted to ${summary}. The display is asleep or the screen is locked, so System Events cannot see Mail's compose window (GUI unavailable even after waking the display and stopping the screen saver); nothing was sent. Wake or unlock the Mac and retry.`;
|
|
1320
1341
|
}
|
|
1321
1342
|
if (err.includes("body_focus_unproven")) {
|
|
1322
1343
|
return `${action} failed — attempted to ${summary}. Keyboard focus was not on the message body (focus proof failed); the draft was discarded and nothing was sent.`;
|
package/lib/shell.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - Validating inputs before execution
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { spawnSync } from "child_process";
|
|
11
|
+
import { spawn, spawnSync } from "child_process";
|
|
12
12
|
import path from "path";
|
|
13
13
|
import { escapeSQL } from "./validators.js";
|
|
14
14
|
|
|
@@ -340,6 +340,28 @@ export function safeOpenApp(appName, options = {}) {
|
|
|
340
340
|
return String(result.stdout || "").trim();
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Start a command in the background and return immediately (no shell, no
|
|
345
|
+
* timeout, stdio ignored). The child is detached and unref'd, so it is never
|
|
346
|
+
* killed mid-run by a spawnSync timeout and never holds the caller open — for
|
|
347
|
+
* self-terminating helpers like `caffeinate -t N`. Returns the pid, or null if
|
|
348
|
+
* the spawn failed.
|
|
349
|
+
*/
|
|
350
|
+
export function safeSpawnDetached(command, args = []) {
|
|
351
|
+
if (!command || typeof command !== 'string') {
|
|
352
|
+
throw new Error('Command is required');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!Array.isArray(args)) {
|
|
356
|
+
throw new Error('Args must be an array');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore', shell: false });
|
|
360
|
+
child.on('error', () => {});
|
|
361
|
+
child.unref();
|
|
362
|
+
return child.pid ?? null;
|
|
363
|
+
}
|
|
364
|
+
|
|
343
365
|
export function safeSpawn(command, args = [], options = {}) {
|
|
344
366
|
const {
|
|
345
367
|
timeout = 30000,
|
package/lib/writeTools.js
CHANGED
|
@@ -385,6 +385,46 @@ export function executeWriteToolLocally(name, args = {}) {
|
|
|
385
385
|
* @param {object} [deps] - injected for tests
|
|
386
386
|
* @returns {Promise<{ ok: boolean, message: string }>}
|
|
387
387
|
*/
|
|
388
|
+
/**
|
|
389
|
+
* One-word reason for a failed write, for logs. Never includes addresses,
|
|
390
|
+
* subjects, or bodies (write results echo the recipient and subject).
|
|
391
|
+
*/
|
|
392
|
+
export function classifyWriteFailure(message) {
|
|
393
|
+
const t = String(message || "");
|
|
394
|
+
if (/GUI scripting is unavailable/i.test(t)) return "gui_scripting_unavailable";
|
|
395
|
+
if (/denied Accessibility|assistive access|-25211/i.test(t)) return "accessibility_denied";
|
|
396
|
+
if (/display is asleep|screen saver/i.test(t)) return "display_asleep_or_screen_saver";
|
|
397
|
+
if (/focus proof failed|caret|Keyboard focus/i.test(t)) return "compose_focus";
|
|
398
|
+
if (/timed out|ETIMEDOUT|-1712/i.test(t)) return "timeout";
|
|
399
|
+
if (/-1743|-10004|not authorized to send Apple events/i.test(t)) return "automation_denied";
|
|
400
|
+
if (/unconfirmed/i.test(t)) return "unconfirmed";
|
|
401
|
+
return "other";
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Wrap the indexer's bridge handler so every bridged write leaves one
|
|
406
|
+
* timestamped line: tool, outcome, reason class, duration. The indexer log
|
|
407
|
+
* otherwise has no record of why an overnight send failed.
|
|
408
|
+
*/
|
|
409
|
+
export function loggedBridgeHandler(run, log, now = () => Date.now()) {
|
|
410
|
+
return async (tool, args) => {
|
|
411
|
+
const started = now();
|
|
412
|
+
let result;
|
|
413
|
+
try {
|
|
414
|
+
result = await run(tool, args);
|
|
415
|
+
} catch (e) {
|
|
416
|
+
log(`${new Date(started).toISOString()} write bridge: ${tool} threw after ${now() - started}ms (${classifyWriteFailure(e && e.message)})`);
|
|
417
|
+
throw e;
|
|
418
|
+
}
|
|
419
|
+
const ok = Boolean(result && result.ok);
|
|
420
|
+
// Dry runs and confirm-gated previews come back ok:false with planned:true — nothing failed.
|
|
421
|
+
const planned = !ok && Boolean(result && result.planned);
|
|
422
|
+
const why = ok || planned ? "" : ` (${classifyWriteFailure(result && result.message)}${result && result.unsupported ? ", unsupported" : ""})`;
|
|
423
|
+
log(`${new Date(started).toISOString()} write bridge: ${tool} ${ok ? "ok" : planned ? "planned (dry run / needs confirm)" : "failed"} in ${now() - started}ms${why}`);
|
|
424
|
+
return result;
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
388
428
|
export async function dispatchWriteTool(name, args = {}, deps = {}) {
|
|
389
429
|
const {
|
|
390
430
|
indexerMode = false,
|
|
@@ -406,16 +446,30 @@ export async function dispatchWriteTool(name, args = {}, deps = {}) {
|
|
|
406
446
|
|
|
407
447
|
const route = planWriteRoute({ indexerMode, bridgeAvailable, toolName: name });
|
|
408
448
|
|
|
449
|
+
let declined = null;
|
|
409
450
|
if (route.target === "daemon") {
|
|
410
451
|
const { delivered, response, error } = await request({ socketPath, tool: name, args });
|
|
411
452
|
const { fallbackLocal } = planAfterDelegation({ delivered, response });
|
|
412
453
|
if (!fallbackLocal) {
|
|
413
454
|
return response;
|
|
414
455
|
}
|
|
415
|
-
|
|
456
|
+
if (delivered && response && typeof response === "object") {
|
|
457
|
+
// The daemon answered but couldn't do it (e.g. it couldn't drive Mail's
|
|
458
|
+
// compose window). Its reason is the real one: it holds the grants.
|
|
459
|
+
declined = response;
|
|
460
|
+
log(`Write bridge declined ${name} (${classifyWriteFailure(response.message)}); running locally.`);
|
|
461
|
+
} else {
|
|
462
|
+
log(`Write bridge unavailable for ${name} (${error || "no response"}); running locally.`);
|
|
463
|
+
}
|
|
416
464
|
}
|
|
417
465
|
|
|
418
466
|
const result = runLocally(name, args);
|
|
467
|
+
if (declined && result && result.ok === false && declined.message) {
|
|
468
|
+
return {
|
|
469
|
+
...result,
|
|
470
|
+
message: `${declined.message} (Reported by the indexer daemon, which holds the Automation/Accessibility grants. The local fallback in this process also failed: ${classifyWriteFailure(result.message)}.)`
|
|
471
|
+
};
|
|
472
|
+
}
|
|
419
473
|
if (
|
|
420
474
|
result &&
|
|
421
475
|
result.ok === false &&
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apple-tools-mcp",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.5",
|
|
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",
|