safari-mcp 2.2.0 → 2.3.0

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/README.md CHANGED
@@ -69,7 +69,12 @@
69
69
  npm install -g safari-mcp
70
70
  ```
71
71
 
72
- **Option B — from source:**
72
+ **Option B — Homebrew:**
73
+ ```bash
74
+ brew install achiya-automation/tap/safari-mcp
75
+ ```
76
+
77
+ **Option C — from source:**
73
78
  ```bash
74
79
  git clone https://github.com/achiya-automation/safari-mcp.git
75
80
  cd safari-mcp
@@ -142,6 +147,29 @@ Add to your MCP client config:
142
147
 
143
148
  ---
144
149
 
150
+ ## Usage Workflow
151
+
152
+ The recommended pattern for AI agents using Safari MCP:
153
+
154
+ ```
155
+ 1. safari_snapshot → Get page state (accessibility tree)
156
+ 2. safari_click/fill/... → Interact with elements by ref
157
+ 3. safari_snapshot → Verify the result
158
+ ```
159
+
160
+ **Element targeting** — tools accept multiple targeting strategies:
161
+
162
+ | Strategy | Example | Best for |
163
+ |----------|---------|----------|
164
+ | CSS selector | `#login-btn`, `.submit` | Unique elements |
165
+ | Visible text | `"Sign In"`, `"Submit"` | Buttons, links |
166
+ | Coordinates | `x: 100, y: 200` | Canvas, custom widgets |
167
+ | Ref from snapshot | `ref: "e42"` | Any element from accessibility tree |
168
+
169
+ > **Tip:** Start with `safari_snapshot` to get element refs, then use refs for precise targeting. This is faster and more reliable than CSS selectors.
170
+
171
+ ---
172
+
145
173
  ## Tools (80)
146
174
 
147
175
  ### Navigation (4)
@@ -317,6 +345,20 @@ Add to your MCP client config:
317
345
 
318
346
  ---
319
347
 
348
+ ## Security
349
+
350
+ Safari MCP runs locally on your Mac with minimal attack surface:
351
+
352
+ | Aspect | Detail |
353
+ |--------|--------|
354
+ | Network | **No remote connections** — all communication is local (stdio + localhost) |
355
+ | Permissions | macOS system permissions required (Screen Recording for screenshots) |
356
+ | Data | No telemetry, no analytics, no data sent anywhere |
357
+ | Extension | Communicates only with `localhost:9224`, validated by Safari |
358
+ | Code | Fully open source (MIT) — audit every line |
359
+
360
+ ---
361
+
320
362
  ## Safari MCP vs Alternatives
321
363
 
322
364
  | Feature | Safari MCP | Chrome DevTools MCP | Playwright MCP |
@@ -335,6 +377,23 @@ Add to your MCP client config:
335
377
 
336
378
  > **Tip:** Use Safari MCP for daily browsing tasks (95% of work) and Chrome DevTools MCP only for Lighthouse/Performance audits.
337
379
 
380
+ ### Safari MCP Servers Comparison
381
+
382
+ | Feature | **safari-mcp** | MCPSafari | safari-mcp-server |
383
+ |---------|:--------------:|:---------:|:-----------------:|
384
+ | Tools | **80** | 23 | ~10 |
385
+ | License | **MIT** | None | MIT |
386
+ | Install | npm / Homebrew | Binary | npm |
387
+ | Storage (cookies, localStorage) | **10 tools** | None | None |
388
+ | Data extraction (tables, links) | **5 tools** | None | None |
389
+ | Network mocking | **Yes** | No | No |
390
+ | Device emulation | **Yes** | No | No |
391
+ | File upload (no dialog) | **Yes** | No | No |
392
+ | PDF export | **Yes** | No | No |
393
+ | Console capture | **4 tools** | 1 | No |
394
+ | Performance metrics | **Yes** | No | No |
395
+ | Fallback engine | **Dual (Extension + AppleScript)** | Extension only | WebDriver |
396
+
338
397
  ---
