safari-mcp 2.11.1 → 2.11.3

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 +18 -31
  2. package/package.json +1 -1
  3. package/safari.js +46 -6
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);
@@ -1100,12 +1080,19 @@ server.tool(
1100
1080
 
1101
1081
  server.tool(
1102
1082
  "safari_select_option",
1103
- "Select an option in a native <select> dropdown. Sets .value and dispatches change event. For custom dropdowns (React/LinkedIn), use safari_click on the dropdown trigger, then safari_click on the option instead.",
1083
+ "Select an option in a native <select> dropdown. Sets .value and dispatches change event. Pass `ref` (from safari_snapshot) for a select inside an iframe or shadow DOM — a plain `selector` only reaches the top document. For custom dropdowns (React/LinkedIn), use safari_click on the dropdown trigger, then safari_click on the option instead.",
1104
1084
  {
1105
- selector: z.string().describe("CSS selector of the select"),
1106
- value: z.string().describe("Option value to select"),
1085
+ selector: z.string().optional().describe("CSS selector of the select (top document only)"),
1086
+ ref: z.string().optional().describe("Ref ID from safari_snapshot — required for selects inside iframes/shadow DOM"),
1087
+ value: z.string().describe("Option value or visible label to select"),
1107
1088
  },
1108
1089
  async (args) => {
1090
+ // ref path: resolve via mcpFindRef (reaches iframes/shadow DOM) on the AppleScript
1091
+ // engine — the extension's select_option handler is selector-only.
1092
+ if (args.ref) {
1093
+ const refResult = await safari.selectOption({ ref: args.ref, value: args.value });
1094
+ return { content: [{ type: "text", text: typeof refResult === 'string' ? refResult : JSON.stringify(refResult) }] };
1095
+ }
1109
1096
  let result = await extensionOrFallback(
1110
1097
  "select_option", { selector: args.selector, value: args.value },
1111
1098
  () => safari.selectOption(args)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.11.1",
3
+ "version": "2.11.3",
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
@@ -1905,11 +1905,23 @@ export async function clearField({ selector }) {
1905
1905
  );
1906
1906
  }
1907
1907
 
1908
- export async function selectOption({ selector, value }) {
1909
- const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1910
- const val = value.replace(/'/g, "\\'");
1908
+ export async function selectOption({ selector, value, ref }) {
1909
+ // ref/deep finder: native <select> elements inside same-origin iframes or shadow
1910
+ // DOM are invisible to a top-frame document.querySelector. mcpFindRef (snapshot ref)
1911
+ // and mcpQuerySelectorDeep traverse those roots — the same finders click() uses.
1912
+ await ensureHelpers();
1913
+ const val = String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1914
+ let finder;
1915
+ if (ref) {
1916
+ finder = `mcpFindRef('${String(ref).replace(/'/g, "\\'")}')`;
1917
+ } else if (selector) {
1918
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1919
+ finder = `(document.querySelector('${sel}')||mcpQuerySelectorDeep('${sel}'))`;
1920
+ } else {
1921
+ throw new Error("selectOption requires 'ref' or 'selector'");
1922
+ }
1911
1923
  return runJS(
1912
- `(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found';el.focus();var t=el._valueTracker;if(t)t.setValue('');var d=Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype,'value');if(d&&d.set){d.set.call(el,'${val}');}else{el.value='${val}';}var m=false;for(var i=0;i<el.options.length;i++){if(el.options[i].value==='${val}'){el.selectedIndex=i;m=true;break;}}if(!m||el.value!=='${val}'){var norm=function(s){return s.replace(/[\\u200B-\\u200F\\u202A-\\u202E\\u2066-\\u2069\\uFEFF]/g,'').replace(/[\\u2010-\\u2015\\u2212\\uFE58\\uFE63\\uFF0D]/g,'-').replace(/\\s*-\\s*/g,'-').replace(/\\s+/g,' ').trim();};var cv=norm('${val}');for(var i=0;i<el.options.length;i++){if(norm(el.options[i].value)===cv||norm(el.options[i].text)===cv){el.selectedIndex=i;if(d&&d.set){d.set.call(el,el.options[i].value);}else{el.value=el.options[i].value;}m=true;break;}}if(!m){for(var i=0;i<el.options.length;i++){var nv=norm(el.options[i].value),nt=norm(el.options[i].text);if(nv.indexOf(cv)>=0||nt.indexOf(cv)>=0||cv.indexOf(nv)>=0||cv.indexOf(nt)>=0){if(i===0&&el.options.length>1)continue;el.selectedIndex=i;if(d&&d.set){d.set.call(el,el.options[i].value);}else{el.value=el.options[i].value;}m=true;break;}}}}el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));el.dispatchEvent(new Event('blur',{bubbles:true}));return 'Selected: '+el.value+' (index '+el.selectedIndex+')';})()`
1924
+ `(function(){var el=${finder};if(!el)return 'Element not found';el.focus();var t=el._valueTracker;if(t)t.setValue('');var d=Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype,'value');if(d&&d.set){d.set.call(el,'${val}');}else{el.value='${val}';}var m=false;for(var i=0;i<el.options.length;i++){if(el.options[i].value==='${val}'){el.selectedIndex=i;m=true;break;}}if(!m||el.value!=='${val}'){var norm=function(s){return s.replace(/[\\u200B-\\u200F\\u202A-\\u202E\\u2066-\\u2069\\uFEFF]/g,'').replace(/[\\u2010-\\u2015\\u2212\\uFE58\\uFE63\\uFF0D]/g,'-').replace(/\\s*-\\s*/g,'-').replace(/\\s+/g,' ').trim();};var cv=norm('${val}');for(var i=0;i<el.options.length;i++){if(norm(el.options[i].value)===cv||norm(el.options[i].text)===cv){el.selectedIndex=i;if(d&&d.set){d.set.call(el,el.options[i].value);}else{el.value=el.options[i].value;}m=true;break;}}if(!m){for(var i=0;i<el.options.length;i++){var nv=norm(el.options[i].value),nt=norm(el.options[i].text);if(nv.indexOf(cv)>=0||nt.indexOf(cv)>=0||cv.indexOf(nv)>=0||cv.indexOf(nt)>=0){if(i===0&&el.options.length>1)continue;el.selectedIndex=i;if(d&&d.set){d.set.call(el,el.options[i].value);}else{el.value=el.options[i].value;}m=true;break;}}}}el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));el.dispatchEvent(new Event('blur',{bubbles:true}));return 'Selected: '+el.value+' (index '+el.selectedIndex+')';})()`
1913
1925
  );
1914
1926
  }
1915
1927
 
@@ -3005,6 +3017,26 @@ export async function waitFor({ selector, text, timeout = 10000 }) {
3005
3017
 
3006
3018
  // ========== EVALUATE ==========
3007
3019
 
3020
+ // Index of the last `;` that ends a top-level statement — skips `;` inside strings,
3021
+ // template literals and parens/brackets/braces (e.g. a `for (;;)` header). Returns
3022
+ // -1 when there is no top-level statement separator.
3023
+ function _lastTopLevelSemicolon(s) {
3024
+ let depth = 0, inStr = false, quote = '', last = -1;
3025
+ for (let i = 0; i < s.length; i++) {
3026
+ const c = s[i];
3027
+ if (inStr) {
3028
+ if (c === '\\') { i++; continue; }
3029
+ if (c === quote) inStr = false;
3030
+ continue;
3031
+ }
3032
+ if (c === '"' || c === "'" || c === '`') { inStr = true; quote = c; }
3033
+ else if (c === '(' || c === '[' || c === '{') depth++;
3034
+ else if (c === ')' || c === ']' || c === '}') depth--;
3035
+ else if (c === ';' && depth === 0) last = i;
3036
+ }
3037
+ return last;
3038
+ }
3039
+
3008
3040
  // Build the expression to evaluate from a user script. Pure (no Safari calls) so
3009
3041
  // it can be unit-tested directly — see scripts/test-evaluate-wrapping.js.
3010
3042
  export function _buildEvalExpr(js) {
@@ -3045,8 +3077,16 @@ export function _buildEvalExpr(js) {
3045
3077
  if (addedReturn) {
3046
3078
  expr = `(${isAsync ? 'async function' : 'function'}(){${lines.join('\n')}})()`;
3047
3079
  } else if (isAsync) {
3048
- // No safe return slot — run the body in an async IIFE (value may be undefined).
3049
- expr = `(async function(){${js}})()`;
3080
+ // No newline gave a return slot — typically a single-line `const x = await …; expr`.
3081
+ // Split at the last top-level `;`: if a bare expression follows it, that becomes the
3082
+ // awaited result. Otherwise run the body as-is (value may be undefined).
3083
+ const semi = _lastTopLevelSemicolon(js);
3084
+ const tail = semi >= 0 ? js.slice(semi + 1).trim() : '';
3085
+ if (tail && !tail.startsWith('}') && !NON_EXPR.test(tail)) {
3086
+ expr = `(async function(){${js.slice(0, semi + 1)} return (${tail}); })()`;
3087
+ } else {
3088
+ expr = `(async function(){${js}})()`;
3089
+ }
3050
3090
  } else {
3051
3091
  // Indirect eval yields the completion value of an arbitrary statement list;
3052
3092
  // the catch re-runs the body plainly when a strict CSP blocks eval.