safari-mcp 2.1.8 → 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
@@ -957,7 +957,9 @@ async function handleCommand(type, payload) {
957
957
  // Learn profile window from newly created tab
958
958
  if (!_profileWindowId) { _profileWindowId = updated.windowId; browser.storage.local.set({ mcpProfileWindowId: _profileWindowId }).catch(() => {}); }
959
959
  // CRITICAL: Set new tab as the target for this session's subsequent commands
960
- _setSessionTab(sessionId, updated.id, updated.url);
960
+ // Use the requested URL (not updated.url) when page hasn't loaded yet (still about:blank)
961
+ const trackUrl = (updated.url && updated.url !== "about:blank") ? updated.url : (payload.url || updated.url);
962
+ _setSessionTab(sessionId, updated.id, trackUrl);
961
963
  return { title: updated.title, url: updated.url, tabIndex: updated.index + 1 };
962
964
  }
963
965
 
@@ -1049,7 +1051,7 @@ async function handleCommand(type, payload) {
1049
1051
 
1050
1052
  // --- Snapshot (accessibility tree with ref IDs) ---
1051
1053
  case "snapshot": {
1052
- return await execInTab((rootSelector) => {
1054
+ return await execInTab((rootSelector, snapshotGen) => {
1053
1055
  // Clean ALL stale data-mcp-ref attributes from previous snapshots.
1054
1056
  // Without this, old refs remain on DOM and findByRef/CSS selector can target WRONG elements.
1055
1057
  document.querySelectorAll("[data-mcp-ref]").forEach(function(el) { el.removeAttribute("data-mcp-ref"); });
@@ -1099,7 +1101,7 @@ async function handleCommand(type, payload) {
1099
1101
 
1100
1102
  const interactive = isInteractive(el);
1101
1103
  const currentId = id++;
1102
- const refId = "0_" + currentId;
1104
+ const refId = snapshotGen + "_" + currentId;
1103
1105
 
1104
1106
  let attrs = "";
1105
1107
  if (interactive) {
@@ -1209,7 +1211,7 @@ async function handleCommand(type, payload) {
1209
1211
  tree += "\n[WARNING: Snapshot truncated at " + MAX_ELEMENTS + " elements. Use selector parameter to focus on a specific section.]";
1210
1212
  }
1211
1213
  return tree;
1212
- }, [payload.selector || null], tabId);
1214
+ }, [payload.selector || null, payload.gen != null ? payload.gen : 0], tabId);
1213
1215
  }
1214
1216
 
1215
1217
  // --- Double Click ---
@@ -1768,15 +1770,26 @@ async function getActiveTab() {
1768
1770
  return tabs[0];
1769
1771
  }
1770
1772
 
1773
+ // Track which tabs already have the deep query helpers injected
1774
+ const _helpersInjected = new Set();
1775
+ // Clean up when tabs are removed or navigated
1776
+ browser.tabs.onRemoved.addListener((tabId) => { _helpersInjected.delete(tabId); });
1777
+ browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
1778
+ if (changeInfo.status === "loading") _helpersInjected.delete(tabId);
1779
+ });
1780
+
1771
1781
  async function execInTab(func, args = [], tabId = null) {
1772
1782
  const id = tabId || (await getActiveTab()).id;
1773
1783
  try {
1774
- // Auto-inject deep query helpers (idempotent — only defines once per page)
1775
- await browser.scripting.executeScript({
1776
- target: { tabId: id },
1777
- world: "MAIN",
1778
- func: _deepQueryScript,
1779
- }).catch(() => {});
1784
+ // Auto-inject deep query helpers — skip if already injected for this tab+page
1785
+ if (!_helpersInjected.has(id)) {
1786
+ await browser.scripting.executeScript({
1787
+ target: { tabId: id },
1788
+ world: "MAIN",
1789
+ func: _deepQueryScript,
1790
+ }).catch(() => {});
1791
+ _helpersInjected.add(id);
1792
+ }
1780
1793
 
1781
1794
  const results = await browser.scripting.executeScript({
1782
1795
  target: { tabId: id },
@@ -1812,7 +1825,13 @@ async function execInAllFrames(func, args = [], tabId = null) {
1812
1825
  }
1813
1826
  }
1814
1827
 
1815
- 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
+
1816
1835
  return new Promise((resolve) => {
1817
1836
  function cleanup() {
1818
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();
@@ -81,9 +91,17 @@ let _memoryCheckTimer = null;
81
91
  function _startMemoryMonitor() {
82
92
  _memoryCheckTimer = setInterval(async () => {
83
93
  try {
84
- const psOut = execFileSync("bash", ["-c",
85
- "ps aux | grep -i 'WebKit\\|WebContent' | grep -v grep | awk '{sum+=$6} END {print sum/1024}'"
86
- ], { encoding: "utf8" }).trim();
94
+ // Use pgrep + ps directly — avoids spawning bash, grep, awk pipeline
95
+ let psOut = "0";
96
+ try {
97
+ const pids = execFileSync("pgrep", ["-f", "WebKit|WebContent"], { encoding: "utf8" }).trim();
98
+ if (pids) {
99
+ const pidList = pids.split("\n").join(",");
100
+ psOut = execFileSync("ps", ["-p", pidList, "-o", "rss="], { encoding: "utf8" }).trim();
101
+ const totalKB = psOut.split("\n").reduce((sum, l) => sum + (parseInt(l.trim(), 10) || 0), 0);
102
+ psOut = String(totalKB / 1024);
103
+ }
104
+ } catch { psOut = "0"; }
87
105
  const webkitMB = parseFloat(psOut) || 0;
88
106
  if (webkitMB > WEBKIT_MEMORY_LIMIT_MB && _openedTabs.size > 1) {
89
107
  console.error(`[Safari MCP] ⚠️ WebKit using ${Math.round(webkitMB)}MB (limit: ${WEBKIT_MEMORY_LIMIT_MB}MB) — closing oldest MCP tab`);
@@ -278,8 +296,8 @@ try {
278
296
  try {
279
297
  const { nonce, expectedProfile } = JSON.parse(body);
280
298
  // Use AppleScript to find which window contains the nonce in a tab title
281
- const safeNonce = nonce.replace(/"/g, '\\"');
282
- 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
283
301
  // Check via AppleScript — look for the nonce in the profile window
284
302
  const { execFile } = await import("node:child_process");
285
303
  const { promisify } = await import("node:util");
@@ -486,12 +504,12 @@ function sendToExtension(type, payload = {}, timeoutMs = 30000) {
486
504
  // Per-command timeouts — fast commands get short timeouts, nav/screenshot get longer ones
487
505
  const _commandTimeouts = {
488
506
  click: 10000, fill: 5000, read_page: 30000, get_source: 10000, evaluate: 30000,
489
- 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,
490
508
  hover: 5000, list_tabs: 5000, new_tab: 15000, close_tab: 5000, switch_tab: 30000,
491
509
  wait_for: 30000, navigate: 30000, navigate_and_read: 30000, go_back: 10000, go_forward: 10000,
492
510
  reload: 15000, screenshot: 15000, snapshot: 30000, click_and_read: 15000,
493
511
  double_click: 10000, right_click: 10000, clear_field: 5000, select_option: 5000, fill_form: 10000,
494
- get_url: 3000, get_title: 3000,
512
+ replace_editor: 10000, get_url: 3000, get_title: 3000,
495
513
  };
496
514
 
497
515
  // Commands where null result means failure (should fall back to AppleScript)
@@ -539,9 +557,11 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
539
557
  return result;
540
558
  }
541
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;
542
562
  const server = new McpServer({
543
563
  name: "safari-mcp",
544
- version: "1.0.0",
564
+ version: _pkgVersion,
545
565
  description: "Safari browser automation - lightweight, keeps logins",
546
566
  });
547
567
 
@@ -628,9 +648,10 @@ server.tool(
628
648
  "PREFERRED way to see page state. Returns accessibility tree with ref IDs for every interactive element. Use refs with click/fill/type instead of CSS selectors. Workflow: snapshot → see refs → click({ref:'0_5'}). PREFER THIS over safari_screenshot (cheaper, structured text vs heavy image) and over safari_read_page (includes interactive refs). Use safari_screenshot only when you need to see visual layout/styling.",
629
649
  { selector: z.string().optional().describe("CSS selector for subtree (default: full page)") },
630
650
  async (args) => {
651
+ const gen = safari.getNextSnapshotGen();
631
652
  const result = await extensionOrFallback(
632
- "snapshot", { selector: args.selector },
633
- () => safari.takeSnapshot(args)
653
+ "snapshot", { selector: args.selector, gen },
654
+ () => safari.takeSnapshot({ ...args, _gen: gen })
634
655
  );
635
656
  return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
636
657
  }
@@ -1083,20 +1104,36 @@ server.tool(
1083
1104
  // Get current tab list
1084
1105
  const beforeRaw = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
1085
1106
  const beforeTabs = typeof beforeRaw === 'string' ? JSON.parse(beforeRaw) : beforeRaw;
1086
- const beforeUrls = new Set(beforeTabs.map(t => t.url));
1107
+ const beforeIds = new Set(beforeTabs.map(t => `${t.index}:${t.url}`));
1108
+ const beforeCount = beforeTabs.length;
1087
1109
 
1088
- // Poll for new tab
1110
+ // Poll for new tab — detect by count increase + new entries (handles about:blank tabs)
1089
1111
  const deadline = Date.now() + timeoutMs;
1090
1112
  while (Date.now() < deadline) {
1091
1113
  await new Promise(r => setTimeout(r, 500));
1092
1114
  const nowRaw = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
1093
1115
  const nowTabs = typeof nowRaw === 'string' ? JSON.parse(nowRaw) : nowRaw;
1094
- for (const tab of nowTabs) {
1095
- if (!beforeUrls.has(tab.url) && tab.url !== 'about:blank') {
1096
- if (urlContains && !tab.url.includes(urlContains)) continue;
1097
- // Switch to new tab
1098
- await extensionOrFallback("switch_tab", { index: tab.index }, () => safari.switchTab(tab.index));
1099
- return { content: [{ type: "text", text: `Found new tab: ${tab.title} (${tab.url})` }] };
1116
+ if (nowTabs.length > beforeCount) {
1117
+ // Find the new tab(s) — could be about:blank initially (OAuth popups)
1118
+ for (const tab of nowTabs) {
1119
+ if (!beforeIds.has(`${tab.index}:${tab.url}`)) {
1120
+ // Wait briefly for about:blank to resolve to actual URL
1121
+ if (tab.url === 'about:blank') {
1122
+ await new Promise(r => setTimeout(r, 1000));
1123
+ const refreshed = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
1124
+ const refreshedTabs = typeof refreshed === 'string' ? JSON.parse(refreshed) : refreshed;
1125
+ const resolved = refreshedTabs.find(t => t.index === tab.index);
1126
+ if (resolved && resolved.url !== 'about:blank') {
1127
+ if (urlContains && !resolved.url.includes(urlContains)) continue;
1128
+ await extensionOrFallback("switch_tab", { index: resolved.index }, () => safari.switchTab(resolved.index));
1129
+ return { content: [{ type: "text", text: `Found new tab: ${resolved.title} (${resolved.url})` }] };
1130
+ }
1131
+ continue;
1132
+ }
1133
+ if (urlContains && !tab.url.includes(urlContains)) continue;
1134
+ await extensionOrFallback("switch_tab", { index: tab.index }, () => safari.switchTab(tab.index));
1135
+ return { content: [{ type: "text", text: `Found new tab: ${tab.title} (${tab.url})` }] };
1136
+ }
1100
1137
  }
1101
1138
  }
1102
1139
  }
package/mcp-helpers.js ADDED
@@ -0,0 +1,299 @@
1
+ // MCP Click/Interaction Helpers — injected into every page
2
+ // This file is loaded by safari.js at startup, escaped, and injected via AppleScript `do JavaScript`.
3
+ // It is also used by the extension's execInTab for the AppleScript fallback path.
4
+ // IMPORTANT: Keep this file compatible with all browsers — no ES6+ modules, use var where possible.
5
+
6
+ if (window.__mcpVersion !== 5) {
7
+ window.__mcpVersion = 5;
8
+ window.__mcpRefs = window.__mcpRefs || {};
9
+ window.__mcpCachedRoots = null;
10
+ window.__mcpRootsDirty = true;
11
+ if (!window.__mcpRootsObserver) {
12
+ window.__mcpRootsObserver = new MutationObserver(function() { window.__mcpRootsDirty = true; });
13
+ window.__mcpRootsObserver.observe(document.documentElement, { childList: true, subtree: true });
14
+ }
15
+ window.mcpCollectRoots = function() {
16
+ if (!window.__mcpRootsDirty && window.__mcpCachedRoots) return window.__mcpCachedRoots;
17
+ var roots = [];
18
+ function collect(root) {
19
+ roots.push(root);
20
+ var all = root.querySelectorAll('*');
21
+ for (var i = 0; i < all.length; i++) {
22
+ if (all[i].shadowRoot) collect(all[i].shadowRoot);
23
+ }
24
+ }
25
+ collect(document);
26
+ var iframes = document.querySelectorAll('iframe');
27
+ for (var i = 0; i < iframes.length; i++) {
28
+ try { var doc = iframes[i].contentDocument; if (doc) collect(doc); } catch (e) {}
29
+ }
30
+ window.__mcpCachedRoots = roots;
31
+ window.__mcpRootsDirty = false;
32
+ return roots;
33
+ };
34
+ window.mcpQuerySelectorDeep = function(selector) {
35
+ try {
36
+ var direct = document.querySelector(selector);
37
+ if (direct) return direct;
38
+ } catch (e) { return null; }
39
+ var roots = window.mcpCollectRoots();
40
+ for (var i = 1; i < roots.length; i++) {
41
+ try {
42
+ var found = roots[i].querySelector(selector);
43
+ if (found) return found;
44
+ } catch (e) {}
45
+ }
46
+ var iframes = document.querySelectorAll('iframe');
47
+ for (var i = 0; i < iframes.length; i++) {
48
+ try {
49
+ var doc = iframes[i].contentDocument;
50
+ if (doc) { var found = doc.querySelector(selector); if (found) return found; }
51
+ } catch (e) {}
52
+ }
53
+ return null;
54
+ };
55
+ window.mcpElementFromPoint = function(x, y) {
56
+ var el = document.elementFromPoint(x, y);
57
+ while (el && el.shadowRoot) {
58
+ try {
59
+ if (typeof el.shadowRoot.elementFromPoint !== 'function') break;
60
+ var inner = el.shadowRoot.elementFromPoint(x, y);
61
+ if (!inner || inner === el) break;
62
+ el = inner;
63
+ } catch (e) { break; }
64
+ }
65
+ return el;
66
+ };
67
+ window.mcpIsVisible = function(el) {
68
+ if (!el || el.nodeType !== 1 || !el.isConnected) return false;
69
+ var cs = window.getComputedStyle(el);
70
+ if (!cs || cs.display === 'none' || cs.visibility === 'hidden' || cs.visibility === 'collapse' || parseFloat(cs.opacity) === 0) return false;
71
+ var r = el.getBoundingClientRect();
72
+ return r.width > 0 && r.height > 0;
73
+ };
74
+ window.mcpIsActionable = function(el) {
75
+ if (!window.mcpIsVisible(el)) return false;
76
+ if (el.disabled || el.getAttribute('aria-disabled') === 'true') return false;
77
+ if (el.matches && el.matches('input[type="hidden"]')) return false;
78
+ return true;
79
+ };
80
+ window.mcpPickActionable = function(el) {
81
+ var node = el && el.nodeType === 1 ? el : (el && el.parentElement) || null;
82
+ while (node) {
83
+ if (window.mcpIsActionable(node) && node.matches && node.matches('a[href],button,input:not([type="hidden"]),textarea,select,summary,label,option,[role],[onclick],[tabindex],[contenteditable=""],[contenteditable="true"]')) return node;
84
+ node = node.parentElement;
85
+ }
86
+ return el && el.nodeType === 1 ? el : null;
87
+ };
88
+ window.mcpFindByAttr = function(attr, value, selector) {
89
+ if (!value) return null;
90
+ var roots = window.mcpCollectRoots();
91
+ var sel = selector || ('[' + attr + ']');
92
+ for (var i = 0; i < roots.length; i++) {
93
+ var els = roots[i].querySelectorAll(sel);
94
+ for (var j = 0; j < els.length; j++) {
95
+ var current = attr === 'href' ? els[j].href : els[j].getAttribute(attr);
96
+ if (current === value && window.mcpIsVisible(els[j])) return els[j];
97
+ }
98
+ }
99
+ return null;
100
+ };
101
+ window.mcpNormalizeText = function(value) {
102
+ return String(value || '').replace(/\s+/g, ' ').trim();
103
+ };
104
+ window.mcpResolveTarget = function(el) {
105
+ var base = window.mcpPickActionable(el) || el;
106
+ if (!base) return null;
107
+ try { base.scrollIntoView({ block: 'center', inline: 'center' }); } catch (e) {}
108
+ var r = base.getBoundingClientRect();
109
+ var dx = Math.min(12, Math.max(4, r.width / 4));
110
+ var dy = Math.min(12, Math.max(4, r.height / 4));
111
+ var pts = [
112
+ [r.left + r.width / 2, r.top + r.height / 2],
113
+ [r.left + dx, r.top + r.height / 2],
114
+ [r.right - dx, r.top + r.height / 2],
115
+ [r.left + r.width / 2, r.top + dy],
116
+ [r.left + r.width / 2, r.bottom - dy]
117
+ ];
118
+ for (var i = 0; i < pts.length; i++) {
119
+ var x = pts[i][0], y = pts[i][1];
120
+ if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue;
121
+ var hit = window.mcpElementFromPoint(x, y);
122
+ if (!hit) continue;
123
+ if (base === hit || base.contains(hit) || hit.contains(base)) return window.mcpPickActionable(hit) || hit;
124
+ // Overlay pattern: transparent button/link overlays a text label (e.g. dropdown items).
125
+ // If the hit is an interactive element sharing a nearby common ancestor, prefer it.
126
+ var hitAction = window.mcpPickActionable(hit);
127
+ if (hitAction && hitAction !== base && hitAction.matches && hitAction.matches('button,a[href],[role="button"],[role="option"],[role="menuitem"]')) {
128
+ var p = base.parentElement;
129
+ for (var k = 0; k < 4 && p; k++) {
130
+ if (p.contains(hitAction)) return hitAction;
131
+ p = p.parentElement;
132
+ }
133
+ }
134
+ }
135
+ return base;
136
+ };
137
+ window.mcpClick = function(el) {
138
+ var target = window.mcpResolveTarget(el);
139
+ if (!window.mcpIsActionable(target)) return false;
140
+ var r = target.getBoundingClientRect();
141
+ var x = r.left + r.width / 2, y = r.top + r.height / 2;
142
+ var beforeUrl = location.href;
143
+ var anchor = target.closest ? target.closest('a[href]') : null;
144
+ var href = anchor && anchor.href && !anchor.href.startsWith('javascript:') ? anchor.href : '';
145
+ var s = { bubbles: true, cancelable: true, composed: true, view: window, clientX: x, clientY: y, button: 0, detail: 1 };
146
+ var p = { ...s, pointerId: 1, pointerType: 'mouse', isPrimary: true, width: 1, height: 1, pressure: 0.5 };
147
+ target.dispatchEvent(new PointerEvent('pointerover', { ...p, buttons: 0 }));
148
+ target.dispatchEvent(new MouseEvent('mouseover', { ...s, buttons: 0 }));
149
+ target.dispatchEvent(new PointerEvent('pointerenter', { ...p, buttons: 0 }));
150
+ target.dispatchEvent(new MouseEvent('mouseenter', { ...s, buttons: 0 }));
151
+ target.dispatchEvent(new PointerEvent('pointermove', { ...p, buttons: 0 }));
152
+ target.dispatchEvent(new MouseEvent('mousemove', { ...s, buttons: 0 }));
153
+ target.dispatchEvent(new PointerEvent('pointerdown', { ...p, buttons: 1 }));
154
+ target.dispatchEvent(new MouseEvent('mousedown', { ...s, buttons: 1 }));
155
+ if (target.focus) { try { target.focus({ preventScroll: true }); } catch (e) { try { target.focus(); } catch (_) {} } }
156
+ target.dispatchEvent(new PointerEvent('pointerup', { ...p, buttons: 0, pressure: 0 }));
157
+ target.dispatchEvent(new MouseEvent('mouseup', { ...s, buttons: 0 }));
158
+ try {
159
+ if (typeof target.click === 'function') {
160
+ target.click();
161
+ if (href && href !== beforeUrl) {
162
+ location.href = href;
163
+ }
164
+ }
165
+ } catch (e) {}
166
+ target.dispatchEvent(new MouseEvent('click', { ...s, buttons: 0 }));
167
+ if (href && href !== beforeUrl) {
168
+ location.href = href;
169
+ }
170
+ var form = target.closest ? target.closest('form') : null;
171
+ if (form && (target.type === 'submit' || (target.tagName === 'BUTTON' && target.type !== 'button' && target.type !== 'reset'))) {
172
+ try { form.requestSubmit ? form.requestSubmit(target.type === 'submit' ? target : undefined) : form.submit(); } catch (e) {}
173
+ }
174
+ return true;
175
+ };
176
+ window.mcpReactClick = function(el) {
177
+ var startNode = window.mcpResolveTarget(el) || el;
178
+ if (!startNode) return false;
179
+ function makeSynth(targetNode, type) {
180
+ var r = targetNode.getBoundingClientRect();
181
+ return { type: type || 'click', target: targetNode, currentTarget: targetNode,
182
+ clientX: r.left + r.width / 2, clientY: r.top + r.height / 2, pageX: r.left + r.width / 2 + window.scrollX, pageY: r.top + r.height / 2 + window.scrollY,
183
+ preventDefault: function() {}, stopPropagation: function() {}, nativeEvent: new MouseEvent(type || 'click'), persist: function() {}, bubbles: true, cancelable: true };
184
+ }
185
+ // For React radio/checkbox inputs, onChange is the primary handler (not onClick)
186
+ var isToggle = startNode.tagName === 'INPUT' && (startNode.type === 'radio' || startNode.type === 'checkbox');
187
+ function tryOnChange(propsObj, targetNode) {
188
+ if (!isToggle || !propsObj.onChange) return false;
189
+ var newChecked = startNode.type === 'radio' ? true : !startNode.checked;
190
+ propsObj.onChange({ target: { value: startNode.value, checked: newChecked, type: startNode.type, name: startNode.name, id: startNode.id, tagName: 'INPUT' },
191
+ currentTarget: targetNode, preventDefault: function() {}, stopPropagation: function() {}, nativeEvent: new Event('change'), persist: function() {}, bubbles: true, cancelable: true, type: 'change' });
192
+ return true;
193
+ }
194
+ var node = startNode;
195
+ for (var depth = 0; depth < 15 && node; depth++) {
196
+ var pk = Object.keys(node).find(function(k) { return k.startsWith('__reactProps$'); });
197
+ if (pk && node[pk]) {
198
+ var props = node[pk];
199
+ if (tryOnChange(props, node)) return true;
200
+ if (props.onClick) { props.onClick(makeSynth(node, 'click')); return true; }
201
+ if (props.onMouseDown) { props.onMouseDown(makeSynth(node, 'mousedown')); return true; }
202
+ if (props.onPointerDown) { props.onPointerDown(makeSynth(node, 'pointerdown')); return true; }
203
+ }
204
+ var fk = Object.keys(node).find(function(k) { return k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'); });
205
+ if (fk) {
206
+ var f = node[fk];
207
+ while (f) {
208
+ if (f.memoizedProps) {
209
+ if (tryOnChange(f.memoizedProps, node)) return true;
210
+ if (f.memoizedProps.onClick) { f.memoizedProps.onClick(makeSynth(node, 'click')); return true; }
211
+ if (f.memoizedProps.onMouseDown) { f.memoizedProps.onMouseDown(makeSynth(node, 'mousedown')); return true; }
212
+ if (f.memoizedProps.onPointerDown) { f.memoizedProps.onPointerDown(makeSynth(node, 'pointerdown')); return true; }
213
+ }
214
+ f = f.return;
215
+ }
216
+ }
217
+ node = node.parentElement;
218
+ }
219
+ return false;
220
+ };
221
+ window.mcpClickWithReact = function(el) {
222
+ var target = window.mcpResolveTarget(el) || el;
223
+ var reactFired = false;
224
+ try { reactFired = window.mcpReactClick(target); } catch (e) {}
225
+ var anchor = target && target.closest ? target.closest('a[href]') : null;
226
+ if (!reactFired || anchor) window.mcpClick(target);
227
+ return target;
228
+ };
229
+ window.mcpFindRef = function(ref) {
230
+ var el = window.mcpQuerySelectorDeep('[data-mcp-ref="' + ref + '"]');
231
+ if (el) return el;
232
+ if (!window.__mcpRefs || !window.__mcpRefs[ref]) return null;
233
+ var m = window.__mcpRefs[ref];
234
+ if (m.id) { el = window.mcpFindByAttr('id', m.id); if (el) return el; }
235
+ if (m.testid) { el = window.mcpFindByAttr('data-testid', m.testid); if (el) return el; }
236
+ if (m.nameAttr) { el = window.mcpFindByAttr('name', m.nameAttr); if (el) return el; }
237
+ if (m.href) { el = window.mcpFindByAttr('href', m.href, 'a[href]'); if (el) return el; }
238
+ if (m.al) { el = window.mcpFindByAttr('aria-label', m.al); if (el) return el; }
239
+ if (m.ph) { el = window.mcpFindByAttr('placeholder', m.ph); if (el) return el; }
240
+ if (m.text) {
241
+ el = window.mcpFindText(m.text, true) || window.mcpFindText(m.text, false);
242
+ if (el) return el;
243
+ }
244
+ if (m.cx !== undefined && m.cy !== undefined) {
245
+ try {
246
+ window.scrollTo(window.scrollX, Math.max(0, m.cy - window.innerHeight / 2));
247
+ } catch (e) {}
248
+ el = window.mcpElementFromPoint(m.cx - window.scrollX, m.cy - window.scrollY);
249
+ if (el) return window.mcpPickActionable(el) || el;
250
+ }
251
+ return null;
252
+ };
253
+ window.mcpFindText = function(text, exact) {
254
+ var needle = window.mcpNormalizeText(text);
255
+ var best = null, bestScore = Infinity;
256
+ function consider(node) {
257
+ var target = window.mcpPickActionable(node) || node;
258
+ if (!window.mcpIsVisible(target)) return;
259
+ var r = target.getBoundingClientRect();
260
+ var area = r.width * r.height;
261
+ var interactive = target.matches && target.matches('a[href],button,input:not([type="hidden"]),textarea,select,summary,label,[role=button],[role=link],[role=tab],[onclick],[tabindex]');
262
+ var score = area + (interactive ? 0 : 1000000);
263
+ if (score < bestScore) { best = target; bestScore = score; }
264
+ }
265
+ var roots = window.mcpCollectRoots();
266
+ for (var i = 0; i < roots.length; i++) {
267
+ var attrEls = roots[i].querySelectorAll('[aria-label],[placeholder],[title],[data-testid],[alt]');
268
+ for (var j = 0; j < attrEls.length; j++) {
269
+ var a = attrEls[j];
270
+ var vals = [a.getAttribute('aria-label'), a.getAttribute('placeholder'), a.getAttribute('title'), a.getAttribute('data-testid'), a.getAttribute('alt')];
271
+ for (var k = 0; k < vals.length; k++) {
272
+ var val = vals[k];
273
+ if (!val) continue;
274
+ var normalized = window.mcpNormalizeText(val);
275
+ if (exact ? normalized === needle : normalized.includes(needle)) { consider(a); break; }
276
+ }
277
+ }
278
+ var tw = document.createTreeWalker(roots[i], NodeFilter.SHOW_TEXT, null);
279
+ while (tw.nextNode()) {
280
+ var n = tw.currentNode;
281
+ var t = window.mcpNormalizeText(n.textContent);
282
+ if (!t) continue;
283
+ if (exact ? (t !== needle) : !t.includes(needle)) continue;
284
+ if (n.parentElement) consider(n.parentElement);
285
+ }
286
+ }
287
+ if (!best) {
288
+ var allEls = document.querySelectorAll('*');
289
+ for (var i = 0; i < allEls.length; i++) {
290
+ var el = allEls[i];
291
+ var it = window.mcpNormalizeText(el.innerText);
292
+ if (!it) continue;
293
+ if (exact ? (it !== needle) : !it.includes(needle)) continue;
294
+ consider(el);
295
+ }
296
+ }
297
+ return best;
298
+ };
299
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.1.8",
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",
@@ -49,6 +49,7 @@
49
49
  "files": [
50
50
  "index.js",
51
51
  "safari.js",
52
+ "mcp-helpers.js",
52
53
  "safari-helper",
53
54
  "safari-helper.swift",
54
55
  "extension/",
package/safari.js CHANGED
@@ -8,6 +8,7 @@ import { promisify } from "node:util";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join, dirname } from "node:path";
10
10
  import { readFile, writeFile, unlink, appendFile } from "node:fs/promises";
11
+ import { readFileSync } from "node:fs";
11
12
  import { fileURLToPath } from "node:url";
12
13
  // Extension bridge is handled by index.js (WebSocket server on port 9223)
13
14
 
@@ -18,6 +19,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
18
19
  // Persistent process — no subprocess spawn overhead (~5ms vs ~90ms)
19
20
  let _helperProc = null;
20
21
  const _helperQueue = []; // callbacks waiting for responses
22
+ let _helperConsecutiveTimeouts = 0; // Track consecutive timeouts — only kill after 3
21
23
 
22
24
  // Reject all pending callbacks when helper crashes
23
25
  function _drainHelperQueue(reason) {
@@ -335,9 +337,15 @@ function _osascriptFastHelper(script, timeout) {
335
337
  resolved = true;
336
338
  const idx = _helperQueue.indexOf(cb);
337
339
  if (idx >= 0) _helperQueue.splice(idx, 1);
338
- _helperProc?.kill();
339
- _helperProc = null;
340
- 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
+ }
341
349
  reject(new Error("safari-helper timeout"));
342
350
  }, timeout);
343
351
 
@@ -345,6 +353,7 @@ function _osascriptFastHelper(script, timeout) {
345
353
  if (resolved) return;
346
354
  resolved = true;
347
355
  clearTimeout(timer);
356
+ _helperConsecutiveTimeouts = 0; // Reset on success
348
357
  try {
349
358
  const parsed = JSON.parse(line);
350
359
  if (parsed.error) reject(new Error(parsed.error));
@@ -354,14 +363,21 @@ function _osascriptFastHelper(script, timeout) {
354
363
  }
355
364
  }
356
365
 
357
- _helperQueue.push(cb);
358
- if (!_helperProc || !_helperProc.stdin) {
359
- // Helper died between the check and write — reject immediately
360
- _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);
361
369
  reject(new Error("safari-helper not available"));
362
370
  return;
363
371
  }
364
- _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
+ }
365
381
  });
366
382
  }
367
383
 
@@ -622,17 +638,21 @@ export async function navigate(url) {
622
638
 
623
639
  export async function goBack() {
624
640
  // Navigate back + smart wait: check immediately, then poll only if loading
625
- return runJS(
641
+ const result = await runJS(
626
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});})()`,
627
643
  { timeout: 10000 }
628
644
  );
645
+ try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
646
+ return result;
629
647
  }
630
648
 
631
649
  export async function goForward() {
632
- return runJS(
650
+ const result = await runJS(
633
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});})()`,
634
652
  { timeout: 10000 }
635
653
  );
654
+ try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
655
+ return result;
636
656
  }
637
657
 
638
658
  export async function reload(hardReload = false) {
@@ -640,10 +660,12 @@ export async function reload(hardReload = false) {
640
660
  await runJS(hardReload ? "location.reload(true)" : "location.reload()");
641
661
  await new Promise((r) => setTimeout(r, 100)); // Brief wait for reload to start (was 500ms)
642
662
  // Poll readyState in a single call
643
- return runJS(
663
+ const result = await runJS(
644
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});})()`,
645
665
  { timeout: 10000 }
646
666
  );
667
+ try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
668
+ return result;
647
669
  }
648
670
 
649
671
  // ========== PAGE INFO ==========
@@ -673,7 +695,12 @@ export async function getPageSource({ maxLength = 200000 } = {}) {
673
695
 
674
696
  // Inject click helpers ONCE per page (cached on window.__mcp)
675
697
  // Includes: mcpClick (full event sequence), mcpReactClick (React Fiber), mcpFindText (fast TreeWalker)
676
- const INJECT_MCP_HELPERS = `if(window.__mcpVersion!==5){window.__mcpVersion=5;
698
+ // Loaded from mcp-helpers.js at startup — enables syntax highlighting, linting, and easier maintenance
699
+ const INJECT_MCP_HELPERS = readFileSync(join(__dirname, 'mcp-helpers.js'), 'utf8');
700
+
701
+ // NOTE: Helper code previously inlined here (~300 lines) is now in mcp-helpers.js
702
+ /* eslint-disable */
703
+ const _LEGACY_INLINE_START = `if(window.__mcpVersion!==5){window.__mcpVersion=5;
677
704
  window.__mcpRefs=window.__mcpRefs||{};
678
705
  window.__mcpCachedRoots=null;
679
706
  window.__mcpRootsDirty=true;
@@ -1233,7 +1260,7 @@ export async function fill({ selector, value, ref }) {
1233
1260
 
1234
1261
  const fillResult = await runJS(
1235
1262
  `(function(){` +
1236
- `var el=document.querySelector('${selector.replace(/'/g, "\\'")}');` +
1263
+ `var el=document.querySelector('${selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}');` +
1237
1264
  `if(!el)return 'Element not found';` +
1238
1265
  `el.focus();el.click();` +
1239
1266
  `var sel=window.getSelection();if(sel.rangeCount){var r=document.createRange();r.selectNodeContents(el);r.collapse(false);sel.removeAllRanges();sel.addRange(r);}` +
@@ -1618,15 +1645,19 @@ export async function screenshot({ fullPage = false } = {}) {
1618
1645
  await osascript(
1619
1646
  `tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, ${Number(w)}, ${Math.min(Number(h) + 100, 5000)}}`
1620
1647
  );
1621
- await new Promise((r) => setTimeout(r, 500));
1622
- // Use do shell script to inherit osascript's Screen Recording permission
1623
- await osascript(
1624
- `do shell script "screencapture -l${windowId} -o -x '${tmpFile}'"`,
1625
- { timeout: 15000 }
1626
- );
1627
- await osascript(
1628
- `tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${bounds}}`
1629
- );
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
+ }
1630
1661
  } else {
1631
1662
  // Try direct execFile first (works if VS Code has Screen Recording permission)
1632
1663
  try {
@@ -1773,7 +1804,7 @@ export async function screenshotElement({ selector }) {
1773
1804
  await execFileAsync("screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
1774
1805
  const { x, y, w, h } = JSON.parse(bounds);
1775
1806
  // Use sips to crop (macOS built-in)
1776
- const toolbarHeight = 52; // Safari toolbar approximate height
1807
+ const toolbarHeight = 74; // Safari toolbar approximate height (matches _getSafariWindowGeometry)
1777
1808
  const cropFile = join(tmpdir(), `safari-el-crop-${Date.now()}.png`);
1778
1809
  await execFileAsync("sips", [
1779
1810
  "-c", String(h), String(w),
@@ -1861,8 +1892,9 @@ export async function newTab(url = "") {
1861
1892
  else { await osascript('tell application "Safari" to make new document'); }
1862
1893
  }
1863
1894
  }
1895
+ // Get count atomically from the same tell block as tab creation (avoids TOCTOU if user opens tabs concurrently)
1864
1896
  const tabCount = await osascriptFast(`tell application "Safari" to return count of tabs of ${getTargetWindowRef()}`);
1865
- _activeTabIndex = Number(tabCount);
1897
+ _activeTabIndex = Number(tabCount); // New tab is always appended as last
1866
1898
  _activeTabURL = url || null;
1867
1899
  _lastResolveTime = Date.now();
1868
1900
  // Wait for page load if URL given
@@ -2052,11 +2084,11 @@ export async function handleDialog({ action = "accept", text }) {
2052
2084
  }
2053
2085
  if (action === "accept") {
2054
2086
  await runJS(
2055
- "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;};"
2056
2088
  );
2057
2089
  } else {
2058
2090
  await runJS(
2059
- "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;};"
2060
2092
  );
2061
2093
  }
2062
2094
  return `Dialog handler set: ${action}${text ? ' with "' + text + '"' : ""}`;
@@ -2100,19 +2132,23 @@ export async function getNetworkRequests({ limit = 50 } = {}) {
2100
2132
 
2101
2133
  export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, targetX, targetY }) {
2102
2134
  if (sourceSelector && targetSelector) {
2103
- const srcSel = sourceSelector.replace(/'/g, "\\'");
2104
- const tgtSel = targetSelector.replace(/'/g, "\\'");
2135
+ const srcSel = sourceSelector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2136
+ const tgtSel = targetSelector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2105
2137
  return runJS(
2106
2138
  `(function(){` +
2107
2139
  `var src=document.querySelector('${srcSel}');var tgt=document.querySelector('${tgtSel}');` +
2108
2140
  `if(!src)return 'Source not found: ${srcSel}';if(!tgt)return 'Target not found: ${tgtSel}';` +
2109
2141
  `var sr=src.getBoundingClientRect();var tr=tgt.getBoundingClientRect();` +
2110
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}));` +
2111
2145
  `src.dispatchEvent(new MouseEvent('mousedown',{clientX:sx,clientY:sy,bubbles:true}));` +
2112
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}));` +
2113
2148
  `tgt.dispatchEvent(new MouseEvent('mousemove',{clientX:tx,clientY:ty,bubbles:true}));` +
2114
2149
  `tgt.dispatchEvent(new MouseEvent('mouseup',{clientX:tx,clientY:ty,bubbles:true}));` +
2115
- `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}));` +
2116
2152
  `return 'Dragged from '+src.tagName+' to '+tgt.tagName;})()`
2117
2153
  );
2118
2154
  }
@@ -2131,9 +2167,27 @@ export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, t
2131
2167
  throw new Error("drag requires sourceSelector+targetSelector or sourceX/Y+targetX/Y");
2132
2168
  }
2133
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
+
2134
2187
  // ========== UPLOAD FILE ==========
2135
2188
 
2136
2189
  export async function uploadFile({ selector, filePath }) {
2190
+ _validateFilePath(filePath);
2137
2191
  // Read file in Node.js, send as base64 to Safari JS, create File + DataTransfer
2138
2192
  // NO file dialog, NO System Events, NO focus stealing
2139
2193
 
@@ -2251,6 +2305,7 @@ export async function uploadFile({ selector, filePath }) {
2251
2305
  // ========== PASTE IMAGE FROM FILE ==========
2252
2306
 
2253
2307
  export async function pasteImageFromFile({ filePath }) {
2308
+ _validateFilePath(filePath);
2254
2309
  // Paste image via JS ClipboardEvent — NO clipboard touch, NO System Events, NO focus steal
2255
2310
  const { extname } = await import("node:path");
2256
2311
  const ext = extname(filePath).toLowerCase().replace(".", "");
@@ -2351,9 +2406,9 @@ export async function emulate({ device, width, height, userAgent, scale = 1 }) {
2351
2406
 
2352
2407
  export async function resetEmulation() {
2353
2408
  await refreshTargetWindow();
2354
- // Reset user agent
2409
+ // Reset user agent — remove the defineProperty override set by emulate()
2355
2410
  await runJS(
2356
- "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(_){}"
2357
2412
  );
2358
2413
  // Maximize window
2359
2414
  await osascript(
@@ -2439,9 +2494,14 @@ export async function savePDF({ path: pdfPath }) {
2439
2494
  // instead of guessing CSS selectors. Much faster, no hallucination risk.
2440
2495
 
2441
2496
  let _snapshotGen = 0;
2442
-
2443
- export async function takeSnapshot({ selector } = {}) {
2444
- const gen = _snapshotGen++;
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.
2500
+ export function getNextSnapshotGen() { return _snapshotGen++; }
2501
+
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++;
2445
2505
  const root = selector ? `document.querySelector('${selector.replace(/'/g, "\\'")}')` : "document.body";
2446
2506
 
2447
2507
  const result = await runJS(
@@ -2793,7 +2853,13 @@ export async function importStorageState({ state }) {
2793
2853
  const parsed = typeof state === "string" ? JSON.parse(state) : state;
2794
2854
  const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "");
2795
2855
  const cmds = [];
2796
- if (parsed.cookies) cmds.push(`document.cookie='${esc(parsed.cookies)}'`);
2856
+ // Cookies must be set one at a time — document.cookie only accepts one cookie per assignment
2857
+ if (parsed.cookies) {
2858
+ const cookiePairs = String(parsed.cookies).split(/;\s*/);
2859
+ for (const pair of cookiePairs) {
2860
+ if (pair.trim()) cmds.push(`document.cookie='${esc(pair.trim())}'`);
2861
+ }
2862
+ }
2797
2863
  if (parsed.localStorage) {
2798
2864
  for (const [k, v] of Object.entries(parsed.localStorage)) {
2799
2865
  cmds.push(`localStorage.setItem('${esc(k)}','${esc(v)}')`);
@@ -2804,7 +2870,12 @@ export async function importStorageState({ state }) {
2804
2870
  cmds.push(`sessionStorage.setItem('${esc(k)}','${esc(v)}')`);
2805
2871
  }
2806
2872
  }
2807
- 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);
2808
2879
  }
2809
2880
 
2810
2881
  // ========== CLIPBOARD ==========
@@ -2926,8 +2997,9 @@ export async function clearNetworkMocks() {
2926
2997
  // ========== WAIT FOR TIME ==========
2927
2998
 
2928
2999
  export async function waitForTime({ ms }) {
2929
- await new Promise((r) => setTimeout(r, Number(ms)));
2930
- 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`;
2931
3003
  }
2932
3004
 
2933
3005
  // ========== NETWORK CAPTURE (Detailed) ==========
@@ -3368,8 +3440,9 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
3368
3440
  const safeUrl = targetUrl.replace(/"/g, '\\"');
3369
3441
  const navTarget = _activeTabIndex ? `tab ${_activeTabIndex} of ${getTargetWindowRef()}` : getFallbackTarget();
3370
3442
  await osascriptFast(`tell application "Safari" to set URL of ${navTarget} to "${safeUrl}"`);
3443
+ _activeTabURL = targetUrl;
3371
3444
  // Single JS call: poll readyState + return page data — 1 call instead of 30+
3372
- return runJS(
3445
+ const navResult = await runJS(
3373
3446
  `(async function(){
3374
3447
  for(var i=0;i<60;i++){
3375
3448
  if(document.readyState==='complete')break;
@@ -3379,6 +3452,12 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
3379
3452
  })()`,
3380
3453
  { timeout: 30000 }
3381
3454
  );
3455
+ // Update _activeTabURL with the actual URL after navigation
3456
+ try {
3457
+ const parsed = JSON.parse(navResult);
3458
+ if (parsed.url && parsed.url !== 'about:blank') _activeTabURL = parsed.url;
3459
+ } catch {}
3460
+ return navResult;
3382
3461
  }
3383
3462
 
3384
3463
  // Click + wait for navigation or element — common after clicking a link/button
@@ -3474,11 +3553,6 @@ export async function analyzePage() {
3474
3553
  }
3475
3554
 
3476
3555
  // ========== GRACEFUL SHUTDOWN ==========
3477
- function cleanup() {
3478
- if (_helperProc) { try { _helperProc.kill(); } catch {} _helperProc = null; }
3479
- _drainHelperQueue("shutting down");
3480
- }
3481
-
3482
- process.on("exit", cleanup);
3483
- process.on("SIGINT", () => { cleanup(); process.exit(0); });
3484
- process.on("SIGTERM", () => { cleanup(); process.exit(0); });
3556
+ // NOTE: Signal handlers are registered at the top of the file (cleanupHelper + process.exit).
3557
+ // _drainHelperQueue is called from cleanupHelper via process.on("exit").
3558
+ process.on("exit", () => { _drainHelperQueue("shutting down"); });