safari-mcp 2.10.8 → 2.10.10

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
@@ -645,7 +645,23 @@ Safari MCP needs these one-time permissions:
645
645
  | JavaScript from Apple Events | Safari → Develop menu | Required for `do JavaScript` |
646
646
  | Automation → Safari | System Settings → Privacy & Security → Automation | Required for all AppleScript-backed tools |
647
647
  | Screen Recording | System Settings → Privacy & Security → Screen Recording | Required for `safari_screenshot` |
648
- | Accessibility | System Settings → Privacy & Security → Accessibility | Required for `safari_save_pdf` only |
648
+ | Accessibility (safari-helper) | System Settings → Privacy & Security → Accessibility | Required for `safari_native_click`, `safari_native_keyboard`, `safari_native_hover` and `safari_save_pdf` |
649
+
650
+ ### Granting Accessibility to safari-helper (required for `safari_native_*`)
651
+
652
+ The `safari_native_click`, `safari_native_keyboard` and `safari_native_hover` tools inject OS-level `CGEvent` events into Safari without stealing focus. macOS requires the underlying helper binary to be approved in **Accessibility** before those events can reach a non-frontmost window.
653
+
654
+ 1. Open **System Settings → Privacy & Security → Accessibility**.
655
+ 2. Click `+` (unlock with your password if needed).
656
+ 3. Navigate to the helper binary and add it:
657
+ - npm global install: `$(npm root -g)/safari-mcp/safari-helper`
658
+ - npx / project install: `./node_modules/safari-mcp/safari-helper`
659
+ - From source clone: `/path/to/safari-mcp/safari-helper`
660
+ 4. Make sure the toggle next to it is **ON**.
661
+
662
+ The postinstall script re-signs the helper with a stable identifier (`com.achiya-automation.safari-mcp`) so this permission survives future upgrades — without that step, every `npm update` would silently revoke approval because the binary's adhoc-signed identifier changes per build.
663
+
664
+ If `safari_native_click` reports success but the page doesn't react (no `isTrusted: true` click events fire), the helper is most likely missing this approval. The `safari_*` (non-`native_`) tools don't need it.
649
665
 
650
666
  ### Granting Automation → Safari (important for IDE users)
651
667
 
@@ -668,6 +684,7 @@ That call registers the Terminal app in the Automation database and then trigger
668
684
  | "AppleScript error" | Enable "Allow JavaScript from Apple Events" in Safari → Develop |
669
685
  | "Not authorized to send Apple events to Safari" | Grant Automation → Safari to your IDE (see above) |
670
686
  | "Not authorized" after `npm update` | Updating changes the binary's cdhash — macOS silently revokes Automation permission. Re-run the `osascript` one-liner above to re-grant it |
687
+ | `safari_native_click` reports success but page doesn't react | Add `safari-helper` to **System Settings → Privacy & Security → Accessibility** (see [Granting Accessibility](#granting-accessibility-to-safari-helper-required-for-safari_native_) above). Confirm by attaching a `click` listener with `{capture:true}` in the page console — without the grant, no `isTrusted: true` event fires |
671
688
  | Screenshots empty | Grant Screen Recording permission to Terminal/VS Code |
672
689
  | Tab not found | Call `safari_list_tabs` to refresh tab indices |
673
690
  | Hebrew keyboard issues | All typing uses JS events — immune to keyboard layout |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.10.8",
