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 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 { safeSpawn } from "./shell.js";
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
- /** System Events sees 0 Mail windows (display asleep) while Mail AppleScript still lists the compose. */
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
- export const DISPLAY_WAKE_SECONDS = 15;
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
- /** Declare user activity so a sleeping display wakes before GUI scripting. Best-effort; never throws. */
170
- export function wakeDisplay() {
171
- if (process.platform !== "darwin" || process.env.VITEST) return false;
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 safeSpawn("/usr/bin/caffeinate", ["-u", "-t", String(DISPLAY_WAKE_SECONDS)], { timeout: 5000 }).status === 0;
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 a wake attempt); nothing was sent. Wake the display and retry.`;
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
- log(`Write bridge unavailable for ${name} (${error || "no response"}); running locally.`);
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",
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",