safari-mcp 2.9.2 → 2.10.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
@@ -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>
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.2",
3
+ "version": "2.10.0",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 80 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
package/safari.js CHANGED
@@ -1042,32 +1042,39 @@ async function clickWithRetry(js) {
1042
1042
 
1043
1043
  export async function click({ selector, text, x, y, ref }) {
1044
1044
  await ensureHelpers();
1045
+ // Native <select> elements look like custom dropdowns on LinkedIn etc., but they
1046
+ // hand off to the OS-level option list. .click() on them blocks AppleScript until
1047
+ // the native popup is dismissed → the JSC eval times out and the tool returns an
1048
+ // error after seconds of hang. Detect early and return a clear directive instead.
1049
+ 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('|');}`;
1050
+ let result;
1045
1051
  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)+'"'):'');})()`
1052
+ result = await clickWithRetry(
1053
+ `(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
1054
  );
1049
- }
1050
-
1051
- if (selector) {
1055
+ } else if (selector) {
1052
1056
  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)+'"'):'');})()`
1057
+ result = await clickWithRetry(
1058
+ `(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
1059
  );
1056
- }
1057
-
1058
- if (text) {
1060
+ } else if (text) {
1059
1061
  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)+'"';})()`
1062
+ result = await clickWithRetry(
1063
+ `(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)+'"';})()`
1064
+ );
1065
+ } else if (x !== undefined && y !== undefined) {
1066
+ result = await clickWithRetry(
1067
+ `(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
1068
  );
1069
+ } else {
1070
+ throw new Error("click requires selector, text, or x/y coordinates");
1063
1071
  }
1064
1072
 
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
- );
1073
+ if (typeof result === 'string' && result.startsWith('__SELECT_GUARD__:')) {
1074
+ const detail = result.substring('__SELECT_GUARD__:'.length);
1075
+ 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
1076
  }
1070
- throw new Error("click requires selector, text, or x/y coordinates");
1077
+ return result;
1071
1078
  }
1072
1079
 
1073
1080
  export async function doubleClick({ selector, x, y, ref }) {
@@ -1289,22 +1296,89 @@ export async function fill({ selector, value, ref }) {
1289
1296
  const val = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
1290
1297
  const result = await runJS(
1291
1298
  `(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'){` +
1299
+ // Lexical editor detection (LinkedIn share composer, modern Meta/Shopify apps).
1300
+ // Three lookup strategies, cheapest first:
1301
+ // A) [data-lexical-editor="true"] on ancestor with __lexicalEditor property.
1302
+ // B) Walk DOM ancestors for __lexicalEditor (nested-config wrappers).
1303
+ // C) React Fiber walk — LinkedIn sometimes obfuscates __lexicalEditor, so we
1304
+ // locate the editor by duck-typing: any object on props/state/stateNode
1305
+ // that exposes both parseEditorState AND setEditorState is the editor.
1306
+ // Once found, two fill strategies, both pure DOM (no CGEvent, no focus shift):
1307
+ // 1) InputEvent('beforeinput', insertFromPaste + DataTransfer) — Lexical's
1308
+ // handleBeforeInput reads dataTransfer and commits via editor.update().
1309
+ // 2) parseEditorState() + setEditorState() with a minimal paragraph doc —
1310
+ // used when Lexical ignores synthetic paste events.
1311
+ `var lexEl=el.closest('[data-lexical-editor="true"]');var lex=(lexEl&&lexEl.__lexicalEditor)||null;` +
1312
+ `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;}}` +
1313
+ `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;}}` +
1314
+ `if(lexFk){var lexF=lexHost[lexFk];var lexIsEd=function(o){return o&&typeof o.parseEditorState==='function'&&typeof o.setEditorState==='function';};` +
1315
+ `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;}}}` +
1316
+ `if(!lex){var ls=lexF.memoizedState;while(ls){if(lexIsEd(ls.memoizedState)){lex=ls.memoizedState;break;}ls=ls.next;}}` +
1317
+ `if(!lex){var lsn=lexF.stateNode;if(lsn){if(lexIsEd(lsn))lex=lsn;else if(lsn.editor&&lexIsEd(lsn.editor))lex=lsn.editor;}}` +
1318
+ `if(lex)break;lexF=lexF.return;}}}` +
1319
+ `if(lex){if(!lexEl){lexEl=lex._rootElement||el;}` +
1320
+ // Strategy 1: beforeinput with insertFromPaste + DataTransfer
1321
+ `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){}` +
1322
+ // Strategy 2: parseEditorState + setEditorState (direct state replacement)
1323
+ `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){}` +
1324
+ `}` +
1292
1325
  // ProseMirror detection
1293
1326
  `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
1327
  // React Fiber walk for ProseMirror view (LinkedIn, Tiptap-React)
1295
1328
  `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){}}` +
1297
- // ProseMirror detected but no view — use char-by-char with beforeinput
1298
- `if(pm&&!v){return '__PM_CHARBYCHAR__';}` +
1299
- // Closure/Medium detection — signal for native paste
1300
- `var isClosure=Object.keys(el).some(function(k){return k.startsWith('closure_uid_');})||location.hostname.includes('medium.com');if(isClosure){return '__CLOSURE_NATIVE_PASTE__';}` +
1329
+ `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();` +
1330
+ // Verify the dispatch actually committed — LinkedIn's PM sometimes accepts the
1331
+ // tr but rolls it back on the next tick (its readOnly plugin reasserts state).
1332
+ // If the doc text doesn't contain our value within 50ms, route to native paste.
1333
+ `var pmCheck=(v.state.doc.textContent||'')+' '+(pm.textContent||'');` +
1334
+ `if(pmCheck.indexOf('${val.substring(0, Math.min(20, val.length))}')>=0){return 'Filled CE (ProseMirror)';}` +
1335
+ `}catch(_pmE){}}}catch(e){}}` +
1336
+ // ProseMirror detected but no view — in a dialog (LinkedIn share composer), go to
1337
+ // native paste (real CGEvent Cmd+V) so ProseMirror's isTrusted-gated paste handler
1338
+ // accepts the insertion and the dialog doesn't dismiss. Outside a dialog fall back
1339
+ // to char-by-char beforeinput (works on Discord-style PM without dismissal risk).
1340
+ `if(pm&&!v){var inDlg=!!el.closest('[role="dialog"]');if(inDlg){return '__NATIVE_PASTE_DIALOG__';}return '__PM_CHARBYCHAR__';}` +
1341
+ // Closure/Medium detection — signal for native paste.
1342
+ // Match any of: closure_uid_ key on element, medium.com hostname, ancestor with
1343
+ // closure markers, or window.goog.events/goog.editor.Plugin globals (broader catch
1344
+ // for Closure-built editors outside Medium).
1345
+ `var isClosure=false;` +
1346
+ `try{` +
1347
+ `if(Object.keys(el).some(function(k){return k.indexOf('closure_uid_')===0||k.indexOf('closure_lm_')===0;}))isClosure=true;` +
1348
+ `else if(location.hostname.indexOf('medium.com')>=0)isClosure=true;` +
1349
+ `else if(typeof goog!=='undefined'&&goog&&(goog.events||goog.editor))isClosure=true;` +
1350
+ `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++;}}` +
1351
+ `}catch(_clE){}` +
1352
+ `if(isClosure){return '__CLOSURE_NATIVE_PASTE__';}` +
1301
1353
  // Synthetic ClipboardEvent paste — works on ProseMirror, TipTap, Slate, and most modern editors
1302
1354
  // that don't respond to execCommand but DO handle paste events
1303
1355
  `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){}` +
1304
- // Default contenteditable: selectAll+delete+insert (safer than textContent='')
1305
- `document.execCommand('selectAll');document.execCommand('delete');document.execCommand('insertText',false,'${val}');el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('blur',{bubbles:true}));return 'Filled contenteditable';}` +
1306
- // Standard input/textarea with _valueTracker
1307
- `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;}})()`
1356
+ // Synthetic paste did not verify — in a dialog, route to native paste (CGEvent Cmd+V)
1357
+ // to produce real isTrusted:true events. Avoids LinkedIn-style dialog dismissal too.
1358
+ `if(!!el.closest('[role="dialog"]')){return '__NATIVE_PASTE_DIALOG__';}` +
1359
+ // Default contenteditable: selectAll+delete+insert. No blur — blur dismisses dialogs
1360
+ // and nearby popovers; React only needs 'input' to see the change.
1361
+ `document.execCommand('selectAll');document.execCommand('delete');document.execCommand('insertText',false,'${val}');el.dispatchEvent(new Event('input',{bubbles:true}));return 'Filled contenteditable';}` +
1362
+ // Standard input/textarea with _valueTracker.
1363
+ // • focus() FIRST — Formik/HubSpot read focused state during onChange validation.
1364
+ // • InputEvent with inputType:'insertReplacementText' + data — React 18 RSC, Next.js,
1365
+ // and Featured.com require a real InputEvent (not plain Event) to update store state.
1366
+ // • composed:true on every event — pierces Shadow DOM (Reddit, web components).
1367
+ // • blur/focusout SUPPRESSED inside dialogs — blur dismisses LinkedIn share composer
1368
+ // and many MUI/Radix dialogs. React only needs 'input' to see the change anyway.
1369
+ // • Post-fill verification — if el.value !== expected after dispatch, signal the wrapper
1370
+ // to try native CGEvent paste fallback (real isTrusted events).
1371
+ `var inDlg=!!(el.closest&&el.closest('[role="dialog"]'));` +
1372
+ `var t=el._valueTracker;if(t)t.setValue('');` +
1373
+ `var proto=el.tagName==='TEXTAREA'?window.HTMLTextAreaElement.prototype:window.HTMLInputElement.prototype;` +
1374
+ `var s=Object.getOwnPropertyDescriptor(proto,'value');` +
1375
+ `if(s&&s.set){s.set.call(el,'${val}');}else{el.value='${val}';}` +
1376
+ `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}));}` +
1377
+ `el.dispatchEvent(new Event('change',{bubbles:true,composed:true}));` +
1378
+ `if(!inDlg){el.dispatchEvent(new Event('blur',{bubbles:true,composed:true}));el.dispatchEvent(new Event('focusout',{bubbles:true,composed:true}));el.focus();}` +
1379
+ `var actual=(el.value!==undefined?el.value:'');` +
1380
+ `if(actual!=='${val}'){return '__FILL_VALUE_MISMATCH__:'+actual.substring(0,80);}` +
1381
+ `return 'Filled: '+actual.substring(0,50);}catch(e){return 'ERR: '+e.message;}})()`
1308
1382
  );
1309
1383
 
1310
1384
  // ProseMirror editor with no view access: use char-by-char with beforeinput events
@@ -1375,9 +1449,135 @@ export async function fill({ selector, value, ref }) {
1375
1449
  return fillResult;
1376
1450
  }
1377
1451
 
1452
+ // Standard input/textarea path returned with el.value mismatched against expected —
1453
+ // typically Next.js RSC (Featured.com) or HubSpot Formik silently ignored the property
1454
+ // descriptor setter. Fall back to native CGEvent Cmd+V paste — real keyboard events
1455
+ // route through the framework's normal input pipeline.
1456
+ if (typeof result === 'string' && result.startsWith('__FILL_VALUE_MISMATCH__')) {
1457
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1458
+ await runJS(
1459
+ `(function(){var el=document.querySelector('${sel}');if(!el)return 'not-found';` +
1460
+ `el.focus();if('select' in el){el.select();}else if(el.setSelectionRange){el.setSelectionRange(0,(el.value||'').length);}` +
1461
+ `return 'focused';})()`
1462
+ );
1463
+ await new Promise(r => setTimeout(r, 30));
1464
+ try {
1465
+ await _nativeTypeViaClipboard(value);
1466
+ return `Filled (native paste fallback after value-mismatch, ${value.length} chars)`;
1467
+ } catch (e) {
1468
+ return `${result} (native paste fallback failed: ${e.message})`;
1469
+ }
1470
+ }
1471
+
1472
+ // ProseMirror/contenteditable inside a dialog (LinkedIn share composer is the canonical
1473
+ // case). Synthetic events get rejected by isTrusted-gated paste handlers, and stray blur
1474
+ // events dismiss the dialog. Use CGEvent Cmd+V — real paste, isTrusted:true, windowed so
1475
+ // no focus steal. Requires the element to be focused + have a collapsed selection at end.
1476
+ if (result === "__NATIVE_PASTE_DIALOG__") {
1477
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1478
+ await runJS(
1479
+ `(function(){var el=document.querySelector('${sel}');if(!el)return 'not-found';` +
1480
+ `el.focus();` +
1481
+ // Select all existing content so Cmd+V replaces (not appends)
1482
+ `var s=window.getSelection();if(s){var r=document.createRange();r.selectNodeContents(el);s.removeAllRanges();s.addRange(r);}` +
1483
+ `return 'focused';})()`
1484
+ );
1485
+ await new Promise(r => setTimeout(r, 50));
1486
+ try {
1487
+ await _nativeTypeViaClipboard(value);
1488
+ return `Filled CE (native paste in dialog, ${value.length} chars)`;
1489
+ } catch (e) {
1490
+ return `ERR native paste in dialog: ${e.message}`;
1491
+ }
1492
+ }
1493
+
1378
1494
  return result;
1379
1495
  }
1380
1496
 
1497
+ // Verify the framework-level state of an editor or input matches `expected`.
1498
+ // Modern editors (ProseMirror, Lexical, Closure, React-controlled inputs) maintain
1499
+ // state separately from the DOM — `.value` or `.textContent` can show the new text
1500
+ // while the framework's internal store still holds the old value, so a Submit click
1501
+ // sends the stale data. Call this after `fill` and BEFORE `click`-Submit.
1502
+ //
1503
+ // Returns JSON: { match: boolean, mode: 'input'|'prosemirror'|'lexical'|'closure'|'contenteditable',
1504
+ // actual: string, expected: string, hint?: string }
1505
+ export async function verifyState({ selector, expected, ref }) {
1506
+ if (ref) selector = refSelector(ref);
1507
+ if (!selector) throw new Error("verifyState requires selector or ref");
1508
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1509
+ const exp = String(expected || '').replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
1510
+ const expSnippet = String(expected || '').substring(0, 30).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
1511
+ return runJS(
1512
+ `(function(){
1513
+ var el = document.querySelector('${sel}');
1514
+ if (!el && window.mcpQuerySelectorDeep) el = window.mcpQuerySelectorDeep('${sel}');
1515
+ if (!el) return JSON.stringify({ match: false, mode: 'not-found', actual: '', expected: '${exp}' });
1516
+
1517
+ var expected = '${exp}';
1518
+ var snippet = '${expSnippet}';
1519
+
1520
+ // ProseMirror — check view state, not DOM
1521
+ var pm = el.closest && el.closest('.ProseMirror') || el.querySelector && el.querySelector('.ProseMirror');
1522
+ if (pm) {
1523
+ var v = (pm.pmViewDesc && pm.pmViewDesc.view) || (pm.cmView && pm.cmView.view) || null;
1524
+ if (!v) {
1525
+ var keys = Object.keys(pm);
1526
+ for (var ki = 0; ki < keys.length; ki++) { var o = pm[keys[ki]]; if (o && o.state && o.dispatch) { v = o; break; } }
1527
+ }
1528
+ if (!v) {
1529
+ var fk = Object.keys(pm).find(function(k){return k.indexOf('__reactFiber')===0||k.indexOf('__reactInternalInstance')===0;});
1530
+ 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; }}
1531
+ }
1532
+ var pmText = v && v.state && v.state.doc ? (v.state.doc.textContent || '') : (pm.textContent || '');
1533
+ return JSON.stringify({ match: pmText.indexOf(snippet) >= 0, mode: 'prosemirror', actual: pmText.substring(0, 200), expected: expected.substring(0, 200) });
1534
+ }
1535
+
1536
+ // Lexical — read editor state
1537
+ var lexEl = el.closest && el.closest('[data-lexical-editor="true"]');
1538
+ var lex = lexEl && lexEl.__lexicalEditor;
1539
+ if (!lex) {
1540
+ var lc = el; for (var li = 0; li < 15 && lc; li++) { if (lc.__lexicalEditor) { lex = lc.__lexicalEditor; break; } lc = lc.parentElement; }
1541
+ }
1542
+ if (lex && typeof lex.getEditorState === 'function') {
1543
+ try {
1544
+ var lexText = '';
1545
+ lex.getEditorState().read(function(){
1546
+ var root = lex._editorState && lex._editorState._nodeMap ? lex._editorState._nodeMap.get('root') : null;
1547
+ // Fallback: read DOM text since direct API requires Lexical's $getRoot
1548
+ lexText = (lexEl || el).textContent || '';
1549
+ });
1550
+ return JSON.stringify({ match: lexText.indexOf(snippet) >= 0, mode: 'lexical', actual: lexText.substring(0, 200), expected: expected.substring(0, 200) });
1551
+ } catch (_lE) {}
1552
+ }
1553
+
1554
+ // Closure (Medium and similar) — check element textContent (Closure mutates DOM directly)
1555
+ var isClosure = Object.keys(el).some(function(k){return k.indexOf('closure_uid_')===0;}) || location.hostname.indexOf('medium.com') >= 0;
1556
+ if (isClosure) {
1557
+ var clText = el.textContent || '';
1558
+ return JSON.stringify({ match: clText.indexOf(snippet) >= 0, mode: 'closure', actual: clText.substring(0, 200), expected: expected.substring(0, 200) });
1559
+ }
1560
+
1561
+ // Generic contenteditable
1562
+ if (el.isContentEditable) {
1563
+ var ceText = el.textContent || '';
1564
+ return JSON.stringify({ match: ceText.indexOf(snippet) >= 0, mode: 'contenteditable', actual: ceText.substring(0, 200), expected: expected.substring(0, 200) });
1565
+ }
1566
+
1567
+ // Standard input/textarea/select
1568
+ var actualVal = el.value !== undefined ? String(el.value) : '';
1569
+ var match = actualVal === expected;
1570
+ var hint = '';
1571
+ // Detect React store/_valueTracker mismatch — common Featured.com / Next.js RSC bug
1572
+ if (!match && el._valueTracker && typeof el._valueTracker.getValue === 'function') {
1573
+ var tracked = el._valueTracker.getValue();
1574
+ if (tracked !== actualVal) hint = 'React _valueTracker out of sync (tracked=' + JSON.stringify(tracked.substring(0, 60)) + ')';
1575
+ }
1576
+ return JSON.stringify({ match: match, mode: 'input', actual: actualVal.substring(0, 200), expected: expected.substring(0, 200), hint: hint });
1577
+ })()`
1578
+ );
1579
+ }
1580
+
1381
1581
  export async function clearField({ selector }) {
1382
1582
  const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1383
1583
  return runJS(
@@ -1394,7 +1594,9 @@ export async function selectOption({ selector, value }) {
1394
1594
  }
1395
1595
 
1396
1596
  export async function fillForm({ fields }) {
1397
- // Single JS call for ALL fields (instead of N separate osascript calls)
1597
+ // Single JS call for ALL fields (instead of N separate osascript calls).
1598
+ // Same React-state-sync logic as `fill`: _valueTracker reset, prototype-correct setter,
1599
+ // InputEvent with inputType, composed events for shadow DOM, dialog-aware blur.
1398
1600
  const fieldsJSON = JSON.stringify(fields.map(f => ({
1399
1601
  s: f.selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'"),
1400
1602
  v: f.value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n"),
@@ -1405,20 +1607,42 @@ export async function fillForm({ fields }) {
1405
1607
  var results = [];
1406
1608
  fields.forEach(function(f) {
1407
1609
  var el = document.querySelector(f.s);
1610
+ if (!el) {
1611
+ var roots = window.mcpCollectRoots ? window.mcpCollectRoots() : [document];
1612
+ for (var ri = 0; ri < roots.length && !el; ri++) {
1613
+ try { el = roots[ri].querySelector(f.s); } catch (_e) {}
1614
+ }
1615
+ }
1408
1616
  if (!el) { results.push('Not found: ' + f.s); return; }
1409
1617
  el.focus();
1618
+ var inDlg = !!(el.closest && el.closest('[role="dialog"]'));
1410
1619
  if (el.isContentEditable) {
1411
- el.textContent = '';
1620
+ try {
1621
+ var sel = window.getSelection();
1622
+ if (sel) { var rng = document.createRange(); rng.selectNodeContents(el); sel.removeAllRanges(); sel.addRange(rng); }
1623
+ document.execCommand('delete');
1624
+ } catch (_seE) {}
1412
1625
  document.execCommand('insertText', false, f.v);
1626
+ el.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
1413
1627
  } else {
1414
- var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value') ||
1415
- Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value');
1628
+ var t = el._valueTracker; if (t) t.setValue('');
1629
+ var proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype :
1630
+ el.tagName === 'SELECT' ? window.HTMLSelectElement.prototype :
1631
+ window.HTMLInputElement.prototype;
1632
+ var setter = Object.getOwnPropertyDescriptor(proto, 'value');
1416
1633
  if (setter && setter.set) setter.set.call(el, f.v);
1417
1634
  else el.value = f.v;
1418
- el.dispatchEvent(new Event('input', {bubbles: true}));
1419
- el.dispatchEvent(new Event('change', {bubbles: true}));
1635
+ try { el.dispatchEvent(new InputEvent('input', { inputType: 'insertReplacementText', data: f.v, bubbles: true, composed: true, cancelable: true })); }
1636
+ catch (_iE) { el.dispatchEvent(new Event('input', { bubbles: true, composed: true })); }
1637
+ el.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
1638
+ if (!inDlg) {
1639
+ el.dispatchEvent(new Event('blur', { bubbles: true, composed: true }));
1640
+ el.dispatchEvent(new Event('focusout', { bubbles: true, composed: true }));
1641
+ }
1420
1642
  }
1421
- results.push('Filled: ' + el.tagName + ' with "' + f.v.substring(0, 30) + '"');
1643
+ var actual = el.value !== undefined ? String(el.value) : (el.textContent || '');
1644
+ var match = actual === f.v || actual.indexOf(f.v.substring(0, 20)) >= 0;
1645
+ results.push((match ? 'Filled' : 'PARTIAL') + ': ' + el.tagName + ' with "' + f.v.substring(0, 30) + '"');
1422
1646
  });
1423
1647
  return results.join('\\n');
1424
1648
  })()`
