safari-mcp 2.10.5 → 2.10.7

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;
@@ -166,6 +166,18 @@ if (window.__mcpVersion !== 5) {
166
166
  }
167
167
  } catch (e) {}
168
168
  target.dispatchEvent(new MouseEvent('click', { ...s, buttons: 0 }));
169
+ // Vue v-model / Lit @change / framework-agnostic toggle sync — for checkbox/radio,
170
+ // Vue 3 listens for `change` via v-model; some Vue components subscribe to `input` instead.
171
+ // Native target.click() fires both, but in production-stripped Vue apps (no fiber/proxy
172
+ // visible), the reactivity sometimes misses the events when fired in the same microtask
173
+ // as a synthetic click. Belt-and-suspenders: re-dispatch input+change with composed:true
174
+ // and reset React's _valueTracker so subsequent submits see the new value.
175
+ if (target.tagName === 'INPUT' && (target.type === 'checkbox' || target.type === 'radio')) {
176
+ try { if (target._valueTracker && typeof target._valueTracker.setValue === 'function') target._valueTracker.setValue(''); } catch (_e) {}
177
+ var toggleOpts = { bubbles: true, cancelable: true, composed: true };
178
+ target.dispatchEvent(new Event('input', toggleOpts));
179
+ target.dispatchEvent(new Event('change', toggleOpts));
180
+ }
169
181
  if (href && href !== beforeUrl) {
170
182
  location.href = href;
171
183
  }
@@ -328,4 +340,90 @@ if (window.__mcpVersion !== 5) {
328
340
  }
329
341
  return best;
330
342
  };
343
+ // React-Select v5 / Radix-style controlled-select bypass: walks fiber up from the
344
+ // target element to find a Select component (props.options + props.onChange), then
345
+ // invokes onChange directly with the matching option. Avoids menu UI entirely —
346
+ // critical for Cloudflare custom-token forms where the dropdown indicator stops
347
+ // responding to dispatched clicks after a few rows.
348
+ window.mcpReactSelectFindInstance = function(el) {
349
+ if (!el) return null;
350
+ var node = el;
351
+ for (var d = 0; d < 25 && node; d++) {
352
+ var keys = Object.keys(node);
353
+ var fk;
354
+ for (var ki = 0; ki < keys.length; ki++) {
355
+ if (keys[ki].indexOf('__reactFiber$') === 0 || keys[ki].indexOf('__reactInternalInstance$') === 0) { fk = keys[ki]; break; }
356
+ }
357
+ if (fk) {
358
+ var fiber = node[fk], hops = 0;
359
+ while (fiber && hops < 60) {
360
+ var p = fiber.memoizedProps || fiber.pendingProps || (fiber.stateNode && fiber.stateNode.props);
361
+ if (p && Array.isArray(p.options) && typeof p.onChange === 'function') {
362
+ return { props: p, fiber: fiber };
363
+ }
364
+ fiber = fiber.return;
365
+ hops++;
366
+ }
367
+ }
368
+ node = node.parentElement;
369
+ }
370
+ return null;
371
+ };
372
+ window.mcpReactSelectFlatten = function(options) {
373
+ var flat = [];
374
+ for (var i = 0; i < options.length; i++) {
375
+ var o = options[i];
376
+ if (o && Array.isArray(o.options)) {
377
+ for (var j = 0; j < o.options.length; j++) flat.push(o.options[j]);
378
+ } else if (o) {
379
+ flat.push(o);
380
+ }
381
+ }
382
+ return flat;
383
+ };
384
+ window.mcpReactSelectSet = function(el, optionLabel) {
385
+ var found = window.mcpReactSelectFindInstance(el);
386
+ if (!found) return JSON.stringify({ ok: false, error: 'no react-select Select component found in fiber tree' });
387
+ var p = found.props;
388
+ var flat = window.mcpReactSelectFlatten(p.options);
389
+ if (!flat.length) return JSON.stringify({ ok: false, error: 'Select component has no options (try opening the menu first to populate)' });
390
+ var needle = String(optionLabel);
391
+ var target = null;
392
+ for (var i = 0; i < flat.length; i++) {
393
+ if (flat[i] && (flat[i].label === needle || flat[i].value === needle)) { target = flat[i]; break; }
394
+ }
395
+ if (!target) {
396
+ var lcNeedle = needle.toLowerCase();
397
+ for (var k = 0; k < flat.length; k++) {
398
+ var lab = String(flat[k] && (flat[k].label !== undefined ? flat[k].label : flat[k].value) || '').toLowerCase();
399
+ if (lab === lcNeedle) { target = flat[k]; break; }
400
+ }
401
+ }
402
+ if (!target) {
403
+ return JSON.stringify({
404
+ ok: false,
405
+ error: 'option not found',
406
+ searched: needle,
407
+ total: flat.length,
408
+ available: flat.slice(0, 30).map(function(o) { return o && (o.label !== undefined ? o.label : o.value); })
409
+ });
410
+ }
411
+ var action = { action: 'select-option', option: target, name: p.name };
412
+ try {
413
+ p.onChange(target, action);
414
+ return JSON.stringify({ ok: true, selected: target.label !== undefined ? target.label : target.value });
415
+ } catch (e) {
416
+ return JSON.stringify({ ok: false, error: 'onChange threw: ' + e.message });
417
+ }
418
+ };
419
+ window.mcpReactSelectListOptions = function(el) {
420
+ var found = window.mcpReactSelectFindInstance(el);
421
+ if (!found) return JSON.stringify({ ok: false, error: 'no react-select Select component found' });
422
+ var flat = window.mcpReactSelectFlatten(found.props.options);
423
+ return JSON.stringify({
424
+ ok: true,
425
+ total: flat.length,
426
+ options: flat.map(function(o) { return { label: o && o.label, value: o && o.value }; })
427
+ });
428
+ };
331
429
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.10.5",
3
+ "version": "2.10.7",
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;
@@ -1708,6 +1708,45 @@ export async function selectOption({ selector, value }) {
1708
1708
  );
1709
1709
  }
1710
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
+
1711
1750
  export async function fillForm({ fields }) {
1712
1751
  // Single JS call for ALL fields (instead of N separate osascript calls).
1713
1752
  // Same React-state-sync logic as `fill`: _valueTracker reset, prototype-correct setter,