safari-mcp 2.2.0 → 2.3.1

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
@@ -1647,16 +1647,20 @@ async function _verifyProfileMatch(expectedProfile) {
1647
1647
  await new Promise(r => setTimeout(r, 500));
1648
1648
 
1649
1649
  // Ask the server to verify which profile has this nonce
1650
- const verifyRes = await fetch(`${HTTP_URL}/verify-profile`, {
1651
- method: "POST",
1652
- headers: { "Content-Type": "application/json" },
1653
- body: JSON.stringify({ nonce, expectedProfile }),
1654
- signal: AbortSignal.timeout(5000),
1655
- });
1656
- // Clean up test tab
1657
- await browser.tabs.remove(checkTab.id).catch(() => {});
1650
+ let verifyRes;
1651
+ try {
1652
+ verifyRes = await fetch(`${HTTP_URL}/verify-profile`, {
1653
+ method: "POST",
1654
+ headers: { "Content-Type": "application/json" },
1655
+ body: JSON.stringify({ nonce, expectedProfile }),
1656
+ signal: AbortSignal.timeout(5000),
1657
+ });
1658
+ } finally {
1659
+ // Always clean up test tab — even if fetch fails
1660
+ await browser.tabs.remove(checkTab.id).catch(() => {});
1661
+ }
1658
1662
 
1659
- if (verifyRes.ok) {
1663
+ if (verifyRes && verifyRes.ok) {
1660
1664
  const result = await verifyRes.json();
1661
1665
  if (result.match) {
1662
1666
  await browser.storage.local.set({ mcpVerifiedProfile: expectedProfile });
@@ -1825,7 +1829,13 @@ async function execInAllFrames(func, args = [], tabId = null) {
1825
1829
  }
1826
1830
  }
1827
1831
 
1828
- function waitForTabLoad(tabId, timeout = 30000) {
1832
+ async function waitForTabLoad(tabId, timeout = 30000) {
1833
+ // Check if already complete BEFORE registering listeners (prevents missing instant-complete events)
1834
+ try {
1835
+ const tab = await browser.tabs.get(tabId);
1836
+ if (tab.status === "complete") return;
1837
+ } catch { return; } // Tab already gone
1838
+
1829
1839
  return new Promise((resolve) => {
1830
1840
  function cleanup() {
1831
1841
  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();
@@ -84,7 +94,7 @@ function _startMemoryMonitor() {
84
94
  // Use pgrep + ps directly — avoids spawning bash, grep, awk pipeline
85
95
  let psOut = "0";
86
96
  try {
87
- const pids = execFileSync("pgrep", ["-f", "WebKit|WebContent"], { encoding: "utf8" }).trim();
97
+ const pids = execFileSync("pgrep", ["-Ef", "WebKit|WebContent"], { encoding: "utf8" }).trim();
88
98
  if (pids) {
89
99
  const pidList = pids.split("\n").join(",");
90
100
  psOut = execFileSync("ps", ["-p", pidList, "-o", "rss="], { encoding: "utf8" }).trim();
@@ -101,8 +111,17 @@ function _startMemoryMonitor() {
101
111
  }
102
112
  if (oldestIdx !== null) {
103
113
  try {
104
- safari.setActiveTabIndex(oldestIdx);
105
- await safari.closeTab();
114
+ // Re-resolve by URL before closing (indices shift as tabs change)
115
+ const info = _openedTabs.get(oldestIdx);
116
+ if (info?.url) {
117
+ const tabs = await safari.listTabs();
118
+ const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
119
+ const match = parsed.find(t => t.url === info.url);
120
+ if (match) {
121
+ safari.setActiveTabIndex(match.index);
122
+ await safari.closeTab();
123
+ }
124
+ }
106
125
  } catch {}
107
126
  _untrackTab(oldestIdx);
108
127
  }
@@ -238,9 +257,10 @@ try {
238
257
  }
239
258
 
240
259
  // POST /result — extension sends command result
260
+ const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB cap — prevents DoS from large payloads
241
261
  if (req.method === "POST" && req.url === "/result") {
242
262
  let body = "";
243
- req.on("data", (chunk) => (body += chunk));
263
+ req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
244
264
  req.on("end", () => {
245
265
  try {
246
266
  const msg = JSON.parse(body);
@@ -281,13 +301,13 @@ try {
281
301
  // POST /verify-profile — extension asks server to check which profile has a nonce tab
282
302
  if (req.method === "POST" && req.url === "/verify-profile") {
283
303
  let body = "";
284
- req.on("data", (chunk) => (body += chunk));
304
+ req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
285
305
  req.on("end", async () => {
286
306
  try {
287
307
  const { nonce, expectedProfile } = JSON.parse(body);
288
308
  // 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, '\\"');
309
+ const safeNonce = String(nonce).replace(/[^0-9]/g, ''); // nonce is numeric only
310
+ const safeProfile = (expectedProfile || "").replace(/[^\p{L}\p{N}\s\-_]/gu, ''); // whitelist: letters, numbers, spaces, hyphens, underscores
291
311
  // Check via AppleScript — look for the nonce in the profile window
292
312
  const { execFile } = await import("node:child_process");
293
313
  const { promisify } = await import("node:util");
@@ -333,7 +353,7 @@ try {
333
353
  // POST /proxy-command — secondary instances send commands through primary
334
354
  if (req.method === "POST" && req.url === "/proxy-command") {
335
355
  let body = "";
336
- req.on("data", (chunk) => (body += chunk));
356
+ req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
337
357
  req.on("end", async () => {
338
358
  try {
339
359
  const { type, payload } = JSON.parse(body);
@@ -381,6 +401,7 @@ setTimeout(() => {
381
401
  }, 2000);
382
402
 
383
403
  async function _checkPrimaryExtension() {
404
+ if (_isExtensionHost) return; // Already hosting — stop polling
384
405
  try {
385
406
  const res = await fetch(`http://127.0.0.1:${HTTP_PORT}/proxy-check`, {
386
407
  method: "GET",
@@ -494,7 +515,7 @@ function sendToExtension(type, payload = {}, timeoutMs = 30000) {
494
515
  // Per-command timeouts — fast commands get short timeouts, nav/screenshot get longer ones
495
516
  const _commandTimeouts = {
496
517
  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,
518
+ type_text: 5000, press_key: 5000, scroll: 3000, scroll_to: 3000, scroll_to_element: 15000,
498
519
  hover: 5000, list_tabs: 5000, new_tab: 15000, close_tab: 5000, switch_tab: 30000,
499
520
  wait_for: 30000, navigate: 30000, navigate_and_read: 30000, go_back: 10000, go_forward: 10000,
500
521
  reload: 15000, screenshot: 15000, snapshot: 30000, click_and_read: 15000,
@@ -547,9 +568,11 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
547
568
  return result;
548
569
  }
549
570
 
571
+ // Read version from package.json to avoid hardcoded mismatch
572
+ const _pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')).version;
550
573
  const server = new McpServer({
551
574
  name: "safari-mcp",
552
- version: "1.0.0",
575
+ version: _pkgVersion,
553
576
  description: "Safari browser automation - lightweight, keeps logins",
554
577
  });
555
578
 
@@ -639,7 +662,7 @@ server.tool(
639
662
  const gen = safari.getNextSnapshotGen();
640
663
  const result = await extensionOrFallback(
641
664
  "snapshot", { selector: args.selector, gen },
642
- () => safari.takeSnapshot(args)
665
+ () => safari.takeSnapshot({ ...args, _gen: gen })
643
666
  );
644
667
  return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
645
668
  }
@@ -1114,12 +1137,16 @@ server.tool(
1114
1137
  if (resolved && resolved.url !== 'about:blank') {
1115
1138
  if (urlContains && !resolved.url.includes(urlContains)) continue;
1116
1139
  await extensionOrFallback("switch_tab", { index: resolved.index }, () => safari.switchTab(resolved.index));
1140
+ safari.setActiveTabIndex(resolved.index);
1141
+ safari.setActiveTabURL(resolved.url);
1117
1142
  return { content: [{ type: "text", text: `Found new tab: ${resolved.title} (${resolved.url})` }] };
1118
1143
  }
1119
1144
  continue;
1120
1145
  }
1121
1146
  if (urlContains && !tab.url.includes(urlContains)) continue;
1122
1147
  await extensionOrFallback("switch_tab", { index: tab.index }, () => safari.switchTab(tab.index));
1148
+ safari.setActiveTabIndex(tab.index);
1149
+ safari.setActiveTabURL(tab.url);
1123
1150
  return { content: [{ type: "text", text: `Found new tab: ${tab.title} (${tab.url})` }] };
1124
1151
  }
1125
1152
  }
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.1",
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
@@ -6,7 +6,7 @@
6
6
  import { execFile, spawn } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { tmpdir } from "node:os";
9
- import { join, dirname } from "node:path";
9
+ import { join, dirname, resolve as resolvePath } from "node:path";
10
10
  import { readFile, writeFile, unlink, appendFile } from "node:fs/promises";
11
11
  import { readFileSync } from "node:fs";
12
12
  import { fileURLToPath } from "node:url";
@@ -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) {
@@ -83,11 +84,9 @@ function cleanupHelper() {
83
84
  _helperProc = null;
84
85
  }
85
86
  }
87
+ // Signal handlers (SIGINT/SIGTERM/SIGHUP) are registered in index.js only.
88
+ // cleanupHelper runs via process.on("exit"), which fires when index.js calls process.exit().
86
89
  process.on("exit", cleanupHelper);
87
- process.on("SIGINT", () => { cleanupHelper(); process.exit(0); });
88
- process.on("SIGTERM", () => { cleanupHelper(); process.exit(0); });
89
- // SIGHUP = terminal closed, uncaught = crash — still clean up
90
- process.on("SIGHUP", () => { cleanupHelper(); process.exit(0); });
91
90
  process.on("uncaughtException", (err) => { console.error("Uncaught:", err); cleanupHelper(); process.exit(1); });
92
91
 
93
92
  // ========== ACTIVE TAB TRACKING ==========
@@ -336,9 +335,15 @@ function _osascriptFastHelper(script, timeout) {
336
335
  resolved = true;
337
336
  const idx = _helperQueue.indexOf(cb);
338
337
  if (idx >= 0) _helperQueue.splice(idx, 1);
339
- _helperProc?.kill();
340
- _helperProc = null;
341
- setTimeout(startHelper, 100);
338
+ _helperConsecutiveTimeouts++;
339
+ // Only kill the daemon after 3 consecutive timeouts (single slow script shouldn't kill concurrent ops)
340
+ if (_helperConsecutiveTimeouts >= 3) {
341
+ console.error(`[Safari MCP] safari-helper: ${_helperConsecutiveTimeouts} consecutive timeouts — killing daemon`);
342
+ _helperProc?.kill();
343
+ _helperProc = null;
344
+ _helperConsecutiveTimeouts = 0;
345
+ setTimeout(startHelper, 100);
346
+ }
342
347
  reject(new Error("safari-helper timeout"));
343
348
  }, timeout);
344
349
 
@@ -346,6 +351,7 @@ function _osascriptFastHelper(script, timeout) {
346
351
  if (resolved) return;
347
352
  resolved = true;
348
353
  clearTimeout(timer);
354
+ _helperConsecutiveTimeouts = 0; // Reset on success
349
355
  try {
350
356
  const parsed = JSON.parse(line);
351
357
  if (parsed.error) reject(new Error(parsed.error));
@@ -355,14 +361,21 @@ function _osascriptFastHelper(script, timeout) {
355
361
  }
356
362
  }
357
363
 
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
364
+ // Check process availability BEFORE pushing to queue (prevents dangling callbacks)
365
+ if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
366
+ clearTimeout(timer);
362
367
  reject(new Error("safari-helper not available"));
363
368
  return;
364
369
  }
365
- _helperProc.stdin.write(JSON.stringify({ script }) + "\n");
370
+ _helperQueue.push(cb);
371
+ try {
372
+ _helperProc.stdin.write(JSON.stringify({ script }) + "\n");
373
+ } catch (writeErr) {
374
+ const idx = _helperQueue.indexOf(cb);
375
+ if (idx >= 0) _helperQueue.splice(idx, 1);
376
+ clearTimeout(timer);
377
+ reject(new Error("safari-helper write failed: " + writeErr.message));
378
+ }
366
379
  });
367
380
  }
368
381
 
@@ -623,17 +636,21 @@ export async function navigate(url) {
623
636
 
624
637
  export async function goBack() {
625
638
  // Navigate back + smart wait: check immediately, then poll only if loading
626
- return runJS(
639
+ const result = await runJS(
627
640
  `(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
641
  { timeout: 10000 }
629
642
  );
643
+ try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
644
+ return result;
630
645
  }
631
646
 
632
647
  export async function goForward() {
633
- return runJS(
648
+ const result = await runJS(
634
649
  `(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
650
  { timeout: 10000 }
636
651
  );
652
+ try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
653
+ return result;
637
654
  }
638
655
 
639
656
  export async function reload(hardReload = false) {
@@ -641,10 +658,12 @@ export async function reload(hardReload = false) {
641
658
  await runJS(hardReload ? "location.reload(true)" : "location.reload()");
642
659
  await new Promise((r) => setTimeout(r, 100)); // Brief wait for reload to start (was 500ms)
643
660
  // Poll readyState in a single call
644
- return runJS(
661
+ const result = await runJS(
645
662
  `(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
663
  { timeout: 10000 }
647
664
  );
665
+ try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
666
+ return result;
648
667
  }
649
668
 
650
669
  // ========== PAGE INFO ==========
@@ -1239,7 +1258,7 @@ export async function fill({ selector, value, ref }) {
1239
1258
 
1240
1259
  const fillResult = await runJS(
1241
1260
  `(function(){` +
1242
- `var el=document.querySelector('${selector.replace(/'/g, "\\'")}');` +
1261
+ `var el=document.querySelector('${selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}');` +
1243
1262
  `if(!el)return 'Element not found';` +
1244
1263
  `el.focus();el.click();` +
1245
1264
  `var sel=window.getSelection();if(sel.rangeCount){var r=document.createRange();r.selectNodeContents(el);r.collapse(false);sel.removeAllRanges();sel.addRange(r);}` +
@@ -1624,15 +1643,19 @@ export async function screenshot({ fullPage = false } = {}) {
1624
1643
  await osascript(
1625
1644
  `tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, ${Number(w)}, ${Math.min(Number(h) + 100, 5000)}}`
1626
1645
  );
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
- );
1646
+ try {
1647
+ await new Promise((r) => setTimeout(r, 500));
1648
+ // Use do shell script to inherit osascript's Screen Recording permission
1649
+ await osascript(
1650
+ `do shell script "screencapture -l${windowId} -o -x '${tmpFile}'"`,
1651
+ { timeout: 15000 }
1652
+ );
1653
+ } finally {
1654
+ // Always restore bounds — even if screencapture fails
1655
+ await osascript(
1656
+ `tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${bounds}}`
1657
+ ).catch(() => {});
1658
+ }
1636
1659
  } else {
1637
1660
  // Try direct execFile first (works if VS Code has Screen Recording permission)
1638
1661
  try {
@@ -1867,8 +1890,9 @@ export async function newTab(url = "") {
1867
1890
  else { await osascript('tell application "Safari" to make new document'); }
1868
1891
  }
1869
1892
  }
1893
+ // Get count atomically from the same tell block as tab creation (avoids TOCTOU if user opens tabs concurrently)
1870
1894
  const tabCount = await osascriptFast(`tell application "Safari" to return count of tabs of ${getTargetWindowRef()}`);
1871
- _activeTabIndex = Number(tabCount);
1895
+ _activeTabIndex = Number(tabCount); // New tab is always appended as last
1872
1896
  _activeTabURL = url || null;
1873
1897
  _lastResolveTime = Date.now();
1874
1898
  // Wait for page load if URL given
@@ -2058,11 +2082,11 @@ export async function handleDialog({ action = "accept", text }) {
2058
2082
  }
2059
2083
  if (action === "accept") {
2060
2084
  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;};"
2085
+ "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
2086
  );
2063
2087
  } else {
2064
2088
  await runJS(
2065
- "window.__origConfirm=window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return false;};"
2089
+ "window.__origConfirm=window.__origConfirm||window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return false;};"
2066
2090
  );
2067
2091
  }
2068
2092
  return `Dialog handler set: ${action}${text ? ' with "' + text + '"' : ""}`;
@@ -2106,19 +2130,23 @@ export async function getNetworkRequests({ limit = 50 } = {}) {
2106
2130
 
2107
2131
  export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, targetX, targetY }) {
2108
2132
  if (sourceSelector && targetSelector) {
2109
- const srcSel = sourceSelector.replace(/'/g, "\\'");
2110
- const tgtSel = targetSelector.replace(/'/g, "\\'");
2133
+ const srcSel = sourceSelector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2134
+ const tgtSel = targetSelector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2111
2135
  return runJS(
2112
2136
  `(function(){` +
2113
2137
  `var src=document.querySelector('${srcSel}');var tgt=document.querySelector('${tgtSel}');` +
2114
2138
  `if(!src)return 'Source not found: ${srcSel}';if(!tgt)return 'Target not found: ${tgtSel}';` +
2115
2139
  `var sr=src.getBoundingClientRect();var tr=tgt.getBoundingClientRect();` +
2116
2140
  `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;` +
2141
+ `var dt=new DataTransfer();` +
2142
+ `src.dispatchEvent(new DragEvent('dragstart',{clientX:sx,clientY:sy,bubbles:true,cancelable:true,dataTransfer:dt}));` +
2117
2143
  `src.dispatchEvent(new MouseEvent('mousedown',{clientX:sx,clientY:sy,bubbles:true}));` +
2118
2144
  `src.dispatchEvent(new MouseEvent('mousemove',{clientX:sx,clientY:sy,bubbles:true}));` +
2145
+ `tgt.dispatchEvent(new DragEvent('dragover',{clientX:tx,clientY:ty,bubbles:true,cancelable:true,dataTransfer:dt}));` +
2119
2146
  `tgt.dispatchEvent(new MouseEvent('mousemove',{clientX:tx,clientY:ty,bubbles:true}));` +
2120
2147
  `tgt.dispatchEvent(new MouseEvent('mouseup',{clientX:tx,clientY:ty,bubbles:true}));` +
2121
- `tgt.dispatchEvent(new DragEvent('drop',{bubbles:true}));` +
2148
+ `tgt.dispatchEvent(new DragEvent('drop',{clientX:tx,clientY:ty,bubbles:true,cancelable:true,dataTransfer:dt}));` +
2149
+ `src.dispatchEvent(new DragEvent('dragend',{bubbles:true}));` +
2122
2150
  `return 'Dragged from '+src.tagName+' to '+tgt.tagName;})()`
2123
2151
  );
2124
2152
  }
@@ -2137,9 +2165,26 @@ export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, t
2137
2165
  throw new Error("drag requires sourceSelector+targetSelector or sourceX/Y+targetX/Y");
2138
2166
  }
2139
2167
 
2168
+ // ========== FILE PATH SAFETY ==========
2169
+ // Prevent reading sensitive system files via upload/paste tools
2170
+ function _validateFilePath(filePath) {
2171
+ const resolved = resolvePath(filePath);
2172
+ if (resolved.includes('..')) throw new Error("Path traversal not allowed: " + filePath);
2173
+ const blocked = ['.ssh', '.gnupg', '.aws', '.config/gcloud', 'credentials', '.env', '.npmrc', '.netrc', 'id_rsa', 'id_ed25519', '.keychain'];
2174
+ const lower = resolved.toLowerCase();
2175
+ for (const b of blocked) {
2176
+ if (lower.includes(b)) throw new Error("Blocked: reading sensitive path " + filePath);
2177
+ }
2178
+ // Must be under /Users/ or /tmp/ or /var/folders/ (macOS temp)
2179
+ if (!resolved.startsWith('/Users/') && !resolved.startsWith('/tmp/') && !resolved.startsWith('/var/folders/') && !resolved.startsWith('/private/tmp/')) {
2180
+ throw new Error("File path must be under /Users/, /tmp/, or /var/folders/: " + filePath);
2181
+ }
2182
+ }
2183
+
2140
2184
  // ========== UPLOAD FILE ==========
2141
2185
 
2142
2186
  export async function uploadFile({ selector, filePath }) {
2187
+ _validateFilePath(filePath);
2143
2188
  // Read file in Node.js, send as base64 to Safari JS, create File + DataTransfer
2144
2189
  // NO file dialog, NO System Events, NO focus stealing
2145
2190
 
@@ -2150,10 +2195,10 @@ export async function uploadFile({ selector, filePath }) {
2150
2195
  repeat with w in every window
2151
2196
  if exists sheet 1 of w then
2152
2197
  try
2153
- click button "ביטול" of sheet 1 of w
2198
+ click button "Cancel" of sheet 1 of w
2154
2199
  on error
2155
2200
  try
2156
- click button "Cancel" of sheet 1 of w
2201
+ click button "ביטול" of sheet 1 of w
2157
2202
  on error
2158
2203
  key code 53
2159
2204
  end try
@@ -2257,6 +2302,7 @@ export async function uploadFile({ selector, filePath }) {
2257
2302
  // ========== PASTE IMAGE FROM FILE ==========
2258
2303
 
2259
2304
  export async function pasteImageFromFile({ filePath }) {
2305
+ _validateFilePath(filePath);
2260
2306
  // Paste image via JS ClipboardEvent — NO clipboard touch, NO System Events, NO focus steal
2261
2307
  const { extname } = await import("node:path");
2262
2308
  const ext = extname(filePath).toLowerCase().replace(".", "");
@@ -2357,9 +2403,9 @@ export async function emulate({ device, width, height, userAgent, scale = 1 }) {
2357
2403
 
2358
2404
  export async function resetEmulation() {
2359
2405
  await refreshTargetWindow();
2360
- // Reset user agent
2406
+ // Reset user agent — remove the defineProperty override set by emulate()
2361
2407
  await runJS(
2362
- "delete Object.getOwnPropertyDescriptor(Navigator.prototype,'userAgent')||true"
2408
+ "try{var d=Object.getOwnPropertyDescriptor(Navigator.prototype,'userAgent');if(d){Object.defineProperty(navigator,'userAgent',d);}else{delete navigator.userAgent;}}catch(_){}"
2363
2409
  );
2364
2410
  // Maximize window
2365
2411
  await osascript(
@@ -2445,10 +2491,14 @@ export async function savePDF({ path: pdfPath }) {
2445
2491
  // instead of guessing CSS selectors. Much faster, no hallucination risk.
2446
2492
 
2447
2493
  let _snapshotGen = 0;
2494
+ // getNextSnapshotGen is used by the MCP tool path (index.js) to reserve a gen for the extension.
2495
+ // If the extension fails and falls back to takeSnapshot(), takeSnapshot uses _snapshotGen directly
2496
+ // (which was already incremented by getNextSnapshotGen) — so no double-increment occurs.
2448
2497
  export function getNextSnapshotGen() { return _snapshotGen++; }
2449
2498
 
2450
- export async function takeSnapshot({ selector } = {}) {
2451
- const gen = _snapshotGen++;
2499
+ export async function takeSnapshot({ selector, _gen } = {}) {
2500
+ // Use provided gen (from tool path) or allocate a new one (direct call)
2501
+ const gen = _gen != null ? _gen : _snapshotGen++;
2452
2502
  const root = selector ? `document.querySelector('${selector.replace(/'/g, "\\'")}')` : "document.body";
2453
2503
 
2454
2504
  const result = await runJS(
@@ -2606,6 +2656,7 @@ export async function takeSnapshot({ selector } = {}) {
2606
2656
  }
2607
2657
 
2608
2658
  window.__mcpRefs = {};
2659
+ window.__mcpRefsTime = Date.now();
2609
2660
  var root = ${root};
2610
2661
  if (!root) return 'Element not found';
2611
2662
  walk(root, 0);
@@ -2817,7 +2868,12 @@ export async function importStorageState({ state }) {
2817
2868
  cmds.push(`sessionStorage.setItem('${esc(k)}','${esc(v)}')`);
2818
2869
  }
2819
2870
  }
2820
- return runJS(cmds.join(";") + "; 'Imported ' + " + cmds.length + " + ' items'");
2871
+ // Use runJSLarge for large sessions (many cookies/localStorage keys can exceed 260KB limit of runJS)
2872
+ const script = cmds.join(";") + "; 'Imported ' + " + cmds.length + " + ' items'";
2873
+ if (script.length > 200000) {
2874
+ return runJSLarge(script, { timeout: 30000 });
2875
+ }
2876
+ return runJS(script);
2821
2877
  }
2822
2878
 
2823
2879
  // ========== CLIPBOARD ==========
@@ -2939,8 +2995,9 @@ export async function clearNetworkMocks() {
2939
2995
  // ========== WAIT FOR TIME ==========
2940
2996
 
2941
2997
  export async function waitForTime({ ms }) {
2942
- await new Promise((r) => setTimeout(r, Number(ms)));
2943
- return `Waited ${ms}ms`;
2998
+ const capped = Math.min(Number(ms) || 0, 60000); // Cap at 60 seconds
2999
+ await new Promise((r) => setTimeout(r, capped));
3000
+ return capped < Number(ms) ? `Waited ${capped}ms (capped from ${ms}ms — max 60s)` : `Waited ${ms}ms`;
2944
3001
  }
2945
3002
 
2946
3003
  // ========== NETWORK CAPTURE (Detailed) ==========