339
398
 
340
399
  ## Architecture
@@ -1825,7 +1825,13 @@ async function execInAllFrames(func, args = [], tabId = null) {
1825
1825
  }
1826
1826
  }
1827
1827
 
1828
- function waitForTabLoad(tabId, timeout = 30000) {
1828
+ async function waitForTabLoad(tabId, timeout = 30000) {
1829
+ // Check if already complete BEFORE registering listeners (prevents missing instant-complete events)
1830
+ try {
1831
+ const tab = await browser.tabs.get(tabId);
1832
+ if (tab.status === "complete") return;
1833
+ } catch { return; } // Tab already gone
1834
+
1829
1835
  return new Promise((resolve) => {
1830
1836
  function cleanup() {
1831
1837
  clearTimeout(timer);
package/index.js CHANGED
@@ -13,6 +13,9 @@ import { WebSocketServer } from "ws";
13
13
  import { createServer } from "node:http";
14
14
  import { randomUUID } from "node:crypto";
15
15
  import { execFileSync } from "node:child_process";
16
+ import { readFileSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
16
19
 
17
20
  // ========== SINGLETON: kill stale instances from previous sessions ==========
18
21
  // NOTE: Claude Code VSCode may start 2 instances simultaneously (~40ms apart).
@@ -66,11 +69,18 @@ function _untrackTab(tabIndex) {
66
69
  async function _cleanupTabs() {
67
70
  if (_openedTabs.size === 0) return;
68
71
  console.error(`[Safari MCP] Cleanup: closing ${_openedTabs.size} MCP-opened tabs`);
69
- const indices = [..._openedTabs.keys()].sort((a, b) => b - a);
70
- for (const idx of indices) {
72
+ // Close by URL (not index) — indices shift as tabs are closed
73
+ const urlsToClose = [..._openedTabs.values()].map(v => v.url).filter(Boolean);
74
+ for (const url of urlsToClose) {
71
75
  try {
72
- safari.setActiveTabIndex(idx);
73
- await safari.closeTab();
76
+ // Re-resolve index by URL before each close (indices shift after each closure)
77
+ const tabs = await safari.listTabs();
78
+ const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
79
+ const match = parsed.find(t => t.url === url);
80
+ if (match) {
81
+ safari.setActiveTabIndex(match.index);
82
+ await safari.closeTab();
83
+ }
74
84
  } catch {}
75
85
  }
76
86
  _openedTabs.clear();
@@ -286,8 +296,8 @@ try {
286
296
  try {
287
297
  const { nonce, expectedProfile } = JSON.parse(body);
288
298
  // Use AppleScript to find which window contains the nonce in a tab title
289
- const safeNonce = nonce.replace(/"/g, '\\"');
290
- const safeProfile = (expectedProfile || "").replace(/"/g, '\\"');
299
+ const safeNonce = String(nonce).replace(/[^0-9]/g, ''); // nonce is numeric only
300
+ const safeProfile = (expectedProfile || "").replace(/[^\p{L}\p{N}\s\-_]/gu, ''); // whitelist: letters, numbers, spaces, hyphens, underscores
291
301
  // Check via AppleScript — look for the nonce in the profile window
292
302
  const { execFile } = await import("node:child_process");
293
303
  const { promisify } = await import("node:util");
@@ -494,7 +504,7 @@ function sendToExtension(type, payload = {}, timeoutMs = 30000) {
494
504
  // Per-command timeouts — fast commands get short timeouts, nav/screenshot get longer ones
495
505
  const _commandTimeouts = {
496
506
  click: 10000, fill: 5000, read_page: 30000, get_source: 10000, evaluate: 30000,
497
- type_text: 5000, press_key: 5000, scroll: 3000, scroll_to: 3000, scroll_to_element: 3000,
507
+ type_text: 5000, press_key: 5000, scroll: 3000, scroll_to: 3000, scroll_to_element: 15000,
498
508
  hover: 5000, list_tabs: 5000, new_tab: 15000, close_tab: 5000, switch_tab: 30000,
499
509
  wait_for: 30000, navigate: 30000, navigate_and_read: 30000, go_back: 10000, go_forward: 10000,
500
510
  reload: 15000, screenshot: 15000, snapshot: 30000, click_and_read: 15000,
@@ -547,9 +557,11 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
547
557
  return result;
548
558
  }
549
559
 
560
+ // Read version from package.json to avoid hardcoded mismatch
561
+ const _pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')).version;
550
562
  const server = new McpServer({
551
563
  name: "safari-mcp",
552
- version: "1.0.0",
564
+ version: _pkgVersion,
553
565
  description: "Safari browser automation - lightweight, keeps logins",
554
566
  });
555
567
 
@@ -639,7 +651,7 @@ server.tool(
639
651
  const gen = safari.getNextSnapshotGen();
640
652
  const result = await extensionOrFallback(
641
653
  "snapshot", { selector: args.selector, gen },
642
- () => safari.takeSnapshot(args)
654
+ () => safari.takeSnapshot({ ...args, _gen: gen })
643
655
  );
644
656
  return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
645
657
  }
package/mcp-helpers.js CHANGED
@@ -67,7 +67,7 @@ if (window.__mcpVersion !== 5) {
67
67
  window.mcpIsVisible = function(el) {
68
68
  if (!el || el.nodeType !== 1 || !el.isConnected) return false;
69
69
  var cs = window.getComputedStyle(el);
70
- if (!cs || cs.display === 'none' || cs.visibility === 'hidden' || cs.visibility === 'collapse' || cs.pointerEvents === 'none' || cs.opacity === '0') return false;
70
+ if (!cs || cs.display === 'none' || cs.visibility === 'hidden' || cs.visibility === 'collapse' || parseFloat(cs.opacity) === 0) return false;
71
71
  var r = el.getBoundingClientRect();
72
72
  return r.width > 0 && r.height > 0;
73
73
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
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
@@ -19,6 +19,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
19
19
  // Persistent process — no subprocess spawn overhead (~5ms vs ~90ms)
20
20
  let _helperProc = null;
21
21
  const _helperQueue = []; // callbacks waiting for responses
22
+ let _helperConsecutiveTimeouts = 0; // Track consecutive timeouts — only kill after 3
22
23
 
23
24
  // Reject all pending callbacks when helper crashes
24
25
  function _drainHelperQueue(reason) {
@@ -336,9 +337,15 @@ function _osascriptFastHelper(script, timeout) {
336
337
  resolved = true;
337
338
  const idx = _helperQueue.indexOf(cb);
338
339
  if (idx >= 0) _helperQueue.splice(idx, 1);
339
- _helperProc?.kill();
340
- _helperProc = null;
341
- setTimeout(startHelper, 100);
340
+ _helperConsecutiveTimeouts++;
341
+ // Only kill the daemon after 3 consecutive timeouts (single slow script shouldn't kill concurrent ops)
342
+ if (_helperConsecutiveTimeouts >= 3) {
343
+ console.error(`[Safari MCP] safari-helper: ${_helperConsecutiveTimeouts} consecutive timeouts — killing daemon`);
344
+ _helperProc?.kill();
345
+ _helperProc = null;
346
+ _helperConsecutiveTimeouts = 0;
347
+ setTimeout(startHelper, 100);
348
+ }
342
349
  reject(new Error("safari-helper timeout"));
343
350
  }, timeout);
344
351
 
@@ -346,6 +353,7 @@ function _osascriptFastHelper(script, timeout) {
346
353
  if (resolved) return;
347
354
  resolved = true;
348
355
  clearTimeout(timer);
356
+ _helperConsecutiveTimeouts = 0; // Reset on success
349
357
  try {
350
358
  const parsed = JSON.parse(line);
351
359
  if (parsed.error) reject(new Error(parsed.error));
@@ -355,14 +363,21 @@ function _osascriptFastHelper(script, timeout) {
355
363
  }
356
364
  }
357
365
 
358
- _helperQueue.push(cb);
359
- if (!_helperProc || !_helperProc.stdin) {
360
- // Helper died between the check and write — reject immediately
361
- _helperQueue.pop(); // Remove the callback we just pushed
366
+ // Check process availability BEFORE pushing to queue (prevents dangling callbacks)
367
+ if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
368
+ clearTimeout(timer);
362
369
  reject(new Error("safari-helper not available"));
363
370
  return;
364
371
  }
365
- _helperProc.stdin.write(JSON.stringify({ script }) + "\n");
372
+ _helperQueue.push(cb);
373
+ try {
374
+ _helperProc.stdin.write(JSON.stringify({ script }) + "\n");
375
+ } catch (writeErr) {
376
+ const idx = _helperQueue.indexOf(cb);
377
+ if (idx >= 0) _helperQueue.splice(idx, 1);
378
+ clearTimeout(timer);
379
+ reject(new Error("safari-helper write failed: " + writeErr.message));
380
+ }
366
381
  });
367
382
  }
368
383
 
@@ -623,17 +638,21 @@ export async function navigate(url) {
623
638
 
624
639
  export async function goBack() {
625
640
  // Navigate back + smart wait: check immediately, then poll only if loading
626
- return runJS(
641
+ const result = await runJS(
627
642
  `(async function(){history.back();await new Promise(function(r){setTimeout(r,50)});if(document.readyState!=='complete'){for(var i=0;i<30;i++){await new Promise(function(r){setTimeout(r,150)});if(document.readyState==='complete')break;}}return JSON.stringify({title:document.title,url:location.href});})()`,
628
643
  { timeout: 10000 }
629
644
  );
645
+ try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
646
+ return result;
630
647
  }
631
648
 
632
649
  export async function goForward() {
633
- return runJS(
650
+ const result = await runJS(
634
651
  `(async function(){history.forward();await new Promise(function(r){setTimeout(r,50)});if(document.readyState!=='complete'){for(var i=0;i<30;i++){await new Promise(function(r){setTimeout(r,150)});if(document.readyState==='complete')break;}}return JSON.stringify({title:document.title,url:location.href});})()`,
635
652
  { timeout: 10000 }
636
653
  );
654
+ try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
655
+ return result;
637
656
  }
638
657
 
639
658
  export async function reload(hardReload = false) {
@@ -641,10 +660,12 @@ export async function reload(hardReload = false) {
641
660
  await runJS(hardReload ? "location.reload(true)" : "location.reload()");
642
661
  await new Promise((r) => setTimeout(r, 100)); // Brief wait for reload to start (was 500ms)
643
662
  // Poll readyState in a single call
644
- return runJS(
663
+ const result = await runJS(
645
664
  `(async function(){for(var i=0;i<30;i++){if(document.readyState==='complete')break;await new Promise(function(r){setTimeout(r,200)});}return JSON.stringify({title:document.title,url:location.href});})()`,
646
665
  { timeout: 10000 }
647
666
  );
667
+ try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
668
+ return result;
648
669
  }
649
670
 
650
671
  // ========== PAGE INFO ==========
@@ -1239,7 +1260,7 @@ export async function fill({ selector, value, ref }) {
1239
1260
 
1240
1261
  const fillResult = await runJS(
1241
1262
  `(function(){` +
1242
- `var el=document.querySelector('${selector.replace(/'/g, "\\'")}');` +
1263
+ `var el=document.querySelector('${selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}');` +
1243
1264
  `if(!el)return 'Element not found';` +
1244
1265
  `el.focus();el.click();` +
1245
1266
  `var sel=window.getSelection();if(sel.rangeCount){var r=document.createRange();r.selectNodeContents(el);r.collapse(false);sel.removeAllRanges();sel.addRange(r);}` +
@@ -1624,15 +1645,19 @@ export async function screenshot({ fullPage = false } = {}) {
1624
1645
  await osascript(
1625
1646
  `tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, ${Number(w)}, ${Math.min(Number(h) + 100, 5000)}}`
1626
1647
  );
1627
- await new Promise((r) => setTimeout(r, 500));
1628
- // Use do shell script to inherit osascript's Screen Recording permission
1629
- await osascript(
1630
- `do shell script "screencapture -l${windowId} -o -x '${tmpFile}'"`,
1631
- { timeout: 15000 }
1632
- );
1633
- await osascript(
1634
- `tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${bounds}}`
1635
- );
1648
+ try {
1649
+ await new Promise((r) => setTimeout(r, 500));
1650
+ // Use do shell script to inherit osascript's Screen Recording permission
1651
+ await osascript(
1652
+ `do shell script "screencapture -l${windowId} -o -x '${tmpFile}'"`,
1653
+ { timeout: 15000 }
1654
+ );
1655
+ } finally {
1656
+ // Always restore bounds — even if screencapture fails
1657
+ await osascript(
1658
+ `tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${bounds}}`
1659
+ ).catch(() => {});
1660
+ }
1636
1661
  } else {
1637
1662
  // Try direct execFile first (works if VS Code has Screen Recording permission)
1638
1663
  try {
@@ -1867,8 +1892,9 @@ export async function newTab(url = "") {
1867
1892
  else { await osascript('tell application "Safari" to make new document'); }
1868
1893
  }
1869
1894
  }
1895
+ // Get count atomically from the same tell block as tab creation (avoids TOCTOU if user opens tabs concurrently)
1870
1896
  const tabCount = await osascriptFast(`tell application "Safari" to return count of tabs of ${getTargetWindowRef()}`);
1871
- _activeTabIndex = Number(tabCount);
1897
+ _activeTabIndex = Number(tabCount); // New tab is always appended as last
1872
1898
  _activeTabURL = url || null;
1873
1899
  _lastResolveTime = Date.now();
1874
1900
  // Wait for page load if URL given
@@ -2058,11 +2084,11 @@ export async function handleDialog({ action = "accept", text }) {
2058
2084
  }
2059
2085
  if (action === "accept") {
2060
2086
  await runJS(
2061
- "window.__origConfirm=window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return true;};window.__origAlert=window.alert;window.alert=function(){window.alert=window.__origAlert;};"
2087
+ "window.__origConfirm=window.__origConfirm||window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return true;};window.__origAlert=window.__origAlert||window.alert;window.alert=function(){window.alert=window.__origAlert;};"
2062
2088
  );
2063
2089
  } else {
2064
2090
  await runJS(
2065
- "window.__origConfirm=window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return false;};"
2091
+ "window.__origConfirm=window.__origConfirm||window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return false;};"
2066
2092
  );
2067
2093
  }
2068
2094
  return `Dialog handler set: ${action}${text ? ' with "' + text + '"' : ""}`;
@@ -2106,19 +2132,23 @@ export async function getNetworkRequests({ limit = 50 } = {}) {
2106
2132
 
2107
2133
  export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, targetX, targetY }) {
2108
2134
  if (sourceSelector && targetSelector) {
2109
- const srcSel = sourceSelector.replace(/'/g, "\\'");
2110
- const tgtSel = targetSelector.replace(/'/g, "\\'");
2135
+ const srcSel = sourceSelector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2136
+ const tgtSel = targetSelector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2111
2137
  return runJS(
2112
2138
  `(function(){` +
2113
2139
  `var src=document.querySelector('${srcSel}');var tgt=document.querySelector('${tgtSel}');` +
2114
2140
  `if(!src)return 'Source not found: ${srcSel}';if(!tgt)return 'Target not found: ${tgtSel}';` +
2115
2141
  `var sr=src.getBoundingClientRect();var tr=tgt.getBoundingClientRect();` +
2116
2142
  `var sx=sr.x+sr.width/2,sy=sr.y+sr.height/2,tx=tr.x+tr.width/2,ty=tr.y+tr.height/2;` +
2143
+ `var dt=new DataTransfer();` +
2144
+ `src.dispatchEvent(new DragEvent('dragstart',{clientX:sx,clientY:sy,bubbles:true,cancelable:true,dataTransfer:dt}));` +
2117
2145
  `src.dispatchEvent(new MouseEvent('mousedown',{clientX:sx,clientY:sy,bubbles:true}));` +
2118
2146
  `src.dispatchEvent(new MouseEvent('mousemove',{clientX:sx,clientY:sy,bubbles:true}));` +
2147
+ `tgt.dispatchEvent(new DragEvent('dragover',{clientX:tx,clientY:ty,bubbles:true,cancelable:true,dataTransfer:dt}));` +
2119
2148
  `tgt.dispatchEvent(new MouseEvent('mousemove',{clientX:tx,clientY:ty,bubbles:true}));` +
2120
2149
  `tgt.dispatchEvent(new MouseEvent('mouseup',{clientX:tx,clientY:ty,bubbles:true}));` +
2121
- `tgt.dispatchEvent(new DragEvent('drop',{bubbles:true}));` +
2150
+ `tgt.dispatchEvent(new DragEvent('drop',{clientX:tx,clientY:ty,bubbles:true,cancelable:true,dataTransfer:dt}));` +
2151
+ `src.dispatchEvent(new DragEvent('dragend',{bubbles:true}));` +
2122
2152
  `return 'Dragged from '+src.tagName+' to '+tgt.tagName;})()`
2123
2153
  );
2124
2154
  }
@@ -2137,9 +2167,27 @@ export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, t
2137
2167
  throw new Error("drag requires sourceSelector+targetSelector or sourceX/Y+targetX/Y");
2138
2168
  }
2139
2169
 
2170
+ // ========== FILE PATH SAFETY ==========
2171
+ // Prevent reading sensitive system files via upload/paste tools
2172
+ function _validateFilePath(filePath) {
2173
+ const { resolve } = require("node:path");
2174
+ const resolved = resolve(filePath);
2175
+ if (resolved.includes('..')) throw new Error("Path traversal not allowed: " + filePath);
2176
+ const blocked = ['.ssh', '.gnupg', '.aws', '.config/gcloud', 'credentials', '.env', '.npmrc', '.netrc', 'id_rsa', 'id_ed25519', '.keychain'];
2177
+ const lower = resolved.toLowerCase();
2178
+ for (const b of blocked) {
2179
+ if (lower.includes(b)) throw new Error("Blocked: reading sensitive path " + filePath);
2180
+ }
2181
+ // Must be under /Users/ or /tmp/ or /var/folders/ (macOS temp)
2182
+ if (!resolved.startsWith('/Users/') && !resolved.startsWith('/tmp/') && !resolved.startsWith('/var/folders/') && !resolved.startsWith('/private/tmp/')) {
2183
+ throw new Error("File path must be under /Users/, /tmp/, or /var/folders/: " + filePath);
2184
+ }
2185
+ }
2186
+
2140
2187
  // ========== UPLOAD FILE ==========
2141
2188
 
2142
2189
  export async function uploadFile({ selector, filePath }) {
2190
+ _validateFilePath(filePath);
2143
2191
  // Read file in Node.js, send as base64 to Safari JS, create File + DataTransfer
2144
2192
  // NO file dialog, NO System Events, NO focus stealing
2145
2193
 
@@ -2257,6 +2305,7 @@ export async function uploadFile({ selector, filePath }) {
2257
2305
  // ========== PASTE IMAGE FROM FILE ==========
2258
2306
 
2259
2307
  export async function pasteImageFromFile({ filePath }) {
2308
+ _validateFilePath(filePath);
2260
2309
  // Paste image via JS ClipboardEvent — NO clipboard touch, NO System Events, NO focus steal
2261
2310
  const { extname } = await import("node:path");
2262
2311
  const ext = extname(filePath).toLowerCase().replace(".", "");
@@ -2357,9 +2406,9 @@ export async function emulate({ device, width, height, userAgent, scale = 1 }) {
2357
2406
 
2358
2407
  export async function resetEmulation() {
2359
2408
  await refreshTargetWindow();
2360
- // Reset user agent
2409
+ // Reset user agent — remove the defineProperty override set by emulate()
2361
2410
  await runJS(
2362
- "delete Object.getOwnPropertyDescriptor(Navigator.prototype,'userAgent')||true"
2411
+ "try{var d=Object.getOwnPropertyDescriptor(Navigator.prototype,'userAgent');if(d){Object.defineProperty(navigator,'userAgent',d);}else{delete navigator.userAgent;}}catch(_){}"
2363
2412
  );
2364
2413
  // Maximize window
2365
2414
  await osascript(
@@ -2445,10 +2494,14 @@ export async function savePDF({ path: pdfPath }) {
2445
2494
  // instead of guessing CSS selectors. Much faster, no hallucination risk.
2446
2495
 
2447
2496
  let _snapshotGen = 0;
2497
+ // getNextSnapshotGen is used by the MCP tool path (index.js) to reserve a gen for the extension.
2498
+ // If the extension fails and falls back to takeSnapshot(), takeSnapshot uses _snapshotGen directly
2499
+ // (which was already incremented by getNextSnapshotGen) — so no double-increment occurs.
2448
2500
  export function getNextSnapshotGen() { return _snapshotGen++; }
2449
2501
 
2450
- export async function takeSnapshot({ selector } = {}) {
2451
- const gen = _snapshotGen++;
2502
+ export async function takeSnapshot({ selector, _gen } = {}) {
2503
+ // Use provided gen (from tool path) or allocate a new one (direct call)
2504
+ const gen = _gen != null ? _gen : _snapshotGen++;
2452
2505
  const root = selector ? `document.querySelector('${selector.replace(/'/g, "\\'")}')` : "document.body";
2453
2506
 
2454
2507
  const result = await runJS(
@@ -2817,7 +2870,12 @@ export async function importStorageState({ state }) {
2817
2870
  cmds.push(`sessionStorage.setItem('${esc(k)}','${esc(v)}')`);
2818
2871
  }
2819
2872
  }
2820
- return runJS(cmds.join(";") + "; 'Imported ' + " + cmds.length + " + ' items'");
2873
+ // Use runJSLarge for large sessions (many cookies/localStorage keys can exceed 260KB limit of runJS)
2874
+ const script = cmds.join(";") + "; 'Imported ' + " + cmds.length + " + ' items'";
2875
+ if (script.length > 200000) {
2876
+ return runJSLarge(script, { timeout: 30000 });
2877
+ }
2878
+ return runJS(script);
2821
2879
  }
2822
2880
 
2823
2881
  // ========== CLIPBOARD ==========
@@ -2939,8 +2997,9 @@ export async function clearNetworkMocks() {
2939
2997
  // ========== WAIT FOR TIME ==========
2940
2998
 
2941
2999
  export async function waitForTime({ ms }) {
2942
- await new Promise((r) => setTimeout(r, Number(ms)));
2943
- return `Waited ${ms}ms`;
3000
+ const capped = Math.min(Number(ms) || 0, 60000); // Cap at 60 seconds
3001
+ await new Promise((r) => setTimeout(r, capped));
3002
+ return capped < Number(ms) ? `Waited ${capped}ms (capped from ${ms}ms — max 60s)` : `Waited ${ms}ms`;
2944
3003
  }
2945
3004
 
2946
3005
  // ========== NETWORK CAPTURE (Detailed) ==========