safari-mcp 2.17.0 → 2.18.0

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
@@ -15,14 +15,9 @@
15
15
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
16
16
  [![macOS](https://img.shields.io/badge/macOS-only-blue)](https://www.apple.com/macos/)
17
17
 
18
- [![Glama AAA](https://img.shields.io/badge/Glama-AAA_Score-22c55e?logo=ai)](https://glama.ai/mcp/servers/@achiya-automation/safari-mcp)
19
- [![MCP Registry](https://img.shields.io/badge/MCP_Registry-Verified-2563eb)](https://registry.modelcontextprotocol.io/v0/servers?search=achiya-automation)
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/)
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>
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>
25
- <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>
18
+ [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) ·
19
+ [VS Code Insiders](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) ·
20
+ [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)
26
21
 
27
22
  **97 tools** · **No Chrome/Puppeteer/Playwright needed** · **~5ms per command** · **60% less CPU than Chrome**
28
23
 
@@ -61,6 +61,30 @@ async function _bridgeFetch(url, init = {}) {
61
61
  headers.set("X-Safari-MCP-Worker", _bridgeWorkerId);
62
62
  return fetch(url, { ...init, headers });
63
63
  }
64
+ const _CONTENT_WAKE_TOKEN_STORAGE_KEY = "mcpContentWakeTokenV1";
65
+ const _CONTENT_WAKE_LABEL = "safari-mcp-content-wakeup-v1";
66
+ async function _publishContentWakeToken() {
67
+ try {
68
+ const bridgeToken = await _bridgeAuthToken();
69
+ const encoder = new TextEncoder();
70
+ const key = await crypto.subtle.importKey(
71
+ "raw",
72
+ encoder.encode(bridgeToken),
73
+ { name: "HMAC", hash: "SHA-256" },
74
+ false,
75
+ ["sign"]
76
+ );
77
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(_CONTENT_WAKE_LABEL));
78
+ const wakeToken = Array.from(new Uint8Array(signature), (byte) =>
79
+ byte.toString(16).padStart(2, "0")
80
+ ).join("");
81
+ if (!/^[0-9a-f]{64}$/.test(wakeToken)) throw new Error("invalid content wake token");
82
+ await browser.storage.local.set({ [_CONTENT_WAKE_TOKEN_STORAGE_KEY]: wakeToken });
83
+ } catch (error) {
84
+ await browser.storage.local.remove(_CONTENT_WAKE_TOKEN_STORAGE_KEY).catch(() => {});
85
+ throw error;
86
+ }
87
+ }
64
88
  let _targetProfile = null; // Profile name from server (e.g. "Automations")
65
89
  let _profileWindowId = null; // Discovered windowId for the profile (shared default)
66
90
  // Per-session profile window. One Safari profile can hold SEVERAL windows once
@@ -419,6 +443,18 @@ let _startupReady = browser.storage.local.get("mcpEnabled").then(data => {
419
443
 
420
444
  // Listen for messages from popup
421
445
  browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
446
+ // Safari can suspend an MV3 worker even while a content-script Port exists. A
447
+ // leader page therefore sends this fixed, authority-free message as a second
448
+ // keepalive signal. runtime.sendMessage is an extension event, so it wakes the
449
+ // worker on Safari versions where Port traffic alone does not. Never accept tab,
450
+ // URL, receipt, session, or command data through this path.
451
+ if (msg?.action === "mcpContentKeepalivePingV1") {
452
+ if (Object.keys(msg).length !== 1) return false;
453
+ if (!Number.isInteger(sender?.tab?.id)) return false;
454
+ if (!_enabled || _bridgeWorkerSuperseded || _bridgeWorkerRetiring) return false;
455
+ sendResponse({ ok: true });
456
+ return false;
457
+ }
422
458
  if (msg.action === "setEnabled") {
423
459
  _enabled = msg.enabled;
424
460
  if (!_enabled) {
@@ -428,6 +464,7 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
428
464
  if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
429
465
  _reconnectDelay = 3000;
430
466
  _stopHeartbeat();
467
+ _disconnectContentKeepalivePort();
431
468
  updateBadge("OFF");
432
469
  } else {
433
470
  updateBadge("");
@@ -449,9 +486,31 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
449
486
  // it into a command channel: accept only the exact name, require a real sender tab,
450
487
  // inspect only the fixed ping type, and never receive or return tab/session data.
451
488
  const _CONTENT_KEEPALIVE_PORT_NAME = "mcp-content-keepalive-v1";
489
+ let _contentKeepalivePort = null;
490
+ function _disconnectContentKeepalivePort() {
491
+ const port = _contentKeepalivePort;
492
+ _contentKeepalivePort = null;
493
+ if (port) {
494
+ try { port.disconnect(); } catch (_) {}
495
+ }
496
+ }
452
497
  browser.runtime.onConnect.addListener((port) => {
453
498
  if (port?.name !== _CONTENT_KEEPALIVE_PORT_NAME) return;
454
499
  if (!Number.isInteger(port?.sender?.tab?.id)) return;
500
+ if (!_enabled || _bridgeWorkerSuperseded || _bridgeWorkerRetiring) {
501
+ try { port.disconnect(); } catch (_) {}
502
+ return;
503
+ }
504
+ // The content-side lease should already converge on one sender. Enforce the same
505
+ // invariant here so a brief storage-election race cannot leave many live Ports.
506
+ if (_contentKeepalivePort && _contentKeepalivePort !== port) {
507
+ try { port.disconnect(); } catch (_) {}
508
+ return;
509
+ }
510
+ _contentKeepalivePort = port;
511
+ port.onDisconnect.addListener(() => {
512
+ if (_contentKeepalivePort === port) _contentKeepalivePort = null;
513
+ });
455
514
  port.onMessage.addListener((message) => {
456
515
  if (message?.type !== "ping") return;
457
516
  // Intentionally empty: delivery of this event is the keepalive.
@@ -522,6 +581,14 @@ async function connect() {
522
581
  signal: AbortSignal.timeout(1500),
523
582
  });
524
583
  if (_restartInvalidatedConnect(pollGeneration)) return;
584
+ if (res.status === 423) {
585
+ isConnected = false;
586
+ updateBadge("");
587
+ _stopHeartbeat();
588
+ scheduleReconnect(3000);
589
+ _connecting = false;
590
+ return;
591
+ }
525
592
  if (!res.ok) continue;
526
593
 
527
594
  const data = await res.json().catch(() => ({}));
@@ -546,7 +613,7 @@ async function connect() {
546
613
  isConnected = false;
547
614
  updateBadge("");
548
615
  _stopHeartbeat();
549
- scheduleReconnect();
616
+ scheduleReconnect(3000);
550
617
  _connecting = false;
551
618
  return;
552
619
  }
@@ -581,17 +648,22 @@ async function connect() {
581
648
  _connecting = false;
582
649
  }
583
650
 
584
- function scheduleReconnect() {
651
+ function scheduleReconnect(delayOverrideMs = null) {
585
652
  if (!_enabled || _bridgeWorkerSuperseded || _bridgeWorkerRetiring) return;
586
653
  // Cancel any existing reconnect to prevent exponential growth
587
654
  if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
588
655
 
589
656
  // Single timer with exponential backoff (3s → 6s → 12s → ... → 60s max)
657
+ const delay = Number.isFinite(delayOverrideMs)
658
+ ? Math.max(0, Number(delayOverrideMs))
659
+ : _reconnectDelay;
590
660
  _reconnectTimer = setTimeout(() => {
591
661
  _reconnectTimer = null;
592
662
  connect();
593
- }, _reconnectDelay);
594
- _reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
663
+ }, delay);
664
+ if (!Number.isFinite(delayOverrideMs)) {
665
+ _reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
666
+ }
595
667
 
596
668
  // Alarm as backup — wakes terminated service worker (Safari minimum 1 minute)
597
669
  try {
@@ -622,6 +694,7 @@ async function pollForCommands(pollGeneration) {
622
694
  isConnected = false;
623
695
  updateBadge("");
624
696
  _stopHeartbeat();
697
+ _disconnectContentKeepalivePort();
625
698
  return;
626
699
  }
627
700
  if (res.status === 401 || res.status === 403 || res.status === 409) {
@@ -1014,11 +1087,17 @@ async function handleCommand(type, payload) {
1014
1087
  );
1015
1088
  if (!r || r.ok !== true) throw new Error((r && r.error) || "content bridge failed");
1016
1089
  _injectionBlockedTabs.add(evalTabId);
1090
+ if (evalOrigin) _injectionBlockedOrigins.set(evalOrigin, Date.now() + _INJECTION_BLOCK_TTL_MS);
1017
1091
  return r.value;
1018
1092
  };
1019
- if (_injectionBlockedTabs.has(evalTabId)) {
1093
+ const evalOrigin = _originKey(targetTab?.url);
1094
+ const originBlocked = !!evalOrigin && (_injectionBlockedOrigins.get(evalOrigin) || 0) > Date.now();
1095
+ if (_injectionBlockedTabs.has(evalTabId) || originBlocked) {
1020
1096
  try { return await viaBridge(); }
1021
- catch (_e) { _injectionBlockedTabs.delete(evalTabId); } // page changed — re-probe
1097
+ catch (_e) { // page changed — re-probe
1098
+ _injectionBlockedTabs.delete(evalTabId);
1099
+ if (evalOrigin) _injectionBlockedOrigins.delete(evalOrigin);
1100
+ }
1022
1101
  }
1023
1102
 
1024
1103
  // First contact with a hardened page: every execInTab strategy below will stall.
@@ -1129,14 +1208,23 @@ async function handleCommand(type, payload) {
1129
1208
  // If it fails, fall back to AppleScript screencapture -l (which also doesn't steal focus).
1130
1209
  // NEVER use browser.windows.update({ focused: true }) — it steals user's keyboard/mouse.
1131
1210
  let captureWindowId = _profileWindowId || null;
1211
+ // captureVisibleTab only sees the window's selected tab, so ours must be selected for
1212
+ // the capture — but the selection belongs to the user when they browse this window.
1213
+ // Remember what they had selected and put it back (the AppleScript path already does
1214
+ // this via _withTargetTabFronted; this path used to leave our tab in front).
1215
+ let restoreTabId = null;
1132
1216
  if (tabId) {
1133
1217
  try {
1134
1218
  const tabInfo = await browser.tabs.get(tabId);
1135
1219
  captureWindowId = tabInfo.windowId;
1136
- // Only activate the correct tab — does NOT bring Safari window to foreground
1137
- await browser.tabs.update(tabId, { active: true });
1220
+ if (!tabInfo.active) {
1221
+ const [selected] = await browser.tabs.query({ active: true, windowId: tabInfo.windowId });
1222
+ if (selected && selected.id !== tabId) restoreTabId = selected.id;
1223
+ // Only selects the tab — does NOT bring the Safari window to the foreground
1224
+ await browser.tabs.update(tabId, { active: true });
1225
+ await new Promise(r => setTimeout(r, 150));
1226
+ }
1138
1227
  } catch (_) {}
1139
- await new Promise(r => setTimeout(r, 150));
1140
1228
  }
1141
1229
  // Use JPEG with quality 50 to reduce size (~600KB PNG → ~60KB JPEG)
1142
1230
  try {
@@ -1154,6 +1242,8 @@ async function handleCommand(type, payload) {
1154
1242
  }
1155
1243
  // Any other error also falls back — better than stealing focus
1156
1244
  return "__SCREENSHOT_PERMISSION_DENIED__";
1245
+ } finally {
1246
+ if (restoreTabId !== null) browser.tabs.update(restoreTabId, { active: true }).catch(() => {});
1157
1247
  }
1158
1248
  }
1159
1249
 
@@ -3644,6 +3734,15 @@ const MAIN_WORLD_INJECT_MS = 3000;
3644
3734
  // pages fast instead of merely working. Cleared when the tab navigates or closes, so a
3645
3735
  // page that stops blocking injection is re-probed rather than downgraded forever.
3646
3736
  const _injectionBlockedTabs = new Set();
3737
+ // Same fact, keyed by origin: the per-tab set is cleared on every navigation, so a hardened
3738
+ // SPA (business.facebook.com) re-paid the ~10s probe on the first evaluate after EACH
3739
+ // navigate — 356 evaluates ≥10s in one week. An origin that blocked injection once is sent
3740
+ // straight to the bridge for a few hours; a bridge failure clears it and re-probes.
3741
+ const _injectionBlockedOrigins = new Map(); // origin → expiry (ms)
3742
+ const _INJECTION_BLOCK_TTL_MS = 6 * 60 * 60 * 1000;
3743
+ function _originKey(url) {
3744
+ try { return new URL(String(url || "")).origin; } catch { return ""; }
3745
+ }
3647
3746
  function _withInjectionDeadline(promise, ms = MAIN_WORLD_INJECT_MS) {
3648
3747
  let timer;
3649
3748
  return Promise.race([
@@ -4284,6 +4383,7 @@ console.log("Safari MCP Bridge: service worker started");
4284
4383
  updateBadge("");
4285
4384
  // Wait for storage to load before connecting (prevents race condition with _enabled)
4286
4385
  _startupReady.then(() => {
4386
+ _publishContentWakeToken().catch(() => {});
4287
4387
  if (_enabled) connect();
4288
4388
  else updateBadge("OFF");
4289
4389
  }).catch(() => connect());