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 +163 -10
- package/extension/background.js +8 -2
- package/extension/content.js +21 -4
- package/extension/manifest.json +1 -1
- package/index.js +13 -0
- package/mcp-helpers.js +51 -21
- package/package.json +1 -1
- package/safari.js +289 -32
package/README.md
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
[](https://opensource.org/licenses/MIT)
|
|
16
16
|
[](https://www.apple.com/macos/)
|
|
17
17
|
|
|
18
|
+
[](https://glama.ai/mcp/servers/@achiya-automation/safari-mcp)
|
|
19
|
+
[](https://registry.modelcontextprotocol.io/v0/servers?search=achiya-automation)
|
|
20
|
+
[](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
|
-

|
|
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
|
-
|
|
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>
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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/extension/background.js
CHANGED
|
@@ -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
|
-
|
|
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("
|
|
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 {
|
package/extension/content.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
// Content script — runs at document_start in MAIN world (before page scripts)
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
+
}
|
package/extension/manifest.json
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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.
|
|
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
|
-
|
|
1047
|
-
`(function(){var el=mcpFindRef('${ref}');if(!el)return 'Element not found: ref=${ref}'
|
|
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
|
-
|
|
1054
|
-
`(function(){var el=mcpQuerySelectorDeep('${sel}');if(!el)return 'Element not found: ${sel}'
|
|
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
|
-
|
|
1061
|
-
`(function(){var el=mcpFindText('${safeText}',true)||mcpFindText('${safeText}',false);if(!el)return 'Element not found with text: ${safeText}'
|
|
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 (
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
-
|
|
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));
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1444
|
-
|
|
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
|
|
1448
|
-
el.dispatchEvent(new Event('
|
|
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
|
-
|
|
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
|
-
|
|
2676
|
-
|
|
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(
|
|
2936
|
+
const fileData = await readFile(resolvedPath);
|
|
2680
2937
|
const base64 = fileData.toString("base64");
|
|
2681
2938
|
|
|
2682
2939
|
// Determine MIME type
|