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 CHANGED
@@ -18,6 +18,7 @@
18
18
  [![Glama AAA](https://img.shields.io/badge/Glama-AAA_Score-22c55e?logo=ai)](https://glama.ai/mcp/servers/@achiya-automation/safari-mcp)
19
19
  [![MCP Registry](https://img.shields.io/badge/MCP_Registry-Verified-2563eb)](https://registry.modelcontextprotocol.io/v0/servers?search=achiya-automation)
20
20
  [![Smithery](https://img.shields.io/badge/Smithery-Listed-7c3aed)](https://smithery.ai/server/@achiya-automation/safari-mcp)
21
+ [![MCP Score](https://mcpscoreboard.com/badge/05977769-8762-4e89-aff3-a0c5776843bb.svg)](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
- [![Glama](https://glama.ai/mcp/servers/achiya-automation/safari-mcp/badges/score.svg)](https://glama.ai/mcp/servers/achiya-automation/safari-mcp) [![MCP Registry](https://img.shields.io/badge/MCP-Registry-purple)](https://registry.modelcontextprotocol.io/) [![CI](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml)
781
+ [![Glama](https://glama.ai/mcp/servers/achiya-automation/safari-mcp/badges/score.svg)](https://glama.ai/mcp/servers/achiya-automation/safari-mcp) [![MCP Registry](https://img.shields.io/badge/MCP-Registry-purple)](https://registry.modelcontextprotocol.io/) [![MCP Scoreboard](https://mcpscoreboard.com/badge/05977769-8762-4e89-aff3-a0c5776843bb.svg)](https://mcpscoreboard.com/server/05977769-8762-4e89-aff3-a0c5776843bb/) [![CI](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml/badge.svg)](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 !== 5) {
7
- window.__mcpVersion = 5;
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",
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
- `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){}` +
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').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,