safari-mcp 2.11.0 → 2.11.2

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.
Files changed (3) hide show
  1. package/index.js +27 -30
  2. package/package.json +1 -1
  3. package/safari.js +39 -2
package/index.js CHANGED
@@ -20,34 +20,14 @@ import { homedir } from "node:os";
20
20
 
21
21
  const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB cap on POST body — prevents DoS
22
22
 
23
- // ========== SINGLETON: kill stale instances from previous sessions ==========
24
- // NOTE: Claude Code VSCode may start 2 instances simultaneously (~40ms apart).
25
- // Only kill instances running >10 seconds (truly stale), not fresh siblings.
26
- try {
27
- const myPid = process.pid;
28
- const lines = execFileSync("pgrep", ["-af", "node.*/safari-mcp/index\\.js"], { encoding: "utf8" }).trim().split("\n");
29
- for (const line of lines) {
30
- const pid = parseInt(line.split(/\s+/)[0], 10);
31
- if (pid && pid !== myPid) {
32
- // Check how long the process has been running (elapsed time in seconds)
33
- let elapsedSec = 0;
34
- try {
35
- const etime = execFileSync("ps", ["-o", "etime=", "-p", String(pid)], { encoding: "utf8" }).trim();
36
- // etime format: [[DD-]HH:]MM:SS — parse to seconds
37
- const parts = etime.replace(/-/g, ":").split(":").map(Number);
38
- if (parts.length === 2) elapsedSec = parts[0] * 60 + parts[1];
39
- else if (parts.length === 3) elapsedSec = parts[0] * 3600 + parts[1] * 60 + parts[2];
40
- else if (parts.length === 4) elapsedSec = parts[0] * 86400 + parts[1] * 3600 + parts[2] * 60 + parts[3];
41
- } catch {}
42
- if (elapsedSec > 10) {
43
- try { process.kill(pid, "SIGTERM"); } catch {}
44
- console.error(`[Safari MCP] Killed stale instance PID ${pid} (running ${elapsedSec}s)`);
45
- } else {
46
- console.error(`[Safari MCP] Skipping fresh instance PID ${pid} (running ${elapsedSec}s)`);
47
- }
48
- }
49
- }
50
- } catch {}
23
+ // ========== MULTI-INSTANCE: concurrent instances coexist (never kill siblings) ==========
24
+ // This block previously SIGTERM'd every other safari-mcp instance running >10s to
25
+ // clear "stale" processes from previous sessions. That broke multi-session use:
26
+ // each new Claude Code session's instance killed every older session's instance,
27
+ // disconnecting them mid-task. Concurrent instances are fully supported by design —
28
+ // the first to bind HTTP_PORT becomes the extension host, the rest proxy commands
29
+ // through it (see PROXY MODE below). Instances from closed sessions are SIGTERM'd
30
+ // by their own MCP client on shutdown, so no cross-instance cleanup is needed here.
51
31
 
52
32
  // ========== SESSION ID (unique per MCP process — enables per-session tab tracking) ==========
53
33
  const SESSION_ID = randomUUID().slice(0, 8);
@@ -127,6 +107,20 @@ function _isURLOwned(url) {
127
107
  return false;
128
108
  }
129
109
 
