safari-mcp 2.1.8 → 2.2.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.
@@ -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 },
package/index.js CHANGED
@@ -81,9 +81,17 @@ let _memoryCheckTimer = null;
81
81
  function _startMemoryMonitor() {
82
82
  _memoryCheckTimer = setInterval(async () => {
83
83
  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();
84
+ // Use pgrep + ps directly — avoids spawning bash, grep, awk pipeline
85
+ let psOut = "0";
86
+ try {
87
+ const pids = execFileSync("pgrep", ["-f", "WebKit|WebContent"], { encoding: "utf8" }).trim();
88
+ if (pids) {
89
+ const pidList = pids.split("\n").join(",");
90
+ psOut = execFileSync("ps", ["-p", pidList, "-o", "rss="], { encoding: "utf8" }).trim();
91
+ const totalKB = psOut.split("\n").reduce((sum, l) => sum + (parseInt(l.trim(), 10) || 0), 0);
92
+ psOut = String(totalKB / 1024);
93
+ }
94
+ } catch { psOut = "0"; }
87
95
  const webkitMB = parseFloat(psOut) || 0;
88
96
  if (webkitMB > WEBKIT_MEMORY_LIMIT_MB && _openedTabs.size > 1) {
89
97
  console.error(`[Safari MCP] ⚠️ WebKit using ${Math.round(webkitMB)}MB (limit: ${WEBKIT_MEMORY_LIMIT_MB}MB) — closing oldest MCP tab`);
@@ -491,7 +499,7 @@ const _commandTimeouts = {
491
499
  wait_for: 30000, navigate: 30000, navigate_and_read: 30000, go_back: 10000, go_forward: 10000,
492
500
  reload: 15000, screenshot: 15000, snapshot: 30000, click_and_read: 15000,
493
501
  double_click: 10000, right_click: 10000, clear_field: 5000, select_option: 5000, fill_form: 10000,
494
- get_url: 3000, get_title: 3000,
502
+ replace_editor: 10000, get_url: 3000, get_title: 3000,
495
503
  };
496
504
 
497
505
  // Commands where null result means failure (should fall back to AppleScript)
@@ -628,8 +636,9 @@ server.tool(
628
636
  "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
637
  { selector: z.string().optional().describe("CSS selector for subtree (default: full page)") },
630
638
  async (args) => {
639
+ const gen = safari.getNextSnapshotGen();
631
640
  const result = await extensionOrFallback(
632
- "snapshot", { selector: args.selector },
641
+ "snapshot", { selector: args.selector, gen },
633
642
  () => safari.takeSnapshot(args)
634
643
  );
635
644
  return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
@@ -1083,20 +1092,36 @@ server.tool(
1083
1092
  // Get current tab list
1084
1093
  const beforeRaw = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
1085
1094
  const beforeTabs = typeof beforeRaw === 'string' ? JSON.parse(beforeRaw) : beforeRaw;
1086
- const beforeUrls = new Set(beforeTabs.map(t => t.url));
1095
+ const beforeIds = new Set(beforeTabs.map(t => `${t.index}:${t.url}`));
1096
+ const beforeCount = beforeTabs.length;
1087
1097
 
1088
- // Poll for new tab
1098
+ // Poll for new tab — detect by count increase + new entries (handles about:blank tabs)
1089
1099
  const deadline = Date.now() + timeoutMs;
1090
1100
  while (Date.now() < deadline) {
1091
1101
  await new Promise(r => setTimeout(r, 500));
1092
1102
  const nowRaw = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
1093
1103
  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})` }] };
1104
+ if (nowTabs.length > beforeCount) {
1105
+ // Find the new tab(s) — could be about:blank initially (OAuth popups)
1106
+ for (const tab of nowTabs) {
1107
+ if (!beforeIds.has(`${tab.index}:${tab.url}`)) {
1108
+ // Wait briefly for about:blank to resolve to actual URL
1109
+ if (tab.url === 'about:blank') {
1110
+ await new Promise(r => setTimeout(r, 1000));
1111
+ const refreshed = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
1112
+ const refreshedTabs = typeof refreshed === 'string' ? JSON.parse(refreshed) : refreshed;
1113
+ const resolved = refreshedTabs.find(t => t.index === tab.index);
1114
+ if (resolved && resolved.url !== 'about:blank') {
1115
+ if (urlContains && !resolved.url.includes(urlContains)) continue;
1116
+ await extensionOrFallback("switch_tab", { index: resolved.index }, () => safari.switchTab(resolved.index));
1117
+ return { content: [{ type: "text", text: `Found new tab: ${resolved.title} (${resolved.url})` }] };
1118
+ }
1119
+ continue;
1120
+ }
1121
+ if (urlContains && !tab.url.includes(urlContains)) continue;
1122
+ await extensionOrFallback("switch_tab", { index: tab.index }, () => safari.switchTab(tab.index));
1123
+ return { content: [{ type: "text", text: `Found new tab: ${tab.title} (${tab.url})` }] };
1124
+ }
1100
1125
  }
1101
1126
  }
1102
1127
  }
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' || cs.pointerEvents === 'none' || 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.2.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
 
@@ -673,7 +674,12 @@ export async function getPageSource({ maxLength = 200000 } = {}) {
673
674
 
674
675
  // Inject click helpers ONCE per page (cached on window.__mcp)
675
676
  // Includes: mcpClick (full event sequence), mcpReactClick (React Fiber), mcpFindText (fast TreeWalker)
676
- const INJECT_MCP_HELPERS = `if(window.__mcpVersion!==5){window.__mcpVersion=5;
677
+ // Loaded from mcp-helpers.js at startup — enables syntax highlighting, linting, and easier maintenance
678
+ const INJECT_MCP_HELPERS = readFileSync(join(__dirname, 'mcp-helpers.js'), 'utf8');
679
+
680
+ // NOTE: Helper code previously inlined here (~300 lines) is now in mcp-helpers.js
681
+ /* eslint-disable */
682
+ const _LEGACY_INLINE_START = `if(window.__mcpVersion!==5){window.__mcpVersion=5;
677
683
  window.__mcpRefs=window.__mcpRefs||{};
678
684
  window.__mcpCachedRoots=null;
679
685
  window.__mcpRootsDirty=true;
@@ -1773,7 +1779,7 @@ export async function screenshotElement({ selector }) {
1773
1779
  await execFileAsync("screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
1774
1780
  const { x, y, w, h } = JSON.parse(bounds);
1775
1781
  // Use sips to crop (macOS built-in)
1776
- const toolbarHeight = 52; // Safari toolbar approximate height
1782
+ const toolbarHeight = 74; // Safari toolbar approximate height (matches _getSafariWindowGeometry)
1777
1783
  const cropFile = join(tmpdir(), `safari-el-crop-${Date.now()}.png`);
1778
1784
  await execFileAsync("sips", [
1779
1785
  "-c", String(h), String(w),
@@ -2439,6 +2445,7 @@ export async function savePDF({ path: pdfPath }) {
2439
2445
  // instead of guessing CSS selectors. Much faster, no hallucination risk.
2440
2446
 
2441
2447
  let _snapshotGen = 0;
2448
+ export function getNextSnapshotGen() { return _snapshotGen++; }
2442
2449
 
2443
2450
  export async function takeSnapshot({ selector } = {}) {
2444
2451
  const gen = _snapshotGen++;
@@ -2793,7 +2800,13 @@ export async function importStorageState({ state }) {
2793
2800
  const parsed = typeof state === "string" ? JSON.parse(state) : state;
2794
2801
  const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "");
2795
2802
  const cmds = [];
2796
- if (parsed.cookies) cmds.push(`document.cookie='${esc(parsed.cookies)}'`);
2803
+ // Cookies must be set one at a time — document.cookie only accepts one cookie per assignment
2804
+ if (parsed.cookies) {
2805
+ const cookiePairs = String(parsed.cookies).split(/;\s*/);
2806
+ for (const pair of cookiePairs) {
2807
+ if (pair.trim()) cmds.push(`document.cookie='${esc(pair.trim())}'`);
2808
+ }
2809
+ }
2797
2810
  if (parsed.localStorage) {
2798
2811
  for (const [k, v] of Object.entries(parsed.localStorage)) {
2799
2812
  cmds.push(`localStorage.setItem('${esc(k)}','${esc(v)}')`);
@@ -3368,8 +3381,9 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
3368
3381
  const safeUrl = targetUrl.replace(/"/g, '\\"');
3369
3382
  const navTarget = _activeTabIndex ? `tab ${_activeTabIndex} of ${getTargetWindowRef()}` : getFallbackTarget();
3370
3383
  await osascriptFast(`tell application "Safari" to set URL of ${navTarget} to "${safeUrl}"`);
3384
+ _activeTabURL = targetUrl;
3371
3385
  // Single JS call: poll readyState + return page data — 1 call instead of 30+
3372
- return runJS(
3386
+ const navResult = await runJS(
3373
3387
  `(async function(){
3374
3388
  for(var i=0;i<60;i++){
3375
3389
  if(document.readyState==='complete')break;
@@ -3379,6 +3393,12 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
3379
3393
  })()`,
3380
3394
  { timeout: 30000 }
3381
3395
  );
3396
+ // Update _activeTabURL with the actual URL after navigation
3397
+ try {
3398
+ const parsed = JSON.parse(navResult);
3399
+ if (parsed.url && parsed.url !== 'about:blank') _activeTabURL = parsed.url;
3400
+ } catch {}
3401
+ return navResult;
3382
3402
  }
3383
3403
 
3384
3404
  // Click + wait for navigation or element — common after clicking a link/button
@@ -3474,11 +3494,6 @@ export async function analyzePage() {
3474
3494
  }
3475
3495
 
3476
3496
  // ========== 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); });
3497
+ // NOTE: Signal handlers are registered at the top of the file (cleanupHelper + process.exit).
3498
+ // _drainHelperQueue is called from cleanupHelper via process.on("exit").
3499
+ process.on("exit", () => { _drainHelperQueue("shutting down"); });