safari-mcp 2.10.3 → 2.10.6
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 +2 -1
- package/index.js +27 -0
- package/mcp-helpers.js +88 -2
- package/package.json +1 -1
- package/safari.js +62 -2
package/README.md
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
[](https://glama.ai/mcp/servers/@achiya-automation/safari-mcp)
|
|
19
19
|
[](https://registry.modelcontextprotocol.io/v0/servers?search=achiya-automation)
|
|
20
20
|
[](https://smithery.ai/server/@achiya-automation/safari-mcp)
|
|
21
|
+
[](https://mcpscoreboard.com/server/05977769-8762-4e89-aff3-a0c5776843bb/)
|
|
21
22
|
|
|
22
23
|
<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>
|
|
23
24
|
<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>
|
|
@@ -777,7 +778,7 @@ If Safari MCP saves you from Chrome overhead, **a star helps others discover it:
|
|
|
777
778
|
|
|
778
779
|
## Listed On
|
|
779
780
|
|
|
780
|
-
[](https://glama.ai/mcp/servers/achiya-automation/safari-mcp) [](https://registry.modelcontextprotocol.io/) [](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml)
|
|
781
|
+
[](https://glama.ai/mcp/servers/achiya-automation/safari-mcp) [](https://registry.modelcontextprotocol.io/) [](https://mcpscoreboard.com/server/05977769-8762-4e89-aff3-a0c5776843bb/) [](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml)
|
|
781
782
|
|
|
782
783
|
---
|
|
783
784
|
|
package/index.js
CHANGED
|
@@ -1106,6 +1106,33 @@ server.tool(
|
|
|
1106
1106
|
}
|
|
1107
1107
|
);
|
|
1108
1108
|
|
|
1109
|
+
server.tool(
|
|
1110
|
+
"safari_react_select_set",
|
|
1111
|
+
"Set a value in a react-select v5 dropdown by walking React fiber to find the Select component and invoking onChange directly — bypasses the menu UI entirely. Use when safari_click on the chevron or option keeps failing (Cloudflare custom token forms after a few rows, portal-rendered selects that intercept synthetic events). Returns JSON {ok, selected} on success, or {ok:false, error, available:[…]} listing up to 30 option labels on miss. Match is by label, value, or case-insensitive label. Either ref or selector required. NOTE: For Permissions-levels combos that are disabled until a Permission is selected, set the Permissions value first — the level combo becomes enabled and its props.options populate.",
|
|
1112
|
+
{
|
|
1113
|
+
selector: z.string().optional().describe("CSS selector — typically input[name=...] or the .react-select__control container"),
|
|
1114
|
+
ref: z.string().optional().describe("Ref ID from safari_snapshot"),
|
|
1115
|
+
value: z.string().describe("Option label (or value) to select — case-insensitive fallback"),
|
|
1116
|
+
},
|
|
1117
|
+
async (args) => {
|
|
1118
|
+
const result = await safari.reactSelectSet(args);
|
|
1119
|
+
return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
|
|
1120
|
+
}
|
|
1121
|
+
);
|
|
1122
|
+
|
|
1123
|
+
server.tool(
|
|
1124
|
+
"safari_react_select_list_options",
|
|
1125
|
+
"List available options of a react-select v5 dropdown without opening the menu. Returns JSON {ok, total, options:[{label,value}…]}. Useful when safari_react_select_set returns 'option not found' and you need to see exact labels (e.g. 'Email Routing Rules' vs 'Email Routing'). Either ref or selector required.",
|
|
1126
|
+
{
|
|
1127
|
+
selector: z.string().optional().describe("CSS selector"),
|
|
1128
|
+
ref: z.string().optional().describe("Ref ID from safari_snapshot"),
|
|
1129
|
+
},
|
|
1130
|
+
async (args) => {
|
|
1131
|
+
const result = await safari.reactSelectListOptions(args);
|
|
1132
|
+
return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
|
|
1133
|
+
}
|
|
1134
|
+
);
|
|
1135
|
+
|
|
1109
1136
|
server.tool(
|
|
1110
1137
|
"safari_fill_form",
|
|
1111
1138
|
"Fill multiple form fields at once",
|
package/mcp-helpers.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// It is also used by the extension's execInTab for the AppleScript fallback path.
|
|
4
4
|
// IMPORTANT: Keep compatible with all browsers — no ES6+ modules, use var where possible.
|
|
5
5
|
|
|
6
|
-
if (window.__mcpVersion !==
|
|
7
|
-
window.__mcpVersion =
|
|
6
|
+
if (window.__mcpVersion !== 6) {
|
|
7
|
+
window.__mcpVersion = 6;
|
|
8
8
|
window.__mcpRefs = window.__mcpRefs || {};
|
|
9
9
|
window.__mcpCachedRoots = null;
|
|
10
10
|
window.__mcpRootsDirty = true;
|
|
@@ -328,4 +328,90 @@ if (window.__mcpVersion !== 5) {
|
|
|
328
328
|
}
|
|
329
329
|
return best;
|
|
330
330
|
};
|
|
331
|
+
// React-Select v5 / Radix-style controlled-select bypass: walks fiber up from the
|
|
332
|
+
// target element to find a Select component (props.options + props.onChange), then
|
|
333
|
+
// invokes onChange directly with the matching option. Avoids menu UI entirely —
|
|
334
|
+
// critical for Cloudflare custom-token forms where the dropdown indicator stops
|
|
335
|
+
// responding to dispatched clicks after a few rows.
|
|
336
|
+
window.mcpReactSelectFindInstance = function(el) {
|
|
337
|
+
if (!el) return null;
|
|
338
|
+
var node = el;
|
|
339
|
+
for (var d = 0; d < 25 && node; d++) {
|
|
340
|
+
var keys = Object.keys(node);
|
|
341
|
+
var fk;
|
|
342
|
+
for (var ki = 0; ki < keys.length; ki++) {
|
|
343
|
+
if (keys[ki].indexOf('__reactFiber$') === 0 || keys[ki].indexOf('__reactInternalInstance$') === 0) { fk = keys[ki]; break; }
|
|
344
|
+
}
|
|
345
|
+
if (fk) {
|
|
346
|
+
var fiber = node[fk], hops = 0;
|
|
347
|
+
while (fiber && hops < 60) {
|
|
348
|
+
var p = fiber.memoizedProps || fiber.pendingProps || (fiber.stateNode && fiber.stateNode.props);
|
|
349
|
+
if (p && Array.isArray(p.options) && typeof p.onChange === 'function') {
|
|
350
|
+
return { props: p, fiber: fiber };
|
|
351
|
+
}
|
|
352
|
+
fiber = fiber.return;
|
|
353
|
+
hops++;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
node = node.parentElement;
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
};
|
|
360
|
+
window.mcpReactSelectFlatten = function(options) {
|
|
361
|
+
var flat = [];
|
|
362
|
+
for (var i = 0; i < options.length; i++) {
|
|
363
|
+
var o = options[i];
|
|
364
|
+
if (o && Array.isArray(o.options)) {
|
|
365
|
+
for (var j = 0; j < o.options.length; j++) flat.push(o.options[j]);
|
|
366
|
+
} else if (o) {
|
|
367
|
+
flat.push(o);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return flat;
|
|
371
|
+
};
|
|
372
|
+
window.mcpReactSelectSet = function(el, optionLabel) {
|
|
373
|
+
var found = window.mcpReactSelectFindInstance(el);
|
|
374
|
+
if (!found) return JSON.stringify({ ok: false, error: 'no react-select Select component found in fiber tree' });
|
|
375
|
+
var p = found.props;
|
|
376
|
+
var flat = window.mcpReactSelectFlatten(p.options);
|
|
377
|
+
if (!flat.length) return JSON.stringify({ ok: false, error: 'Select component has no options (try opening the menu first to populate)' });
|
|
378
|
+
var needle = String(optionLabel);
|
|
379
|
+
var target = null;
|
|
380
|
+
for (var i = 0; i < flat.length; i++) {
|
|
381
|
+
if (flat[i] && (flat[i].label === needle || flat[i].value === needle)) { target = flat[i]; break; }
|
|
382
|
+
}
|
|
383
|
+
if (!target) {
|
|
384
|
+
var lcNeedle = needle.toLowerCase();
|
|
385
|
+
for (var k = 0; k < flat.length; k++) {
|
|
386
|
+
var lab = String(flat[k] && (flat[k].label !== undefined ? flat[k].label : flat[k].value) || '').toLowerCase();
|
|
387
|
+
if (lab === lcNeedle) { target = flat[k]; break; }
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (!target) {
|
|
391
|
+
return JSON.stringify({
|
|
392
|
+
ok: false,
|
|
393
|
+
error: 'option not found',
|
|
394
|
+
searched: needle,
|
|
395
|
+
total: flat.length,
|
|
396
|
+
available: flat.slice(0, 30).map(function(o) { return o && (o.label !== undefined ? o.label : o.value); })
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
var action = { action: 'select-option', option: target, name: p.name };
|
|
400
|
+
try {
|
|
401
|
+
p.onChange(target, action);
|
|
402
|
+
return JSON.stringify({ ok: true, selected: target.label !== undefined ? target.label : target.value });
|
|
403
|
+
} catch (e) {
|
|
404
|
+
return JSON.stringify({ ok: false, error: 'onChange threw: ' + e.message });
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
window.mcpReactSelectListOptions = function(el) {
|
|
408
|
+
var found = window.mcpReactSelectFindInstance(el);
|
|
409
|
+
if (!found) return JSON.stringify({ ok: false, error: 'no react-select Select component found' });
|
|
410
|
+
var flat = window.mcpReactSelectFlatten(found.props.options);
|
|
411
|
+
return JSON.stringify({
|
|
412
|
+
ok: true,
|
|
413
|
+
total: flat.length,
|
|
414
|
+
options: flat.map(function(o) { return { label: o && o.label, value: o && o.value }; })
|
|
415
|
+
});
|
|
416
|
+
};
|
|
331
417
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.6",
|
|
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
|
@@ -1070,7 +1070,7 @@ async function ensureHelpers() {
|
|
|
1070
1070
|
if (_activeTabURL && _helpersInjectedForUrl === _activeTabURL && (now - _helpersInjectedAt) < HELPERS_CACHE_MS) return;
|
|
1071
1071
|
|
|
1072
1072
|
// Check if helpers are actually present (not just version flag)
|
|
1073
|
-
const check = await runJS("(typeof mcpClickWithReact==='function'&&typeof mcpFindText==='function')?'ok':'missing'").catch(() => 'missing');
|
|
1073
|
+
const check = await runJS("(typeof mcpClickWithReact==='function'&&typeof mcpFindText==='function'&&typeof mcpReactSelectSet==='function')?'ok':'missing'").catch(() => 'missing');
|
|
1074
1074
|
if (check === 'ok') {
|
|
1075
1075
|
_helpersInjectedForUrl = _activeTabURL;
|
|
1076
1076
|
_helpersInjectedAt = now;
|
|
@@ -1446,7 +1446,11 @@ export async function fill({ selector, value, ref }) {
|
|
|
1446
1446
|
`if(isClosure){return '__CLOSURE_NATIVE_PASTE__';}` +
|
|
1447
1447
|
// Synthetic ClipboardEvent paste — works on ProseMirror, TipTap, Slate, and most modern editors
|
|
1448
1448
|
// that don't respond to execCommand but DO handle paste events
|
|
1449
|
-
|
|
1449
|
+
// Pre-clear: select all + delete BEFORE paste. Some editors (X's tweetTextarea_0
|
|
1450
|
+
// when it has URL-prefilled content from /intent/post?text=, Quill in some configs)
|
|
1451
|
+
// append paste content to selection instead of replacing — explicit delete avoids
|
|
1452
|
+
// the duplication seen when the textarea was pre-populated by URL parameters.
|
|
1453
|
+
`try{el.focus();var sel2=window.getSelection();if(sel2.rangeCount){var rng=document.createRange();rng.selectNodeContents(el);sel2.removeAllRanges();sel2.addRange(rng);document.execCommand('delete',false,null);}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){}` +
|
|
1450
1454
|
// Synthetic paste did not verify — in a dialog, route to native paste (CGEvent Cmd+V)
|
|
1451
1455
|
// to produce real isTrusted:true events. Avoids LinkedIn-style dialog dismissal too.
|
|
1452
1456
|
`if(!!el.closest('[role="dialog"]')){return '__NATIVE_PASTE_DIALOG__';}` +
|
|
@@ -1505,6 +1509,23 @@ export async function fill({ selector, value, ref }) {
|
|
|
1505
1509
|
`el.focus();el.click();` +
|
|
1506
1510
|
`var t=document.activeElement||el;` +
|
|
1507
1511
|
charInserts +
|
|
1512
|
+
// Verification: count actual text length vs expected. If char-by-char dropped
|
|
1513
|
+
// intermediate paragraphs (Hashnode-style Tiptap with markdown-like chars at
|
|
1514
|
+
// line starts: `>`, `**`, `[`), fall back to execCommand('insertHTML') with
|
|
1515
|
+
// paragraph-wrapped HTML.
|
|
1516
|
+
`var actualLen=(el.innerText||'').length;` +
|
|
1517
|
+
`var expectedLen=${value.length};` +
|
|
1518
|
+
`if(actualLen<expectedLen*0.6){` +
|
|
1519
|
+
// Clear and re-fill via insertHTML
|
|
1520
|
+
`var sel=window.getSelection();var r=document.createRange();r.selectNodeContents(el);sel.removeAllRanges();sel.addRange(r);` +
|
|
1521
|
+
`document.execCommand('delete',false,null);` +
|
|
1522
|
+
// Build paragraph HTML from the original value
|
|
1523
|
+
`var paras=${JSON.stringify(value)}.split(/\\n\\n+/).filter(function(p){return p.trim().length>0;});` +
|
|
1524
|
+
`var html=paras.map(function(p){var safe=p.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\\n/g,'<br>');return '<p>'+safe+'</p>';}).join('');` +
|
|
1525
|
+
`el.focus();var sel2=window.getSelection();var r2=document.createRange();r2.selectNodeContents(el);r2.collapse(true);sel2.removeAllRanges();sel2.addRange(r2);` +
|
|
1526
|
+
`document.execCommand('insertHTML',false,html);` +
|
|
1527
|
+
`return 'Filled CE (ProseMirror insertHTML fallback, '+(el.innerText||'').length+'/'+expectedLen+')';` +
|
|
1528
|
+
`}` +
|
|
1508
1529
|
`return 'Filled CE (ProseMirror char-by-char)';` +
|
|
1509
1530
|
`})()`
|
|
1510
1531
|
);
|
|
@@ -1687,6 +1708,45 @@ export async function selectOption({ selector, value }) {
|
|
|
1687
1708
|
);
|
|
1688
1709
|
}
|
|
1689
1710
|
|
|
1711
|
+
// React-Select v5 / Radix-style controlled-select bypass.
|
|
1712
|
+
// Walks React fiber up from the target element to find a Select component
|
|
1713
|
+
// (props.options + props.onChange) and calls onChange directly — no menu UI.
|
|
1714
|
+
// Use when safari_click on the chevron/option fails (Cloudflare token form,
|
|
1715
|
+
// portal-rendered selects that intercept synthetic events).
|
|
1716
|
+
export async function reactSelectSet({ selector, ref, value }) {
|
|
1717
|
+
await ensureHelpers();
|
|
1718
|
+
if (value === undefined || value === null) throw new Error("reactSelectSet requires 'value' (option label)");
|
|
1719
|
+
let finder;
|
|
1720
|
+
if (ref) {
|
|
1721
|
+
const safeRef = String(ref).replace(/'/g, "\\'");
|
|
1722
|
+
finder = `mcpFindRef('${safeRef}')`;
|
|
1723
|
+
} else if (selector) {
|
|
1724
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1725
|
+
finder = `mcpQuerySelectorDeep('${sel}')`;
|
|
1726
|
+
} else {
|
|
1727
|
+
throw new Error("reactSelectSet requires 'ref' or 'selector'");
|
|
1728
|
+
}
|
|
1729
|
+
const safeValue = String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1730
|
+
const js = `(function(){var el=${finder};if(!el)return JSON.stringify({ok:false,error:'element not found'});return window.mcpReactSelectSet(el,'${safeValue}');})()`;
|
|
1731
|
+
return runJS(js);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
export async function reactSelectListOptions({ selector, ref }) {
|
|
1735
|
+
await ensureHelpers();
|
|
1736
|
+
let finder;
|
|
1737
|
+
if (ref) {
|
|
1738
|
+
const safeRef = String(ref).replace(/'/g, "\\'");
|
|
1739
|
+
finder = `mcpFindRef('${safeRef}')`;
|
|
1740
|
+
} else if (selector) {
|
|
1741
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1742
|
+
finder = `mcpQuerySelectorDeep('${sel}')`;
|
|
1743
|
+
} else {
|
|
1744
|
+
throw new Error("reactSelectListOptions requires 'ref' or 'selector'");
|
|
1745
|
+
}
|
|
1746
|
+
const js = `(function(){var el=${finder};if(!el)return JSON.stringify({ok:false,error:'element not found'});return window.mcpReactSelectListOptions(el);})()`;
|
|
1747
|
+
return runJS(js);
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1690
1750
|
export async function fillForm({ fields }) {
|
|
1691
1751
|
// Single JS call for ALL fields (instead of N separate osascript calls).
|
|
1692
1752
|
// Same React-state-sync logic as `fill`: _valueTracker reset, prototype-correct setter,
|