110
+ // Sentinel persisted when a blank tab (about:blank) is opened by this session.
111
+ // A blank tab has no unique URL to own, but ownership must still survive an MCP
112
+ // process restart (_openedTabs is in-memory only) — otherwise reopening blank
113
+ // tabs falsely trips the "no tabs opened yet" guard. The sentinel is never a
114
+ // real tab URL, so it cannot falsely match a user's page in _isURLOwned().
115
+ const BLANK_TAB_SENTINEL = "__mcp-blank-tab__";
116
+
117
+ function _markBlankTabOpened() {
118
+ if (!_ownedTabURLs.has(BLANK_TAB_SENTINEL)) {
119
+ _ownedTabURLs.add(BLANK_TAB_SENTINEL);
120
+ _saveOwnershipFile(_ownedTabURLs);
121
+ }
122
+ }
123
+
130
124
  function _addOwnedURL(url) {
131
125
  if (url && url !== 'about:blank' && url !== 'favorites://') {
132
126
  _ownedTabURLs.add(url);
@@ -697,7 +691,7 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
697
691
  throw new Error(msg);
698
692
  } else if (currentUrl && !_isURLOwned(currentUrl)) {
699
693
  // about:blank tabs are owned if we have any tracked tabs (new_tab creates them at about:blank)
700
- const isBlankOwned = (currentUrl === 'about:blank' || currentUrl === 'missing value') && _openedTabs.size > 0;
694
+ const isBlankOwned = (currentUrl === 'about:blank' || currentUrl === 'missing value') && (_openedTabs.size > 0 || _ownedTabURLs.has(BLANK_TAB_SENTINEL));
701
695
  if (!isBlankOwned) {
702
696
  const msg = `⚠️ Tab safety: refusing "${extensionType}" — current tab (${currentUrl}) was not opened by this MCP session. Use safari_new_tab or safari_switch_tab to target your own tab.`;
703
697
  console.error(`[Safari MCP] ${msg}`);
@@ -1354,6 +1348,9 @@ server.tool(
1354
1348
  _addOwnedURL(trackUrl);
1355
1349
  if (url && url !== trackUrl) _addOwnedURL(url); // also own the requested URL (handles www redirects)
1356
1350
  }
1351
+ // Blank tab (no URL requested): persist a restart-surviving ownership marker.
1352
+ const _effectiveNewURL = (result?.url && result.url !== 'about:blank' && result.url !== 'missing value') ? result.url : url;
1353
+ if (!_effectiveNewURL) _markBlankTabOpened();
1357
1354
  return { content: [{ type: "text", text: typeof rawResult === 'string' ? rawResult : JSON.stringify(result) }] };
1358
1355
  }
1359
1356
  );
@@ -1384,7 +1381,7 @@ server.tool(
1384
1381
  const target = parsed.find(t => t.index === index);
1385
1382
  if (target && target.url && !_isURLOwned(target.url)) {
1386
1383
  // about:blank / missing value tabs are owned if tracked in _openedTabs
1387
- const isBlankOwned = (target.url === 'about:blank' || target.url === 'missing value') && _openedTabs.has(index);
1384
+ const isBlankOwned = (target.url === 'about:blank' || target.url === 'missing value') && (_openedTabs.has(index) || _ownedTabURLs.has(BLANK_TAB_SENTINEL));
1388
1385
  if (!isBlankOwned) {
1389
1386
  const msg = `⚠️ Tab safety: refusing switch_tab to index ${index} (${target.url}) — not opened by this MCP session. Use safari_new_tab to open your own tab.`;
1390
1387
  console.error(`[Safari MCP] ${msg}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.11.0",
3
+ "version": "2.11.2",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 80 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
package/safari.js CHANGED
@@ -107,6 +107,15 @@ function cleanupHelper() {
107
107
  // cleanupHelper runs via process.on("exit"), which fires when index.js calls process.exit().
108
108
  process.on("exit", cleanupHelper);
109
109
  process.on("uncaughtException", (err) => { console.error("Uncaught:", err); cleanupHelper(); process.exit(1); });
110
+ // Unhandled promise rejections must NOT terminate the server. A single failed
111
+ // async operation — e.g. a proxy fetch to the primary instance while it is
112
+ // mid-restart, or an aborted fetch timeout — would otherwise bubble to the
113
+ // uncaughtException handler above (Node's default for unhandled rejections) and
114
+ // exit the whole MCP process, disconnecting every concurrent session. Log and
115
+ // continue: the failed operation is localized, the process itself is healthy.
116
+ process.on("unhandledRejection", (reason) => {
117
+ console.error("[Safari MCP] Unhandled rejection (non-fatal, continuing):", (reason && reason.stack) || reason);
118
+ });
110
119
 
111
120
  // ========== SAFARI RUNNING CHECK ==========
112
121
  // Prevent AppleScript from auto-launching Safari when it's closed
@@ -2996,6 +3005,26 @@ export async function waitFor({ selector, text, timeout = 10000 }) {
2996
3005
 
2997
3006
  // ========== EVALUATE ==========
2998
3007
 
3008
+ // Index of the last `;` that ends a top-level statement — skips `;` inside strings,
3009
+ // template literals and parens/brackets/braces (e.g. a `for (;;)` header). Returns
3010
+ // -1 when there is no top-level statement separator.
3011
+ function _lastTopLevelSemicolon(s) {
3012
+ let depth = 0, inStr = false, quote = '', last = -1;
3013
+ for (let i = 0; i < s.length; i++) {
3014
+ const c = s[i];
3015
+ if (inStr) {
3016
+ if (c === '\\') { i++; continue; }
3017
+ if (c === quote) inStr = false;
3018
+ continue;
3019
+ }
3020
+ if (c === '"' || c === "'" || c === '`') { inStr = true; quote = c; }
3021
+ else if (c === '(' || c === '[' || c === '{') depth++;
3022
+ else if (c === ')' || c === ']' || c === '}') depth--;
3023
+ else if (c === ';' && depth === 0) last = i;
3024
+ }
3025
+ return last;
3026
+ }
3027
+
2999
3028
  // Build the expression to evaluate from a user script. Pure (no Safari calls) so
3000
3029
  // it can be unit-tested directly — see scripts/test-evaluate-wrapping.js.
3001
3030
  export function _buildEvalExpr(js) {
@@ -3036,8 +3065,16 @@ export function _buildEvalExpr(js) {
3036
3065
  if (addedReturn) {
3037
3066
  expr = `(${isAsync ? 'async function' : 'function'}(){${lines.join('\n')}})()`;
3038
3067
  } else if (isAsync) {
3039
- // No safe return slot — run the body in an async IIFE (value may be undefined).
3040
- expr = `(async function(){${js}})()`;
3068
+ // No newline gave a return slot — typically a single-line `const x = await …; expr`.
3069
+ // Split at the last top-level `;`: if a bare expression follows it, that becomes the
3070
+ // awaited result. Otherwise run the body as-is (value may be undefined).
3071
+ const semi = _lastTopLevelSemicolon(js);
3072
+ const tail = semi >= 0 ? js.slice(semi + 1).trim() : '';
3073
+ if (tail && !tail.startsWith('}') && !NON_EXPR.test(tail)) {
3074
+ expr = `(async function(){${js.slice(0, semi + 1)} return (${tail}); })()`;
3075
+ } else {
3076
+ expr = `(async function(){${js}})()`;
3077
+ }
3041
3078
  } else {
3042
3079
  // Indirect eval yields the completion value of an arbitrary statement list;
3043
3080
  // the catch re-runs the body plainly when a strict CSP blocks eval.