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.
- package/index.js +27 -30
- package/package.json +1 -1
- 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
|
-
// ==========
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
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
|
|
3040
|
-
|
|
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.
|