safari-mcp 2.9.3 → 2.10.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
@@ -15,6 +15,10 @@
15
15
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
16
16
  [![macOS](https://img.shields.io/badge/macOS-only-blue)](https://www.apple.com/macos/)
17
17
 
18
+ [![Glama AAA](https://img.shields.io/badge/Glama-AAA_Score-22c55e?logo=ai)](https://glama.ai/mcp/servers/@achiya-automation/safari-mcp)
19
+ [![MCP Registry](https://img.shields.io/badge/MCP_Registry-Verified-2563eb)](https://registry.modelcontextprotocol.io/v0/servers?search=achiya-automation)
20
+ [![Smithery](https://img.shields.io/badge/Smithery-Listed-7c3aed)](https://smithery.ai/server/@achiya-automation/safari-mcp)
21
+
18
22
  <a href="vscode:mcp/install?%7B%22safari-mcp%22%3A%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D%7D"><img src="https://img.shields.io/badge/VS_Code-Install_Server-0078d4?logo=visual-studio-code&logoColor=white" alt="Install in VS Code"></a>
19
23
  <a href="https://insiders.vscode.dev/redirect?url=vscode-insiders:mcp/install?%7B%22safari-mcp%22%3A%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D%7D"><img src="https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?logo=visual-studio-code&logoColor=white" alt="Install in VS Code Insiders"></a>
20
24
  <a href="cursor://anysphere.cursor-deeplink/mcp/install?name=safari-mcp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D"><img src="https://img.shields.io/badge/Cursor-Install_Server-f97316?logo=cursor&logoColor=white" alt="Install in Cursor"></a>
@@ -23,7 +27,7 @@
23
27
 
24
28
  [Quick Start](#quick-start) · [All 80 Tools](#tools-80) · [Examples](examples/) · [Why Safari MCP?](#safari-mcp-vs-alternatives) · [Architecture](#architecture) · [Changelog](CHANGELOG.md)
25
29
 
26
- ![Safari MCP Demo](https://github.com/achiya-automation/safari-mcp/releases/download/v2.1.2/safari-mcp-showcase.gif)
30
+ ![Safari MCP Demo](https://github.com/achiya-automation/safari-mcp/raw/main/assets/safari-mcp-promo.gif)
27
31
 
28
32
  </div>
29
33
 
@@ -80,7 +84,54 @@ npm install -g safari-mcp
80
84
 
81
85
  ### Configure your MCP client
82
86
 
83
- Add to your client's config file — all clients use the same JSON:
87
+ All clients run Safari MCP the same way — `npx safari-mcp`. Pick your editor:
88
+
89
+ <details>
90
+ <summary><b>Claude Code</b></summary>
91
+
92
+ ```bash
93
+ claude mcp add safari -- npx safari-mcp
94
+ ```
95
+
96
+ Or edit `~/.mcp.json`:
97
+
98
+ ```json
99
+ {
100
+ "mcpServers": {
101
+ "safari": {
102
+ "command": "npx",
103
+ "args": ["safari-mcp"]
104
+ }
105
+ }
106
+ }
107
+ ```
108
+ </details>
109
+
110
+ <details>
111
+ <summary><b>Claude Desktop</b></summary>
112
+
113
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
114
+
115
+ ```json
116
+ {
117
+ "mcpServers": {
118
+ "safari": {
119
+ "command": "npx",
120
+ "args": ["safari-mcp"]
121
+ }
122
+ }
123
+ }
124
+ ```
125
+
126
+ Restart Claude Desktop after saving.
127
+ </details>
128
+
129
+ <details>
130
+ <summary><b>Cursor</b></summary>
131
+
132
+ One-click: [**Install in Cursor**](cursor://anysphere.cursor-deeplink/mcp/install?name=safari-mcp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D)
133
+
134
+ Or edit `.cursor/mcp.json` in your project:
84
135
 
85
136
  ```json
86
137
  {
@@ -92,18 +143,120 @@ Add to your client's config file — all clients use the same JSON:
92
143
  }
93
144
  }
94
145
  ```
146
+ </details>
95
147
 
96
148
  <details>
97
- <summary><b>Where does each client store this?</b></summary>
149
+ <summary><b>VS Code / VS Code Insiders</b></summary>
150
+
151
+ One-click: [**Install in VS Code**](vscode:mcp/install?%7B%22safari-mcp%22%3A%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D%7D)
98
152
 
99
- | Client | Config file |
100
- |--------|------------|
101
- | **Claude Code** | `~/.mcp.json` |
102
- | **Claude Desktop** | `~/Library/Application Support/Claude/claude_desktop_config.json` |
103
- | **Cursor** | `.cursor/mcp.json` in your project |
104
- | **Windsurf** | `.windsurf/mcp.json` in your project |
105
- | **VS Code + Continue** | `.vscode/mcp.json` in your project |
153
+ Or edit `.vscode/mcp.json`:
154
+
155
+ ```json
156
+ {
157
+ "servers": {
158
+ "safari": {
159
+ "type": "stdio",
160
+ "command": "npx",
161
+ "args": ["safari-mcp"]
162
+ }
163
+ }
164
+ }
165
+ ```
166
+ </details>
167
+
168
+ <details>
169
+ <summary><b>Windsurf</b></summary>
106
170
 
171
+ Edit `.windsurf/mcp.json` in your project (or `~/.codeium/windsurf/mcp_config.json` globally):
172
+
173
+ ```json
174
+ {
175
+ "mcpServers": {
176
+ "safari": {
177
+ "command": "npx",
178
+ "args": ["safari-mcp"]
179
+ }
180
+ }
181
+ }
182
+ ```
183
+ </details>
184
+
185
+ <details>
186
+ <summary><b>Cline</b></summary>
187
+
188
+ Open Cline in VS Code → click the MCP icon → **Edit MCP Settings** → add:
189
+
190
+ ```json
191
+ {
192
+ "mcpServers": {
193
+ "safari": {
194
+ "command": "npx",
195
+ "args": ["safari-mcp"]
196
+ }
197
+ }
198
+ }
199
+ ```
200
+ </details>
201
+
202
+ <details>
203
+ <summary><b>Continue</b></summary>
204
+
205
+ Edit `~/.continue/config.yaml` (or `.continue/config.yaml` in workspace):
206
+
207
+ ```yaml
208
+ mcpServers:
209
+ - name: safari
210
+ command: npx
211
+ args:
212
+ - safari-mcp
213
+ ```
214
+ </details>
215
+
216
+ <details>
217
+ <summary><b>Goose</b></summary>
218
+
219
+ Edit `~/.config/goose/config.yaml`:
220
+
221
+ ```yaml
222
+ extensions:
223
+ safari:
224
+ name: safari
225
+ type: stdio
226
+ cmd: npx
227
+ args:
228
+ - safari-mcp
229
+ enabled: true
230
+ ```
231
+ </details>
232
+
233
+ <details>
234
+ <summary><b>LM Studio</b></summary>
235
+
236
+ Open LM Studio → **Settings** → **MCP Servers** → **Add Server**:
237
+
238
+ - **Name:** `safari`
239
+ - **Command:** `npx`
240
+ - **Args:** `safari-mcp`
241
+ </details>
242
+
243
+ <details>
244
+ <summary><b>Zed</b></summary>
245
+
246
+ Open Zed → **Settings** → search for "Context Servers" and add:
247
+
248
+ ```json
249
+ {
250
+ "context_servers": {
251
+ "safari": {
252
+ "command": {
253
+ "path": "npx",
254
+ "args": ["safari-mcp"]
255
+ }
256
+ }
257
+ }
258
+ }
259
+ ```
107
260
  </details>
108
261
 
109
262
  <details>
@@ -342,9 +342,15 @@ async function handleCommand(type, payload) {
342
342
  window[id] = { done: false };
343
343
  const s = document.createElement("script");
344
344
  const code = "try{var __r=(function(){" + script + "})();if(__r&&typeof __r.then==='function'){__r.then(function(v){window['" + id + "']={done:true,v:v};}).catch(function(e){window['" + id + "']={done:true,e:e.message};});}else{window['" + id + "']={done:true,v:__r};}}catch(e){window['" + id + "']={done:true,e:e.message};}";
345
- if (window.trustedTypes && window.trustedTypes.createPolicy) {
345
+ // Prefer the policy pre-registered by content.js at document_start —
346
+ // pages that block new policy creation post-load (GSC, modern Google admin)
347
+ // still accept ours because it was grandfathered in before their CSP applied.
348
+ if (window.__mcpTrustedPolicy && typeof window.__mcpTrustedPolicy.createScript === "function") {
349
+ try { s.textContent = window.__mcpTrustedPolicy.createScript(code); }
350
+ catch (_) { s.textContent = code; }
351
+ } else if (window.trustedTypes && window.trustedTypes.createPolicy) {
346
352
  try {
347
- const policy = window.trustedTypes.createPolicy("mcpEval", { createScript: (s) => s });
353
+ const policy = window.trustedTypes.createPolicy("mcpEval_" + Date.now(), { createScript: (s) => s });
348
354
  s.textContent = policy.createScript(code);
349
355
  } catch (_) { s.textContent = code; }
350
356
  } else {
@@ -1,7 +1,11 @@
1
- // Content script — runs at document_start in MAIN world (before page scripts)
2
- // Purpose: Monkey-patch attachShadow to capture CLOSED shadow roots.
3
- // Without this, Reddit and other sites using closed shadow DOM
4
- // are invisible to snapshot/click/fill.
1
+ // Content script — runs at document_start in MAIN world (before page scripts).
2
+ // Two responsibilities:
3
+ // 1. Monkey-patch attachShadow to capture CLOSED shadow roots (Reddit, etc.).
4
+ // 2. Pre-register a Trusted Types policy named "mcpBridge" BEFORE the page sets
5
+ // its own require-trusted-types-for directive. Our policy is then grandfathered
6
+ // and survives even on pages (Google Search Console, Google admin, modern banks)
7
+ // that block new policy creation after page load. MCP evaluate strategies
8
+ // consult `window.__mcpTrustedPolicy` first.
5
9
  // Runs in MAIN world via manifest "world": "MAIN" — no script injection needed,
6
10
  // so CSP cannot block it.
7
11
 
@@ -21,3 +25,16 @@ if (!window.__mcpShadowPatched) {
21
25
  return el.shadowRoot || _closedRoots.get(el) || null;
22
26
  };
23
27
  }
28
+
29
+ if (!window.__mcpTrustedPolicy && window.trustedTypes && typeof window.trustedTypes.createPolicy === "function") {
30
+ try {
31
+ window.__mcpTrustedPolicy = window.trustedTypes.createPolicy("mcpBridge", {
32
+ createScript: function (s) { return s; },
33
+ createScriptURL: function (s) { return s; },
34
+ createHTML: function (s) { return s; }
35
+ });
36
+ } catch (_e) {
37
+ // Page already restricts policies — rare since content script runs at document_start
38
+ // before page scripts. Leave undefined; evaluate fallbacks will probe other paths.
39
+ }
40
+ }
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "name": "Safari MCP Bridge",
4
4
  "description": "Connects AI agents to Safari — fast JS execution, screenshots, clicks with your real cookies/logins",
5
- "version": "2.9.1",
5
+ "version": "2.10.1",
6
6
  "icons": {
7
7
  "48": "images/icon-48.png",
8
8
  "96": "images/icon-96.png",
package/index.js CHANGED
@@ -1071,6 +1071,19 @@ server.tool(
1071
1071
  }
1072
1072
  );
1073
1073
 
1074
+ server.tool(
1075
+ "safari_verify_state",
1076
+ "Verify the framework-level state of an editor/input matches the expected value. Returns JSON {match, mode, actual, expected, hint?}. Modern editors (ProseMirror, Lexical, Closure, React-controlled inputs) maintain state separately from the DOM — `.value` or `.textContent` may show new text while the internal store still holds old data, so a Submit click sends stale data. Call this AFTER safari_fill and BEFORE clicking Submit on critical forms (Featured.com, LinkedIn share, Medium, Reddit).",
1077
+ {
1078
+ selector: z.string().describe("CSS selector of the editor/input to verify"),
1079
+ expected: z.string().describe("Expected value or text fragment that should appear in framework state"),
1080
+ },
1081
+ async (args) => {
1082
+ const result = await safari.verifyState(args);
1083
+ return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
1084
+ }
1085
+ );
1086
+
1074
1087
  server.tool(
1075
1088
  "safari_select_option",
1076
1089
  "Select an option in a native <select> dropdown. Sets .value and dispatches change event. For custom dropdowns (React/LinkedIn), use safari_click on the dropdown trigger, then safari_click on the option instead.",
package/mcp-helpers.js CHANGED
@@ -178,42 +178,72 @@ if (window.__mcpVersion !== 5) {
178
178
  window.mcpReactClick = function(el) {
179
179
  var startNode = window.mcpResolveTarget(el) || el;
180
180
  if (!startNode) return false;
181
+ // React 18-compatible SyntheticEvent — includes isDefaultPrevented/isPropagationStopped
182
+ // methods that React internally checks. Without them, some portal-rendered components
183
+ // (HackerNoon modal Submit, Radix dropdowns) silently no-op.
181
184
  function makeSynth(targetNode, type) {
182
185
  var r = targetNode.getBoundingClientRect();
183
- return { type: type || 'click', target: targetNode, currentTarget: targetNode,
184
- 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,
185
- preventDefault: function() {}, stopPropagation: function() {}, nativeEvent: new MouseEvent(type || 'click'), persist: function() {}, bubbles: true, cancelable: true };
186
+ var native = type === 'click' || type === 'mousedown' || type === 'mouseup' || type === 'mousemove'
187
+ ? new MouseEvent(type, { bubbles: true, cancelable: true, clientX: r.left + r.width / 2, clientY: r.top + r.height / 2 })
188
+ : type === 'pointerdown' || type === 'pointerup'
189
+ ? new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse' })
190
+ : new Event(type || 'click', { bubbles: true, cancelable: true });
191
+ var defaultPrevented = false, propagationStopped = false;
192
+ return {
193
+ type: type || 'click', target: targetNode, currentTarget: targetNode,
194
+ clientX: r.left + r.width / 2, clientY: r.top + r.height / 2,
195
+ pageX: r.left + r.width / 2 + window.scrollX, pageY: r.top + r.height / 2 + window.scrollY,
196
+ screenX: r.left + r.width / 2, screenY: r.top + r.height / 2,
197
+ button: 0, buttons: 0, detail: 1,
198
+ altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
199
+ preventDefault: function() { defaultPrevented = true; if (native.preventDefault) native.preventDefault(); },
200
+ stopPropagation: function() { propagationStopped = true; if (native.stopPropagation) native.stopPropagation(); },
201
+ stopImmediatePropagation: function() { propagationStopped = true; },
202
+ isDefaultPrevented: function() { return defaultPrevented; },
203
+ isPropagationStopped: function() { return propagationStopped; },
204
+ defaultPrevented: false, cancelBubble: false,
205
+ nativeEvent: native, persist: function() {},
206
+ bubbles: true, cancelable: true, eventPhase: 2, isTrusted: false,
207
+ timeStamp: Date.now(), composed: true, view: window
208
+ };
186
209
  }
187
210
  // For React radio/checkbox inputs, onChange is the primary handler (not onClick)
188
211
  var isToggle = startNode.tagName === 'INPUT' && (startNode.type === 'radio' || startNode.type === 'checkbox');
189
212
  function tryOnChange(propsObj, targetNode) {
190
213
  if (!isToggle || !propsObj.onChange) return false;
191
214
  var newChecked = startNode.type === 'radio' ? true : !startNode.checked;
192
- propsObj.onChange({ target: { value: startNode.value, checked: newChecked, type: startNode.type, name: startNode.name, id: startNode.id, tagName: 'INPUT' },
193
- currentTarget: targetNode, preventDefault: function() {}, stopPropagation: function() {}, nativeEvent: new Event('change'), persist: function() {}, bubbles: true, cancelable: true, type: 'change' });
215
+ var ev = makeSynth(targetNode, 'change');
216
+ ev.target = { value: startNode.value, checked: newChecked, type: startNode.type, name: startNode.name, id: startNode.id, tagName: 'INPUT' };
217
+ propsObj.onClick && propsObj.onClick(ev);
218
+ propsObj.onChange(ev);
194
219
  return true;
195
220
  }
221
+ // Try a single React props bag: returns true if ANY handler fired.
222
+ // Order matters — onPointerDown/onMouseDown sometimes opens modals, then onClick commits.
223
+ function tryProps(propsObj, targetNode) {
224
+ if (!propsObj) return false;
225
+ if (tryOnChange(propsObj, targetNode)) return true;
226
+ var fired = false;
227
+ if (typeof propsObj.onPointerDown === 'function') { try { propsObj.onPointerDown(makeSynth(targetNode, 'pointerdown')); fired = true; } catch (_e) {} }
228
+ if (typeof propsObj.onMouseDown === 'function') { try { propsObj.onMouseDown(makeSynth(targetNode, 'mousedown')); fired = true; } catch (_e) {} }
229
+ if (typeof propsObj.onMouseUp === 'function') { try { propsObj.onMouseUp(makeSynth(targetNode, 'mouseup')); fired = true; } catch (_e) {} }
230
+ if (typeof propsObj.onPointerUp === 'function') { try { propsObj.onPointerUp(makeSynth(targetNode, 'pointerup')); fired = true; } catch (_e) {} }
231
+ if (typeof propsObj.onClick === 'function') { try { propsObj.onClick(makeSynth(targetNode, 'click')); fired = true; } catch (_e) {} }
232
+ return fired;
233
+ }
196
234
  var node = startNode;
197
235
  for (var depth = 0; depth < 15 && node; depth++) {
198
236
  var pk = Object.keys(node).find(function(k) { return k.startsWith('__reactProps$'); });
199
- if (pk && node[pk]) {
200
- var props = node[pk];
201
- if (tryOnChange(props, node)) return true;
202
- if (props.onClick) { props.onClick(makeSynth(node, 'click')); return true; }
203
- if (props.onMouseDown) { props.onMouseDown(makeSynth(node, 'mousedown')); return true; }
204
- if (props.onPointerDown) { props.onPointerDown(makeSynth(node, 'pointerdown')); return true; }
205
- }
237
+ if (pk && node[pk] && tryProps(node[pk], node)) return true;
206
238
  var fk = Object.keys(node).find(function(k) { return k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'); });
207
239
  if (fk) {
208
- var f = node[fk];
209
- while (f) {
210
- if (f.memoizedProps) {
211
- if (tryOnChange(f.memoizedProps, node)) return true;
212
- if (f.memoizedProps.onClick) { f.memoizedProps.onClick(makeSynth(node, 'click')); return true; }
213
- if (f.memoizedProps.onMouseDown) { f.memoizedProps.onMouseDown(makeSynth(node, 'mousedown')); return true; }
214
- if (f.memoizedProps.onPointerDown) { f.memoizedProps.onPointerDown(makeSynth(node, 'pointerdown')); return true; }
215
- }
216
- f = f.return;
240
+ // Walk fiber chain UP via .return (covers portals — Fiber tree is logical, not DOM-shaped).
241
+ var f = node[fk], hops = 0;
242
+ while (f && hops < 30) {
243
+ if (tryProps(f.memoizedProps, node)) return true;
244
+ // Components written as forwardRef put the click handler on stateNode.props
245
+ if (f.stateNode && f.stateNode.props && tryProps(f.stateNode.props, node)) return true;
246
+ f = f.return; hops++;
217
247
  }
218
248
  }
219
249
  node = node.parentElement;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.9.3",
3
+ "version": "2.10.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
@@ -158,6 +158,14 @@ async function _restoreClipboard(savedContent) {
158
158
  // to the current index.
159
159
  let _activeTabIndex = null; // null = use front document (default)
160
160
  let _activeTabURL = null; // URL-based tracking (stable even when tabs shift)
161
+ // Timestamp of most recent new_tab — within this grace window we MUST NOT fall
162
+ // back to "current tab of window" (which is the USER'S active tab). New tabs
163
+ // often start at about:blank if their URL fails to load (file://, blocked, etc.),
164
+ // and the URL-based marker resolution can fail until the page actually loads.
165
+ // During the grace window, navigate/click/fill operations either use the cached
166
+ // _activeTabIndex or fail loudly — never silently target the user's tab.
167
+ let _lastNewTabAt = 0;
168
+ const NEW_TAB_GRACE_MS = 30000;
161
169
  let _lastResolveTime = 0; // Cache: skip resolve if verified recently
162
170
  let _lastTabCount = null; // Track tab count for smart cache invalidation
163
171
  let _activeTabMarker = null; // window.__mcpTabMarker — survives same-tab navigation, bulletproof tab identity
@@ -272,6 +280,20 @@ function isStaleWindowError(err) {
272
280
 
273
281
  // Safe fallback target: when no tab index is known, use the profile window's current tab
274
282
  // instead of "front document" which can target the user's personal profile window
283
+ // Throw if a write operation is about to fall back to the user's active tab
284
+ // during the new-tab grace window. Without this, navigate/fill/click silently
285
+ // target whatever tab the user is looking at when our cached index is lost.
286
+ function _assertNotFallingBackToUserTab(opName) {
287
+ if (_activeTabIndex) return; // we have a tracked index — fine
288
+ const sinceNew = Date.now() - _lastNewTabAt;
289
+ if (sinceNew < NEW_TAB_GRACE_MS) {
290
+ throw new Error(
291
+ `Tab tracking lost within ${Math.round(sinceNew/1000)}s of safari_new_tab — refusing to ${opName} via fallback (would target the user's active tab). ` +
292
+ `Re-run safari_new_tab and retry.`
293
+ );
294
+ }
295
+ }
296
+
275
297
  function getFallbackTarget() {
276
298
  return SAFARI_PROFILE ? `current tab of ${getTargetWindowRef()}` : "front document";
277
299
  }
@@ -826,6 +848,7 @@ export async function navigate(url) {
826
848
  const safeUrl = targetUrl.replace(/"/g, '\\"');
827
849
  // Resolve tab by URL first (in case indices shifted)
828
850
  if (_activeTabURL) await resolveActiveTab();
851
+ _assertNotFallingBackToUserTab('navigate');
829
852
  const navTarget = _activeTabIndex
830
853
  ? `tab ${_activeTabIndex} of ${getTargetWindowRef()}`
831
854
  : getFallbackTarget();
@@ -904,6 +927,17 @@ export async function navigate(url) {
904
927
  }
905
928
  _lastResolveTime = Date.now();
906
929
 
930
+ // Re-inject the tab marker — navigation creates a new JS context which wipes the
931
+ // window.__mcpTabMarker we set in newTab. Without re-injection, resolveActiveTab's
932
+ // marker-based search fails on the very next call and falls back to URL search,
933
+ // which can mis-target if the tab is still loading or shows an error page.
934
+ if (_activeTabIndex && _activeTabMarker) {
935
+ const safeMarker = _activeTabMarker.replace(/'/g, "\\'");
936
+ await osascriptFast(
937
+ `tell application "Safari" to do JavaScript "window.__mcpTabMarker='${safeMarker}'" in tab ${_activeTabIndex} of ${getTargetWindowRef()}`
938
+ ).catch(() => {});
939
+ }
940
+
907
941
  return result;
908
942
  }
909
943
 
@@ -1042,32 +1076,39 @@ async function clickWithRetry(js) {
1042
1076
 
1043
1077
  export async function click({ selector, text, x, y, ref }) {
1044
1078
  await ensureHelpers();
1079
+ // Native <select> elements look like custom dropdowns on LinkedIn etc., but they
1080
+ // hand off to the OS-level option list. .click() on them blocks AppleScript until
1081
+ // the native popup is dismissed → the JSC eval times out and the tool returns an
1082
+ // error after seconds of hang. Detect early and return a clear directive instead.
1083
+ const selectGuard = `if(el&&el.tagName==='SELECT'){var opts=[];for(var oi=0;oi<el.options.length&&opts.length<8;oi++){opts.push(el.options[oi].text||el.options[oi].value);}return '__SELECT_GUARD__:'+(el.id||el.name||'select')+': '+opts.join('|');}`;
1084
+ let result;
1045
1085
  if (ref) {
1046
- return clickWithRetry(
1047
- `(function(){var el=mcpFindRef('${ref}');if(!el)return 'Element not found: ref=${ref}';var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+((target.innerText||target.textContent)?(' "'+(target.innerText||target.textContent).trim().substring(0,50)+'"'):'');})()`
1086
+ result = await clickWithRetry(
1087
+ `(function(){var el=mcpFindRef('${ref}');if(!el)return 'Element not found: ref=${ref}';${selectGuard}var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+((target.innerText||target.textContent)?(' "'+(target.innerText||target.textContent).trim().substring(0,50)+'"'):'');})()`
1048
1088
  );
1049
- }
1050
-
1051
- if (selector) {
1089
+ } else if (selector) {
1052
1090
  const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1053
- return clickWithRetry(
1054
- `(function(){var el=mcpQuerySelectorDeep('${sel}');if(!el)return 'Element not found: ${sel}';var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+((target.innerText||target.textContent)?(' "'+(target.innerText||target.textContent).trim().substring(0,50)+'"'):'');})()`
1091
+ result = await clickWithRetry(
1092
+ `(function(){var el=mcpQuerySelectorDeep('${sel}');if(!el)return 'Element not found: ${sel}';${selectGuard}var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+((target.innerText||target.textContent)?(' "'+(target.innerText||target.textContent).trim().substring(0,50)+'"'):'');})()`
1055
1093
  );
1056
- }
1057
-
1058
- if (text) {
1094
+ } else if (text) {
1059
1095
  const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1060
- return clickWithRetry(
1061
- `(function(){var el=mcpFindText('${safeText}',true)||mcpFindText('${safeText}',false);if(!el)return 'Element not found with text: ${safeText}';var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+' "'+((target.innerText||target.textContent)||'').trim().substring(0,50)+'"';})()`
1096
+ result = await clickWithRetry(
1097
+ `(function(){var el=mcpFindText('${safeText}',true)||mcpFindText('${safeText}',false);if(!el)return 'Element not found with text: ${safeText}';${selectGuard}var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+' "'+((target.innerText||target.textContent)||'').trim().substring(0,50)+'"';})()`
1098
+ );
1099
+ } else if (x !== undefined && y !== undefined) {
1100
+ result = await clickWithRetry(
1101
+ `(function(){var el=mcpElementFromPoint(${Number(x)},${Number(y)});if(!el)return 'No element at (${Number(x)},${Number(y)})';${selectGuard}var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+' at (${Number(x)},${Number(y)})';})()`
1062
1102
  );
1103
+ } else {
1104
+ throw new Error("click requires selector, text, or x/y coordinates");
1063
1105
  }
1064
1106
 
1065
- if (x !== undefined && y !== undefined) {
1066
- return clickWithRetry(
1067
- `(function(){var el=mcpElementFromPoint(${Number(x)},${Number(y)});if(!el)return 'No element at (${Number(x)},${Number(y)})';var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+' at (${Number(x)},${Number(y)})';})()`
1068
- );
1107
+ if (typeof result === 'string' && result.startsWith('__SELECT_GUARD__:')) {
1108
+ const detail = result.substring('__SELECT_GUARD__:'.length);
1109
+ throw new Error(`Target is a native <select> (${detail}). Use safari_select_option with a value matching one of the options instead — clicking it would open the OS picker and block.`);
1069
1110
  }
1070
- throw new Error("click requires selector, text, or x/y coordinates");
1111
+ return result;
1071
1112
  }
1072
1113
 
1073
1114
  export async function doubleClick({ selector, x, y, ref }) {
@@ -1289,18 +1330,60 @@ export async function fill({ selector, value, ref }) {
1289
1330
  const val = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
1290
1331
  const result = await runJS(
1291
1332
  `(function(){try{var el=document.querySelector('${sel}');if(!el){var q=function(r){var a=r.querySelectorAll('*');for(var i=0;i<a.length;i++){if(a[i].shadowRoot){el=a[i].shadowRoot.querySelector('${sel}');if(el)return el;el=q(a[i].shadowRoot);if(el)return el;}}return null;};el=q(document);}if(!el)return 'Element not found: ${sel}';el.focus();if(el.isContentEditable||el.getAttribute('contenteditable')==='true'){` +
1333
+ // Lexical editor detection (LinkedIn share composer, modern Meta/Shopify apps).
1334
+ // Three lookup strategies, cheapest first:
1335
+ // A) [data-lexical-editor="true"] on ancestor with __lexicalEditor property.
1336
+ // B) Walk DOM ancestors for __lexicalEditor (nested-config wrappers).
1337
+ // C) React Fiber walk — LinkedIn sometimes obfuscates __lexicalEditor, so we
1338
+ // locate the editor by duck-typing: any object on props/state/stateNode
1339
+ // that exposes both parseEditorState AND setEditorState is the editor.
1340
+ // Once found, two fill strategies, both pure DOM (no CGEvent, no focus shift):
1341
+ // 1) InputEvent('beforeinput', insertFromPaste + DataTransfer) — Lexical's
1342
+ // handleBeforeInput reads dataTransfer and commits via editor.update().
1343
+ // 2) parseEditorState() + setEditorState() with a minimal paragraph doc —
1344
+ // used when Lexical ignores synthetic paste events.
1345
+ `var lexEl=el.closest('[data-lexical-editor="true"]');var lex=(lexEl&&lexEl.__lexicalEditor)||null;` +
1346
+ `if(!lex){var lexCur=el;for(var lexI=0;lexI<15&&lexCur;lexI++){if(lexCur.__lexicalEditor){lexEl=lexCur;lex=lexCur.__lexicalEditor;break;}lexCur=lexCur.parentElement;}}` +
1347
+ `if(!lex){var lexHost=lexEl||el;var lexFk=null;for(var lfk in lexHost){if(lfk.indexOf('__reactFiber')===0||lfk.indexOf('__reactInternalInstance')===0){lexFk=lfk;break;}}` +
1348
+ `if(lexFk){var lexF=lexHost[lexFk];var lexIsEd=function(o){return o&&typeof o.parseEditorState==='function'&&typeof o.setEditorState==='function';};` +
1349
+ `for(var lexD=0;lexD<30&&lexF;lexD++){var lp=lexF.memoizedProps;if(lp){for(var lpk in lp){if(lexIsEd(lp[lpk])){lex=lp[lpk];break;}}}` +
1350
+ `if(!lex){var ls=lexF.memoizedState;while(ls){if(lexIsEd(ls.memoizedState)){lex=ls.memoizedState;break;}ls=ls.next;}}` +
1351
+ `if(!lex){var lsn=lexF.stateNode;if(lsn){if(lexIsEd(lsn))lex=lsn;else if(lsn.editor&&lexIsEd(lsn.editor))lex=lsn.editor;}}` +
1352
+ `if(lex)break;lexF=lexF.return;}}}` +
1353
+ `if(lex){if(!lexEl){lexEl=lex._rootElement||el;}` +
1354
+ // Strategy 1: beforeinput with insertFromPaste + DataTransfer
1355
+ `try{lexEl.focus();var lexSel=window.getSelection();if(lexSel){var lexRng=document.createRange();lexRng.selectNodeContents(lexEl);lexSel.removeAllRanges();lexSel.addRange(lexRng);}var lexDt=new DataTransfer();lexDt.setData('text/plain','${val}');var lexBi=new InputEvent('beforeinput',{inputType:'insertFromPaste',dataTransfer:lexDt,bubbles:true,cancelable:true});var lexCancelled=!lexEl.dispatchEvent(lexBi);if(lexCancelled||lexEl.textContent.indexOf('${val.substring(0, 20)}')>=0){return 'Filled CE (Lexical beforeinput paste)';}}catch(lexE1){}` +
1356
+ // Strategy 2: parseEditorState + setEditorState (direct state replacement)
1357
+ `try{var lexJson='{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"${val}","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"textFormat":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}';var lexNs=lex.parseEditorState(lexJson);lex.setEditorState(lexNs);if(typeof lex.focus==='function')lex.focus();return 'Filled CE (Lexical setEditorState)';}catch(lexE2){}` +
1358
+ `}` +
1292
1359
  // ProseMirror detection
1293
1360
  `var pm=el.closest('.ProseMirror')||el.querySelector('.ProseMirror');if(pm){try{var v=null;if(pm.pmViewDesc&&pm.pmViewDesc.view)v=pm.pmViewDesc.view;else if(pm.cmView&&pm.cmView.view)v=pm.cmView.view;else{var keys=Object.keys(pm);for(var ki=0;ki<keys.length;ki++){var o=pm[keys[ki]];if(o&&o.state&&o.dispatch){v=o;break;}}}` +
1294
1361
  // React Fiber walk for ProseMirror view (LinkedIn, Tiptap-React)
1295
1362
  `if(!v){var fk=Object.keys(pm).find(function(k){return k.startsWith('__reactFiber$')||k.startsWith('__reactInternalInstance$');});if(fk){var fiber=pm[fk];for(var d=0;d<20&&fiber;d++){var props=fiber.memoizedProps||(fiber.stateNode&&fiber.stateNode.props);if(props){var pv=props.editorView||props.view;if(pv&&pv.state&&pv.dispatch){v=pv;break;}}fiber=fiber.return;}}}` +
1296
- `if(v&&v.state&&v.dispatch){var doc=v.state.doc;var hasContent=doc.textContent&&doc.textContent.trim().length>0;if(hasContent){var endPos=doc.content.size>1?doc.content.size-1:doc.content.size;v.dispatch(v.state.tr.insertText(' ${val}',endPos));v.focus();return 'Filled CE (ProseMirror append)';}else{var tr=v.state.tr;tr.replaceWith(0,doc.content.size,v.state.schema.text('${val}'));v.dispatch(tr);v.focus();return 'Filled CE (ProseMirror replace)';}}}catch(e){}}` +
1363
+ `if(v&&v.state&&v.dispatch){try{var doc=v.state.doc;var hasContent=doc.textContent&&doc.textContent.trim().length>0;if(hasContent){var endPos=doc.content.size>1?doc.content.size-1:doc.content.size;v.dispatch(v.state.tr.insertText(' ${val}',endPos));}else{var tr=v.state.tr;tr.replaceWith(0,doc.content.size,v.state.schema.text('${val}'));v.dispatch(tr);}v.focus();` +
1364
+ // Verify the dispatch actually committed — LinkedIn's PM sometimes accepts the
1365
+ // tr but rolls it back on the next tick (its readOnly plugin reasserts state).
1366
+ // If the doc text doesn't contain our value within 50ms, route to native paste.
1367
+ `var pmCheck=(v.state.doc.textContent||'')+' '+(pm.textContent||'');` +
1368
+ `if(pmCheck.indexOf('${val.substring(0, Math.min(20, val.length))}')>=0){return 'Filled CE (ProseMirror)';}` +
1369
+ `}catch(_pmE){}}}catch(e){}}` +
1297
1370
  // ProseMirror detected but no view — in a dialog (LinkedIn share composer), go to
1298
1371
  // native paste (real CGEvent Cmd+V) so ProseMirror's isTrusted-gated paste handler
1299
1372
  // accepts the insertion and the dialog doesn't dismiss. Outside a dialog fall back
1300
1373
  // to char-by-char beforeinput (works on Discord-style PM without dismissal risk).
1301
1374
  `if(pm&&!v){var inDlg=!!el.closest('[role="dialog"]');if(inDlg){return '__NATIVE_PASTE_DIALOG__';}return '__PM_CHARBYCHAR__';}` +
1302
- // Closure/Medium detection — signal for native paste
1303
- `var isClosure=Object.keys(el).some(function(k){return k.startsWith('closure_uid_');})||location.hostname.includes('medium.com');if(isClosure){return '__CLOSURE_NATIVE_PASTE__';}` +
1375
+ // Closure/Medium detection — signal for native paste.
1376
+ // Match any of: closure_uid_ key on element, medium.com hostname, ancestor with
1377
+ // closure markers, or window.goog.events/goog.editor.Plugin globals (broader catch
1378
+ // for Closure-built editors outside Medium).
1379
+ `var isClosure=false;` +
1380
+ `try{` +
1381
+ `if(Object.keys(el).some(function(k){return k.indexOf('closure_uid_')===0||k.indexOf('closure_lm_')===0;}))isClosure=true;` +
1382
+ `else if(location.hostname.indexOf('medium.com')>=0)isClosure=true;` +
1383
+ `else if(typeof goog!=='undefined'&&goog&&(goog.events||goog.editor))isClosure=true;` +
1384
+ `else{var clCur=el.parentElement,clHop=0;while(clCur&&clHop<10){if(Object.keys(clCur).some(function(k){return k.indexOf('closure_uid_')===0;})){isClosure=true;break;}clCur=clCur.parentElement;clHop++;}}` +
1385
+ `}catch(_clE){}` +
1386
+ `if(isClosure){return '__CLOSURE_NATIVE_PASTE__';}` +
1304
1387
  // Synthetic ClipboardEvent paste — works on ProseMirror, TipTap, Slate, and most modern editors
1305
1388
  // that don't respond to execCommand but DO handle paste events
1306
1389
  `try{el.focus();var sel2=window.getSelection();if(sel2.rangeCount){var rng=document.createRange();rng.selectNodeContents(el);sel2.removeAllRanges();sel2.addRange(rng);}var dt=new DataTransfer();dt.setData('text/plain','${val}');var pe=new ClipboardEvent('paste',{bubbles:true,cancelable:true,clipboardData:dt});var handled=!el.dispatchEvent(pe);if(handled||el.textContent.indexOf('${val.substring(0, 20)}')>=0){return 'Filled CE (synthetic paste)';}}catch(ep){}` +
@@ -1310,8 +1393,26 @@ export async function fill({ selector, value, ref }) {
1310
1393
  // Default contenteditable: selectAll+delete+insert. No blur — blur dismisses dialogs
1311
1394
  // and nearby popovers; React only needs 'input' to see the change.
1312
1395
  `document.execCommand('selectAll');document.execCommand('delete');document.execCommand('insertText',false,'${val}');el.dispatchEvent(new Event('input',{bubbles:true}));return 'Filled contenteditable';}` +
1313
- // Standard input/textarea with _valueTracker
1314
- `var t=el._valueTracker;if(t)t.setValue('');var proto=el.tagName==='TEXTAREA'?window.HTMLTextAreaElement.prototype:window.HTMLInputElement.prototype;var s=Object.getOwnPropertyDescriptor(proto,'value');if(s&&s.set){s.set.call(el,'${val}');}else{el.value='${val}';}el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));el.dispatchEvent(new Event('blur',{bubbles:true}));el.dispatchEvent(new Event('focusout',{bubbles:true}));el.focus();return 'Filled: '+el.value.substring(0,50);}catch(e){return 'ERR: '+e.message;}})()`
1396
+ // Standard input/textarea with _valueTracker.
1397
+ // • focus() FIRST — Formik/HubSpot read focused state during onChange validation.
1398
+ // • InputEvent with inputType:'insertReplacementText' + data — React 18 RSC, Next.js,
1399
+ // and Featured.com require a real InputEvent (not plain Event) to update store state.
1400
+ // • composed:true on every event — pierces Shadow DOM (Reddit, web components).
1401
+ // • blur/focusout SUPPRESSED inside dialogs — blur dismisses LinkedIn share composer
1402
+ // and many MUI/Radix dialogs. React only needs 'input' to see the change anyway.
1403
+ // • Post-fill verification — if el.value !== expected after dispatch, signal the wrapper
1404
+ // to try native CGEvent paste fallback (real isTrusted events).
1405
+ `var inDlg=!!(el.closest&&el.closest('[role="dialog"]'));` +
1406
+ `var t=el._valueTracker;if(t)t.setValue('');` +
1407
+ `var proto=el.tagName==='TEXTAREA'?window.HTMLTextAreaElement.prototype:window.HTMLInputElement.prototype;` +
1408
+ `var s=Object.getOwnPropertyDescriptor(proto,'value');` +
1409
+ `if(s&&s.set){s.set.call(el,'${val}');}else{el.value='${val}';}` +
1410
+ `try{el.dispatchEvent(new InputEvent('input',{inputType:'insertReplacementText',data:'${val}',bubbles:true,composed:true,cancelable:true}));}catch(_iE){el.dispatchEvent(new Event('input',{bubbles:true,composed:true}));}` +
1411
+ `el.dispatchEvent(new Event('change',{bubbles:true,composed:true}));` +
1412
+ `if(!inDlg){el.dispatchEvent(new Event('blur',{bubbles:true,composed:true}));el.dispatchEvent(new Event('focusout',{bubbles:true,composed:true}));el.focus();}` +
1413
+ `var actual=(el.value!==undefined?el.value:'');` +
1414
+ `if(actual!=='${val}'){return '__FILL_VALUE_MISMATCH__:'+actual.substring(0,80);}` +
1415
+ `return 'Filled: '+actual.substring(0,50);}catch(e){return 'ERR: '+e.message;}})()`
1315
1416
  );
1316
1417
 
1317
1418
  // ProseMirror editor with no view access: use char-by-char with beforeinput events
@@ -1382,6 +1483,26 @@ export async function fill({ selector, value, ref }) {
1382
1483
  return fillResult;
1383
1484
  }
1384
1485
 
1486
+ // Standard input/textarea path returned with el.value mismatched against expected —
1487
+ // typically Next.js RSC (Featured.com) or HubSpot Formik silently ignored the property
1488
+ // descriptor setter. Fall back to native CGEvent Cmd+V paste — real keyboard events
1489
+ // route through the framework's normal input pipeline.
1490
+ if (typeof result === 'string' && result.startsWith('__FILL_VALUE_MISMATCH__')) {
1491
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1492
+ await runJS(
1493
+ `(function(){var el=document.querySelector('${sel}');if(!el)return 'not-found';` +
1494
+ `el.focus();if('select' in el){el.select();}else if(el.setSelectionRange){el.setSelectionRange(0,(el.value||'').length);}` +
1495
+ `return 'focused';})()`
1496
+ );
1497
+ await new Promise(r => setTimeout(r, 30));
1498
+ try {
1499
+ await _nativeTypeViaClipboard(value);
1500
+ return `Filled (native paste fallback after value-mismatch, ${value.length} chars)`;
1501
+ } catch (e) {
1502
+ return `${result} (native paste fallback failed: ${e.message})`;
1503
+ }
1504
+ }
1505
+
1385
1506
  // ProseMirror/contenteditable inside a dialog (LinkedIn share composer is the canonical
1386
1507
  // case). Synthetic events get rejected by isTrusted-gated paste handlers, and stray blur
1387
1508
  // events dismiss the dialog. Use CGEvent Cmd+V — real paste, isTrusted:true, windowed so
@@ -1407,6 +1528,90 @@ export async function fill({ selector, value, ref }) {
1407
1528
  return result;
1408
1529
  }
1409
1530
 
1531
+ // Verify the framework-level state of an editor or input matches `expected`.
1532
+ // Modern editors (ProseMirror, Lexical, Closure, React-controlled inputs) maintain
1533
+ // state separately from the DOM — `.value` or `.textContent` can show the new text
1534
+ // while the framework's internal store still holds the old value, so a Submit click
1535
+ // sends the stale data. Call this after `fill` and BEFORE `click`-Submit.
1536
+ //
1537
+ // Returns JSON: { match: boolean, mode: 'input'|'prosemirror'|'lexical'|'closure'|'contenteditable',
1538
+ // actual: string, expected: string, hint?: string }
1539
+ export async function verifyState({ selector, expected, ref }) {
1540
+ if (ref) selector = refSelector(ref);
1541
+ if (!selector) throw new Error("verifyState requires selector or ref");
1542
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1543
+ const exp = String(expected || '').replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
1544
+ const expSnippet = String(expected || '').substring(0, 30).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
1545
+ return runJS(
1546
+ `(function(){
1547
+ var el = document.querySelector('${sel}');
1548
+ if (!el && window.mcpQuerySelectorDeep) el = window.mcpQuerySelectorDeep('${sel}');
1549
+ if (!el) return JSON.stringify({ match: false, mode: 'not-found', actual: '', expected: '${exp}' });
1550
+
1551
+ var expected = '${exp}';
1552
+ var snippet = '${expSnippet}';
1553
+
1554
+ // ProseMirror — check view state, not DOM
1555
+ var pm = el.closest && el.closest('.ProseMirror') || el.querySelector && el.querySelector('.ProseMirror');
1556
+ if (pm) {
1557
+ var v = (pm.pmViewDesc && pm.pmViewDesc.view) || (pm.cmView && pm.cmView.view) || null;
1558
+ if (!v) {
1559
+ var keys = Object.keys(pm);
1560
+ for (var ki = 0; ki < keys.length; ki++) { var o = pm[keys[ki]]; if (o && o.state && o.dispatch) { v = o; break; } }
1561
+ }
1562
+ if (!v) {
1563
+ var fk = Object.keys(pm).find(function(k){return k.indexOf('__reactFiber')===0||k.indexOf('__reactInternalInstance')===0;});
1564
+ if (fk) { var fb = pm[fk]; for (var d=0; d<20 && fb; d++){ var pp=fb.memoizedProps||(fb.stateNode&&fb.stateNode.props); if (pp){ var pv=pp.editorView||pp.view; if (pv&&pv.state){ v=pv; break; }} fb=fb.return; }}
1565
+ }
1566
+ var pmText = v && v.state && v.state.doc ? (v.state.doc.textContent || '') : (pm.textContent || '');
1567
+ return JSON.stringify({ match: pmText.indexOf(snippet) >= 0, mode: 'prosemirror', actual: pmText.substring(0, 200), expected: expected.substring(0, 200) });
1568
+ }
1569
+
1570
+ // Lexical — read editor state
1571
+ var lexEl = el.closest && el.closest('[data-lexical-editor="true"]');
1572
+ var lex = lexEl && lexEl.__lexicalEditor;
1573
+ if (!lex) {
1574
+ var lc = el; for (var li = 0; li < 15 && lc; li++) { if (lc.__lexicalEditor) { lex = lc.__lexicalEditor; break; } lc = lc.parentElement; }
1575
+ }
1576
+ if (lex && typeof lex.getEditorState === 'function') {
1577
+ try {
1578
+ var lexText = '';
1579
+ lex.getEditorState().read(function(){
1580
+ var root = lex._editorState && lex._editorState._nodeMap ? lex._editorState._nodeMap.get('root') : null;
1581
+ // Fallback: read DOM text since direct API requires Lexical's $getRoot
1582
+ lexText = (lexEl || el).textContent || '';
1583
+ });
1584
+ return JSON.stringify({ match: lexText.indexOf(snippet) >= 0, mode: 'lexical', actual: lexText.substring(0, 200), expected: expected.substring(0, 200) });
1585
+ } catch (_lE) {}
1586
+ }
1587
+
1588
+ // Closure (Medium and similar) — check element textContent (Closure mutates DOM directly)
1589
+ var isClosure = Object.keys(el).some(function(k){return k.indexOf('closure_uid_')===0;}) || location.hostname.indexOf('medium.com') >= 0;
1590
+ if (isClosure) {
1591
+ var clText = el.textContent || '';
1592
+ return JSON.stringify({ match: clText.indexOf(snippet) >= 0, mode: 'closure', actual: clText.substring(0, 200), expected: expected.substring(0, 200) });
1593
+ }
1594
+
1595
+ // Generic contenteditable
1596
+ if (el.isContentEditable) {
1597
+ var ceText = el.textContent || '';
1598
+ return JSON.stringify({ match: ceText.indexOf(snippet) >= 0, mode: 'contenteditable', actual: ceText.substring(0, 200), expected: expected.substring(0, 200) });
1599
+ }
1600
+
1601
+ // Standard input/textarea/select
1602
+ var actualVal = el.value !== undefined ? String(el.value) : '';
1603
+ var match = actualVal === expected;
1604
+ var hint = '';
1605
+ // Detect React store/_valueTracker mismatch — common Featured.com / Next.js RSC bug
1606
+ if (!match && el._valueTracker && typeof el._valueTracker.getValue === 'function') {
1607
+ var tracked = el._valueTracker.getValue();
1608
+ if (tracked !== actualVal) hint = 'React _valueTracker out of sync (tracked=' + JSON.stringify(tracked.substring(0, 60)) + ')';
1609
+ }
1610
+ return JSON.stringify({ match: match, mode: 'input', actual: actualVal.substring(0, 200), expected: expected.substring(0, 200), hint: hint });
1611
+ })()`
1612
+ );
1613
+ }
1614
+
1410
1615
  export async function clearField({ selector }) {
1411
1616
  const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1412
1617
  return runJS(
@@ -1423,7 +1628,9 @@ export async function selectOption({ selector, value }) {
1423
1628
  }
1424
1629
 
1425
1630
  export async function fillForm({ fields }) {
1426
- // Single JS call for ALL fields (instead of N separate osascript calls)
1631
+ // Single JS call for ALL fields (instead of N separate osascript calls).
1632
+ // Same React-state-sync logic as `fill`: _valueTracker reset, prototype-correct setter,
1633
+ // InputEvent with inputType, composed events for shadow DOM, dialog-aware blur.
1427
1634
  const fieldsJSON = JSON.stringify(fields.map(f => ({
1428
1635
  s: f.selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'"),
1429
1636
  v: f.value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n"),
@@ -1434,20 +1641,42 @@ export async function fillForm({ fields }) {
1434
1641
  var results = [];
1435
1642
  fields.forEach(function(f) {
1436
1643
  var el = document.querySelector(f.s);
1644
+ if (!el) {
1645
+ var roots = window.mcpCollectRoots ? window.mcpCollectRoots() : [document];
1646
+ for (var ri = 0; ri < roots.length && !el; ri++) {
1647
+ try { el = roots[ri].querySelector(f.s); } catch (_e) {}
1648
+ }
1649
+ }
1437
1650
  if (!el) { results.push('Not found: ' + f.s); return; }
1438
1651
  el.focus();
1652
+ var inDlg = !!(el.closest && el.closest('[role="dialog"]'));
1439
1653
  if (el.isContentEditable) {
1440
- el.textContent = '';
1654
+ try {
1655
+ var sel = window.getSelection();
1656
+ if (sel) { var rng = document.createRange(); rng.selectNodeContents(el); sel.removeAllRanges(); sel.addRange(rng); }
1657
+ document.execCommand('delete');
1658
+ } catch (_seE) {}
1441
1659
  document.execCommand('insertText', false, f.v);
1660
+ el.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
1442
1661
  } else {
1443
- var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value') ||
1444
- Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value');
1662
+ var t = el._valueTracker; if (t) t.setValue('');
1663
+ var proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype :
1664
+ el.tagName === 'SELECT' ? window.HTMLSelectElement.prototype :
1665
+ window.HTMLInputElement.prototype;
1666
+ var setter = Object.getOwnPropertyDescriptor(proto, 'value');
1445
1667
  if (setter && setter.set) setter.set.call(el, f.v);
1446
1668
  else el.value = f.v;
1447
- el.dispatchEvent(new Event('input', {bubbles: true}));
1448
- el.dispatchEvent(new Event('change', {bubbles: true}));
1669
+ try { el.dispatchEvent(new InputEvent('input', { inputType: 'insertReplacementText', data: f.v, bubbles: true, composed: true, cancelable: true })); }
1670
+ catch (_iE) { el.dispatchEvent(new Event('input', { bubbles: true, composed: true })); }
1671
+ el.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
1672
+ if (!inDlg) {
1673
+ el.dispatchEvent(new Event('blur', { bubbles: true, composed: true }));
1674
+ el.dispatchEvent(new Event('focusout', { bubbles: true, composed: true }));
1675
+ }
1449
1676
  }
1450
- results.push('Filled: ' + el.tagName + ' with "' + f.v.substring(0, 30) + '"');
1677
+ var actual = el.value !== undefined ? String(el.value) : (el.textContent || '');
1678
+ var match = actual === f.v || actual.indexOf(f.v.substring(0, 20)) >= 0;
1679
+ results.push((match ? 'Filled' : 'PARTIAL') + ': ' + el.tagName + ' with "' + f.v.substring(0, 30) + '"');
1451
1680
  });
1452
1681
  return results.join('\\n');
1453
1682
  })()`
@@ -2348,6 +2577,7 @@ export async function newTab(url = "") {
2348
2577
  _activeTabURL = url || null;
2349
2578
  _lastResolveTime = Date.now();
2350
2579
  _lastTabCount = Number(tabCount); // Update tab count cache
2580
+ _lastNewTabAt = Date.now(); // Start grace window — block fallback to user's tab
2351
2581
  // Set bulletproof tab marker — survives same-tab navigation, redirects, query changes (v2.8.3)
2352
2582
  _activeTabMarker = `MCP_${SESSION_ID}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
2353
2583
  await osascriptFast(
@@ -2672,11 +2902,38 @@ export async function uploadFile({ selector, filePath }) {
2672
2902
 
2673
2903
  const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2674
2904
  const { basename, extname } = await import("node:path");
2675
- const fileName = basename(filePath);
2676
- const ext = extname(filePath).toLowerCase().replace(".", "");
2905
+ let fileName = basename(filePath);
2906
+ let ext = extname(filePath).toLowerCase().replace(".", "");
2907
+ let resolvedPath = filePath;
2908
+
2909
+ // Auto-convert images that the target input rejects.
2910
+ // Quora is the canonical case — declares accept="image/png,image/jpeg" and silently
2911
+ // ignores webp drops. Convert webp/heic to PNG via macOS sips (no extra deps).
2912
+ const imageFormats = new Set(['webp', 'heic', 'heif', 'tiff', 'tif']);
2913
+ if (imageFormats.has(ext)) {
2914
+ const accept = await runJS(
2915
+ `(function(){var el=document.querySelector('${sel}');if(!el){var roots=window.mcpCollectRoots?window.mcpCollectRoots():[document];for(var i=0;i<roots.length;i++){el=roots[i].querySelector('${sel}');if(el)break;}}return el?(el.getAttribute('accept')||''):'';})()`
2916
+ ).catch(() => '');
2917
+ const acceptStr = String(accept || '').toLowerCase();
2918
+ const accepted = !acceptStr ||
2919
+ acceptStr.includes('image/*') ||
2920
+ acceptStr.includes(`image/${ext}`) ||
2921
+ acceptStr.includes(`.${ext}`);
2922
+ if (!accepted) {
2923
+ const tmpPng = join(tmpdir(), `safari-mcp-upload-${Date.now()}.png`);
2924
+ try {
2925
+ await execFileAsync('sips', ['-s', 'format', 'png', resolvedPath, '--out', tmpPng], { timeout: 15000 });
2926
+ resolvedPath = tmpPng;
2927
+ fileName = basename(tmpPng);
2928
+ ext = 'png';
2929
+ } catch (sipsErr) {
2930
+ console.error(`[Safari MCP] sips conversion failed (${ext}→png): ${sipsErr.message}. Continuing with original.`);
2931
+ }
2932
+ }
2933
+ }
2677
2934
 
2678
2935
  // Read file as base64
2679
- const fileData = await readFile(filePath);
2936
+ const fileData = await readFile(resolvedPath);
2680
2937
  const base64 = fileData.toString("base64");
2681
2938
 
2682
2939
  // Determine MIME type