apple-tools-mcp 3.0.2 → 3.0.4

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/lib/mailWrite.js CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  isHardTccDenial
32
32
  } from "./appleScript.js";
33
33
  import { isIndexerMode } from "./processMode.js";
34
+ import { safeSpawnDetached } from "./shell.js";
34
35
  import {
35
36
  planWrite,
36
37
  plannedWriteResult,
@@ -160,6 +161,30 @@ export const BODY_PASTE_MISDIRECTED_SENTINEL = "BODY_PASTE_MISDIRECTED";
160
161
  export const BODY_FOCUS_FAILED_SENTINEL = "BODY_FOCUS_FAILED";
161
162
  /** Focus request ran, but the re-read AXFocusedUIElement was not the chosen body. */
162
163
  export const BODY_FOCUS_UNPROVEN_SENTINEL = "BODY_FOCUS_UNPROVEN";
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
+ */
168
+ export const DISPLAY_ASLEEP_GUI_UNAVAILABLE_SENTINEL = "DISPLAY_ASLEEP_GUI_UNAVAILABLE";
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;
171
+ export const DISPLAY_WAKE_POLL_ATTEMPTS = 20;
172
+
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;
181
+ try {
182
+ return spawnDetached("/usr/bin/caffeinate", ["-u", "-t", String(DISPLAY_WAKE_SECONDS)]) != null;
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
163
188
  /** Mail To: recipient chips surface as U+FFFC (object replacement), not body text. */
164
189
  export const MAIL_RECIPIENT_TOKEN_CHAR_ID = 65532;
165
190
  export const GUI_SCRIPTING_UNAVAILABLE_SENTINEL = "GUI_SCRIPTING_UNAVAILABLE";
@@ -618,6 +643,16 @@ on atmAxValueIsRecipientToken(atmVal)
618
643
  return false
619
644
  end atmAxValueIsRecipientToken
620
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
+
621
656
  on atmFillMailBody(bodyText, needle, titleMarker, msg)
622
657
  try
623
658
  tell application "Mail" to activate
@@ -628,8 +663,15 @@ on atmFillMailBody(bodyText, needle, titleMarker, msg)
628
663
  set atmSaveBh to -1
629
664
  with timeout of ${seSecs} seconds
630
665
  tell application "System Events"
666
+ my atmStopScreenSaver()
631
667
  tell process "Mail"
632
668
  set frontmost to true
669
+ repeat ${DISPLAY_WAKE_POLL_ATTEMPTS} times
670
+ if (count of (every window)) > 0 then exit repeat
671
+ my atmStopScreenSaver()
672
+ delay 0.5
673
+ end repeat
674
+ set atmSeWindowCount to count of (every window)
633
675
  delay 0.3
634
676
  set atmWin to missing value
635
677
  repeat with atmCand in windows
@@ -658,6 +700,7 @@ on atmFillMailBody(bodyText, needle, titleMarker, msg)
658
700
  end if
659
701
  end repeat
660
702
  end if
703
+ if atmWin is missing value and atmSeWindowCount is 0 then error "${DISPLAY_ASLEEP_GUI_UNAVAILABLE_SENTINEL}"
661
704
  if atmWin is missing value then error "${BODY_FOCUS_FAILED_SENTINEL}"
662
705
  try
663
706
  perform action "AXRaise" of atmWin
@@ -744,6 +787,7 @@ on atmFillMailBody(bodyText, needle, titleMarker, msg)
744
787
  on error errMsg number errNum
745
788
  if errMsg contains "assistive access" or errNum is -25211 then error "${ACCESSIBILITY_DENIED_SENTINEL}"
746
789
  if errMsg contains "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}" then error "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}"
790
+ if errMsg contains "${DISPLAY_ASLEEP_GUI_UNAVAILABLE_SENTINEL}" then error "${DISPLAY_ASLEEP_GUI_UNAVAILABLE_SENTINEL}"
747
791
  error errMsg number errNum
748
792
  end try
749
793
  end atmFillMailBody`;
@@ -1292,6 +1336,9 @@ function failure(action, summary, result, secrets) {
1292
1336
  if (err.includes("gui_scripting_unavailable")) {
1293
1337
  return `${action} failed — attempted to ${summary}. ${MAIL_GUI_SCRIPTING_GUIDANCE}`;
1294
1338
  }
1339
+ if (err.includes("display_asleep_gui_unavailable")) {
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.`;
1341
+ }
1295
1342
  if (err.includes("body_focus_unproven")) {
1296
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.`;
1297
1344
  }
@@ -1480,6 +1527,7 @@ export function mailCompose(args = {}, { draft = false, indexerMode = isIndexerM
1480
1527
  });
1481
1528
 
1482
1529
  const deadlineMs = sentVerifyClock.now() + MCP_CALL_DEADLINE_MS;
1530
+ wakeDisplay();
1483
1531
  const result = runAppleScript(script, { timeout: MAIL_COMPOSE_TIMEOUT_MS, appName: "Mail" });
1484
1532
  if (
1485
1533
  !result.ok &&
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "3.0.2",
3
+ "version": "3.0.4",
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",