3
+ "version": "2.10.10",
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-helper CHANGED
Binary file
package/safari.js CHANGED
@@ -216,9 +216,14 @@ async function refreshTargetWindow(force = false) {
216
216
  if (!force && _targetWindowRef && (now - _targetWindowCacheTime) < TARGET_WINDOW_CACHE_MS) return;
217
217
  const safeProfile = SAFARI_PROFILE.replace(/"/g, '\\"');
218
218
  // Find profile window by name AND verify the window ID still matches
219
- const result = await osascriptFast(
220
- `tell application "Safari"\n repeat with w in every window\n if name of w starts with "${safeProfile} \u2014" then return (id of w as text) & "|" & name of w\n end repeat\n return "0|"\nend tell`
221
- ).catch(() => '0|');
219
+ const detectScript = `tell application "Safari"\n repeat with w in every window\n if name of w starts with "${safeProfile} \u2014" then return (id of w as text) & "|" & name of w\n end repeat\n return "0|"\nend tell`;
220
+ let result = await osascriptFast(detectScript).catch(() => '0|');
221
+ // The persistent helper occasionally returns '0|' for a window that genuinely
222
+ // exists (daemon timeout / restart race). Before concluding the window is
223
+ // missing, retry once with a plain osascript subprocess \u2014 slower but reliable.
224
+ if (String(result).split('|')[0] === '0') {
225
+ result = await osascript(detectScript).catch(() => '0|');
226
+ }
222
227
  const [idStr, windowName] = String(result).split('|');
223
228
  const id = Number(idStr);
224
229
  if (id > 0) {
@@ -243,7 +248,12 @@ async function refreshTargetWindow(force = false) {
243
248
  // Background verification: periodically check that cached window ID still belongs to profile
244
249
  if (SAFARI_PROFILE) {
245
250
  setInterval(async () => {
246
- if (!_targetWindowRef || !_targetWindowId) return;
251
+ // No cached window (e.g. flaky detection at startup) — keep trying to
252
+ // rediscover it so the server self-heals instead of staying stuck.
253
+ if (!_targetWindowRef || !_targetWindowId) {
254
+ await refreshTargetWindow(true).catch(() => {});
255
+ return;
256
+ }
247
257
  try {
248
258
  const name = await osascriptFast(
249
259
  `tell application "Safari" to return name of ${_targetWindowRef}`
@@ -374,15 +384,17 @@ async function resolveActiveTab() {
374
384
 
375
385
  // Strategy 1: window.__mcpTabMarker (bulletproof — survives navigation, redirects, query changes)
376
386
  // Only attempted if we have a marker AND a cached index hint
377
- if (_activeTabMarker && _activeTabIndex) {
387
+ if (_activeTabMarker) {
378
388
  try {
379
389
  const safeMarker = _activeTabMarker.replace(/'/g, "\\'");
380
390
  // Single JS call: check cached index first, then walk all tabs to find marker
381
391
  const checkScript = `(function(){return window.__mcpTabMarker==='${safeMarker}'?'1':'0'})()`;
382
- const matchAtCached = await osascriptFast(
383
- `tell application "Safari" to do JavaScript "${checkScript}" in tab ${_activeTabIndex} of ${getTargetWindowRef()}`
384
- ).catch(() => '0');
385
- if (String(matchAtCached).trim() === '1') return _activeTabIndex;
392
+ if (_activeTabIndex) {
393
+ const matchAtCached = await osascriptFast(
394
+ `tell application "Safari" to do JavaScript "${checkScript}" in tab ${_activeTabIndex} of ${getTargetWindowRef()}`
395
+ ).catch(() => '0');
396
+ if (String(matchAtCached).trim() === '1') return _activeTabIndex;
397
+ }
386
398
  // Cached index doesn't match — scan all tabs for marker
387
399
  const tabCountStr = await osascriptFast(
388
400
  `tell application "Safari" to return count of tabs of ${getTargetWindowRef()}`
@@ -764,13 +776,18 @@ async function runJS(js, { tabIndex, timeout = 15000 } = {}) {
764
776
  if (!idx && _activeTabIndex && _activeTabURL && (Date.now() - _lastResolveTime < RESOLVE_CACHE_MS)) {
765
777
  // Recently verified and tab count unchanged — use cached index
766
778
  idx = _activeTabIndex;
767
- } else if (!idx && _activeTabURL && _activeTabURL !== '') {
768
- // Resolve by URL — verify index is still correct
779
+ } else if (!idx && (_activeTabURL || _activeTabMarker)) {
780
+ // Resolve by URL or marker — the marker scan still finds an owned tab
781
+ // even after _activeTabURL has been cleared by a failed URL lookup.
769
782
  const resolved = await resolveActiveTab();
770
783
  if (resolved) { idx = resolved; _lastResolveTime = Date.now(); }
771
784
  }
772
785
  // ALWAYS fall back to _activeTabIndex — never clear it from resolve failures
773
786
  if (!idx) idx = _activeTabIndex;
787
+ // Once this session owns a tab, never silently run on the user's current tab.
788
+ if (!idx && _hasOwnedTab && SAFARI_PROFILE) {
789
+ throw new Error('Tab tracking lost during runJS — refusing to target the user\'s current tab. Call safari_new_tab to reopen.');
790
+ }
774
791
  const target = idx
775
792
  ? `tab ${idx} of ${getTargetWindowRef()}`
776
793
  : getFallbackTarget();
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
- // Safari MCP — postinstall welcome message
2
+ // Safari MCP — postinstall: codesign helper + welcome message
3
3
  // Skipped silently in CI and when stdout is not a TTY (npm install in scripts).
4
4
 
5
- if (process.env.CI || process.env.SAFARI_MCP_SILENT_INSTALL === "1") process.exit(0);
5
+ const path = require("path");
6
+ const { execSync } = require("child_process");
7
+ const fs = require("fs");
6
8
 
7
9
  const c = {
8
10
  reset: "\x1b[0m",
@@ -12,8 +14,36 @@ const c = {
12
14
  yellow: "\x1b[33m",
13
15
  magenta: "\x1b[35m",
14
16
  green: "\x1b[32m",
17
+ red: "\x1b[31m",
15
18
  };
16
19
 
20
+ // Re-sign safari-helper with a stable identifier so macOS Accessibility approval persists.
21
+ // The package ships with an adhoc-signed binary whose codesign Identifier is a one-off hash
22
+ // (e.g. `safari-helper-555549441c166aa237e130ddbe3d95629266ecaf`). macOS TCC keys grants by
23
+ // that identifier, so a fresh npm install or rebuild silently invalidates any previously-granted
24
+ // Accessibility approval — the helper keeps running but CGEvent injections to non-frontmost Safari
25
+ // windows stop reaching WebKit content (no isTrusted click events fire on the page).
26
+ // Re-signing here with the fixed identifier `com.achiya-automation.safari-mcp` keeps the grant
27
+ // stable across installs.
28
+ function ensureCodesign() {
29
+ if (process.platform !== "darwin") return;
30
+ const helper = path.join(__dirname, "..", "safari-helper");
31
+ if (!fs.existsSync(helper)) return;
32
+ try {
33
+ // Check current identifier; only re-sign if it doesn't already match.
34
+ const current = execSync(`codesign -d -- "${helper}" 2>&1 | grep ^Identifier=`, { encoding: "utf8" }).trim();
35
+ if (current.includes("com.achiya-automation.safari-mcp")) return;
36
+ const entitlements = path.join(__dirname, "..", "safari-helper.entitlements");
37
+ const entFlag = fs.existsSync(entitlements) ? `--entitlements "${entitlements}"` : "";
38
+ execSync(`codesign -s - -f --identifier com.achiya-automation.safari-mcp ${entFlag} "${helper}" 2>/dev/null`);
39
+ } catch (_e) {
40
+ // codesign may fail in environments without the toolchain; binary still works adhoc-signed.
41
+ }
42
+ }
43
+ ensureCodesign();
44
+
45
+ if (process.env.CI || process.env.SAFARI_MCP_SILENT_INSTALL === "1") process.exit(0);
46
+
17
47
  const msg = `
18
48
  ${c.bold}${c.cyan}🦁 Safari MCP installed${c.reset} ${c.dim}— 80 native browser tools for AI agents${c.reset}
19
49
 
@@ -21,6 +51,9 @@ ${c.bold}Next steps:${c.reset}
21
51
  1. Enable Safari → Develop → ${c.yellow}Allow JavaScript from Apple Events${c.reset}
22
52
  2. Add to your MCP client config:
23
53
  ${c.dim}{ "mcpServers": { "safari": { "command": "npx", "args": ["safari-mcp"] } } }${c.reset}
54
+ 3. ${c.bold}For native_click / native_keyboard${c.reset} (no focus stealing):
55
+ System Settings → Privacy & Security → ${c.yellow}Accessibility${c.reset} → add
56
+ ${c.dim}node_modules/safari-mcp/safari-helper${c.reset} ${c.dim}(or the global install path)${c.reset}
24
57
 
25
58
  ${c.bold}${c.magenta}⭐ Found this useful?${c.reset} A star helps others discover it:
26
59
  ${c.cyan}https://github.com/achiya-automation/safari-mcp${c.reset}