@@ -2643,11 +2867,38 @@ export async function uploadFile({ selector, filePath }) {
2643
2867
 
2644
2868
  const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2645
2869
  const { basename, extname } = await import("node:path");
2646
- const fileName = basename(filePath);
2647
- const ext = extname(filePath).toLowerCase().replace(".", "");
2870
+ let fileName = basename(filePath);
2871
+ let ext = extname(filePath).toLowerCase().replace(".", "");
2872
+ let resolvedPath = filePath;
2873
+
2874
+ // Auto-convert images that the target input rejects.
2875
+ // Quora is the canonical case — declares accept="image/png,image/jpeg" and silently
2876
+ // ignores webp drops. Convert webp/heic to PNG via macOS sips (no extra deps).
2877
+ const imageFormats = new Set(['webp', 'heic', 'heif', 'tiff', 'tif']);
2878
+ if (imageFormats.has(ext)) {
2879
+ const accept = await runJS(
2880
+ `(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')||''):'';})()`
2881
+ ).catch(() => '');
2882
+ const acceptStr = String(accept || '').toLowerCase();
2883
+ const accepted = !acceptStr ||
2884
+ acceptStr.includes('image/*') ||
2885
+ acceptStr.includes(`image/${ext}`) ||
2886
+ acceptStr.includes(`.${ext}`);
2887
+ if (!accepted) {
2888
+ const tmpPng = join(tmpdir(), `safari-mcp-upload-${Date.now()}.png`);
2889
+ try {
2890
+ await execFileAsync('sips', ['-s', 'format', 'png', resolvedPath, '--out', tmpPng], { timeout: 15000 });
2891
+ resolvedPath = tmpPng;
2892
+ fileName = basename(tmpPng);
2893
+ ext = 'png';
2894
+ } catch (sipsErr) {
2895
+ console.error(`[Safari MCP] sips conversion failed (${ext}→png): ${sipsErr.message}. Continuing with original.`);
2896
+ }
2897
+ }
2898
+ }
2648
2899
 
2649
2900
  // Read file as base64
2650
- const fileData = await readFile(filePath);
2901
+ const fileData = await readFile(resolvedPath);
2651
2902
  const base64 = fileData.toString("base64");
2652
2903
 
2653
2904
  // Determine MIME type