safari-mcp 2.17.0 → 2.17.1

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
 
@@ -428,6 +428,7 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
428
428
  if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
429
429
  _reconnectDelay = 3000;
430
430
  _stopHeartbeat();
431
+ _disconnectContentKeepalivePort();
431
432
  updateBadge("OFF");
432
433
  } else {
433
434
  updateBadge("");
@@ -449,9 +450,31 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
449
450
  // it into a command channel: accept only the exact name, require a real sender tab,
450
451
  // inspect only the fixed ping type, and never receive or return tab/session data.
451
452
  const _CONTENT_KEEPALIVE_PORT_NAME = "mcp-content-keepalive-v1";
453
+ let _contentKeepalivePort = null;
454
+ function _disconnectContentKeepalivePort() {
455
+ const port = _contentKeepalivePort;
456
+ _contentKeepalivePort = null;
457
+ if (port) {
458
+ try { port.disconnect(); } catch (_) {}
459
+ }
460
+ }
452
461
  browser.runtime.onConnect.addListener((port) => {
453
462
  if (port?.name !== _CONTENT_KEEPALIVE_PORT_NAME) return;
454
463
  if (!Number.isInteger(port?.sender?.tab?.id)) return;
464
+ if (!_enabled || _bridgeWorkerSuperseded || _bridgeWorkerRetiring) {
465
+ try { port.disconnect(); } catch (_) {}
466
+ return;
467
+ }
468
+ // The content-side lease should already converge on one sender. Enforce the same
469
+ // invariant here so a brief storage-election race cannot leave many live Ports.
470
+ if (_contentKeepalivePort && _contentKeepalivePort !== port) {
471
+ try { port.disconnect(); } catch (_) {}
472
+ return;
473
+ }
474
+ _contentKeepalivePort = port;
475
+ port.onDisconnect.addListener(() => {
476
+ if (_contentKeepalivePort === port) _contentKeepalivePort = null;
477
+ });
455
478
  port.onMessage.addListener((message) => {
456
479
  if (message?.type !== "ping") return;
457
480
  // Intentionally empty: delivery of this event is the keepalive.
@@ -622,6 +645,7 @@ async function pollForCommands(pollGeneration) {
622
645
  isConnected = false;
623
646
  updateBadge("");
624
647
  _stopHeartbeat();
648
+ _disconnectContentKeepalivePort();
625
649
  return;
626
650
  }
627
651
  if (res.status === 401 || res.status === 403 || res.status === 409) {
@@ -7,38 +7,110 @@
7
7
 
8
8
  // Register on every execution. Safari can preserve the isolated-world globals while
9
9
  // invalidating the old runtime listener during an extension update; a persistent
10
- // boolean guard then falsely says the bridge is installed. sendContentCommand only
11
- // reinjects this file after a message failure, so duplicate live listeners are rare
12
- // and harmless (the first synchronous response wins).
10
+ // boolean guard would then falsely say the bridge is installed. Hot reload can also
11
+ // leave the old listener alive, so every listener belongs to a revocable generation:
12
+ // only the newest generation may execute a command, even if removing the old runtime
13
+ // listener fails because its extension runtime has already been invalidated.
13
14
 
14
15
  // Safari may unload an MV3 service worker even while it owns an HTTP long-poll. A
15
- // content script lives with the page, so use a narrow extension Port to generate a
16
- // real worker event comfortably inside Safari's roughly 30-second idle window. The
17
- // ping carries no page URL, DOM data, receipt, or other authority. If Safari drops the
18
- // worker, onDisconnect retries quickly; the normal interval remains the bounded
19
- // fallback if the extension was reloaded and this script's runtime became stale.
16
+ // content script lives with the page, so elect ONE top-frame page to send a narrow
17
+ // extension-Port ping inside Safari's roughly 30-second idle window. Visible pages
18
+ // take priority; when Safari marks every background-app page hidden, a short storage
19
+ // lease provides one fallback leader. Non-leaders have no Port and do no periodic
20
+ // storage work. The shared record and ping contain no URL, DOM data, receipt, or other
21
+ // authority.
22
+ (() => {
23
+ const previousKeepaliveState = globalThis.__mcpKeepaliveState;
24
+ const inheritedKeepaliveId = /^[0-9a-f]{24}$/.test(String(previousKeepaliveState?.leaseId || ""))
25
+ ? previousKeepaliveState.leaseId
26
+ : "";
27
+ try { previousKeepaliveState?.cleanup?.(); } catch (_) {}
28
+
20
29
  const _MCP_KEEPALIVE_PORT_NAME = "mcp-content-keepalive-v1";
21
- const _MCP_KEEPALIVE_INTERVAL_MS = 10000;
30
+ const _MCP_KEEPALIVE_LEASE_KEY = "mcpContentKeepaliveLeaseV1";
31
+ const _MCP_KEEPALIVE_LEASE_MS = 26000;
32
+ const _MCP_KEEPALIVE_RENEW_MS = 18000;
33
+ const _MCP_KEEPALIVE_PING_MS = 8000;
22
34
  const _MCP_KEEPALIVE_RECONNECT_MS = 1500;
35
+ const _mcpKeepaliveInstanceId = inheritedKeepaliveId || (() => {
36
+ const bytes = new Uint8Array(12);
37
+ crypto.getRandomValues(bytes);
38
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
39
+ })();
23
40
  let _mcpKeepalivePort = null;
24
41
  let _mcpKeepaliveReconnectTimer = null;
25
- let _mcpKeepaliveTickRunning = false;
42
+ let _mcpKeepaliveLeaseTimer = null;
43
+ let _mcpKeepaliveLeaseTimerDueAt = 0;
44
+ let _mcpKeepalivePingTimer = null;
45
+ let _mcpKeepaliveEnabled = true;
46
+ let _mcpKeepaliveEnabledRevision = 0;
47
+ let _mcpKeepaliveLeader = false;
48
+ let _mcpKeepaliveClaiming = false;
49
+ let _mcpKeepaliveDisposed = false;
50
+
51
+ function _isMcpKeepaliveTopFrame() {
52
+ try { return window.top === window; } catch (_) { return false; }
53
+ }
54
+
55
+ function _mcpKeepaliveVisible() {
56
+ return document.visibilityState === "visible";
57
+ }
58
+
59
+ function _validMcpKeepaliveLease(value, now = Date.now()) {
60
+ return value && typeof value.id === "string" && /^[0-9a-f]{24}$/.test(value.id)
61
+ && Number.isFinite(value.expiresAt) && value.expiresAt > now
62
+ && value.expiresAt <= now + (_MCP_KEEPALIVE_LEASE_MS * 2)
63
+ ? value
64
+ : null;
65
+ }
66
+
67
+ function _clearMcpKeepaliveLeaseTimer() {
68
+ if (_mcpKeepaliveLeaseTimer) clearTimeout(_mcpKeepaliveLeaseTimer);
69
+ _mcpKeepaliveLeaseTimer = null;
70
+ _mcpKeepaliveLeaseTimerDueAt = 0;
71
+ }
72
+
73
+ function _scheduleMcpKeepaliveLeaseCheck(delayMs) {
74
+ if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled || !_isMcpKeepaliveTopFrame()) return;
75
+ const boundedDelay = Math.max(0, Math.min(Number(delayMs) || 0, _MCP_KEEPALIVE_LEASE_MS));
76
+ const dueAt = Date.now() + boundedDelay;
77
+ if (_mcpKeepaliveLeaseTimer && _mcpKeepaliveLeaseTimerDueAt <= dueAt) return;
78
+ _clearMcpKeepaliveLeaseTimer();
79
+ _mcpKeepaliveLeaseTimerDueAt = dueAt;
80
+ _mcpKeepaliveLeaseTimer = setTimeout(() => {
81
+ if (_mcpKeepaliveDisposed) return;
82
+ _mcpKeepaliveLeaseTimer = null;
83
+ _mcpKeepaliveLeaseTimerDueAt = 0;
84
+ if (_mcpKeepaliveLeader) _renewMcpKeepaliveLeaseAndPing();
85
+ else _attemptMcpKeepaliveClaim();
86
+ }, boundedDelay);
87
+ }
26
88
 
27
89
  function _scheduleMcpKeepaliveReconnect() {
28
- if (_mcpKeepaliveReconnectTimer) return;
90
+ if (_mcpKeepaliveDisposed || _mcpKeepaliveReconnectTimer || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return;
29
91
  _mcpKeepaliveReconnectTimer = setTimeout(() => {
30
92
  _mcpKeepaliveReconnectTimer = null;
31
- _mcpKeepaliveTick();
93
+ if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return;
94
+ const port = _openMcpKeepalivePort();
95
+ if (port) {
96
+ try { port.postMessage({ type: "ping" }); }
97
+ catch (_) {
98
+ if (_mcpKeepalivePort === port) _mcpKeepalivePort = null;
99
+ try { port.disconnect(); } catch (_) {}
100
+ _scheduleMcpKeepaliveReconnect();
101
+ }
102
+ }
32
103
  }, _MCP_KEEPALIVE_RECONNECT_MS);
33
104
  }
34
105
 
35
106
  function _openMcpKeepalivePort() {
107
+ if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return null;
36
108
  if (_mcpKeepalivePort) return _mcpKeepalivePort;
37
109
  try {
38
110
  const port = browser.runtime.connect({ name: _MCP_KEEPALIVE_PORT_NAME });
39
111
  _mcpKeepalivePort = port;
40
112
  port.onDisconnect.addListener(() => {
41
- if (_mcpKeepalivePort !== port) return;
113
+ if (_mcpKeepaliveDisposed || _mcpKeepalivePort !== port) return;
42
114
  _mcpKeepalivePort = null;
43
115
  _scheduleMcpKeepaliveReconnect();
44
116
  });
@@ -49,58 +121,248 @@ function _openMcpKeepalivePort() {
49
121
  }
50
122
  }
51
123
 
52
- async function _mcpKeepaliveTick() {
53
- if (_mcpKeepaliveTickRunning) return;
54
- _mcpKeepaliveTickRunning = true;
124
+ function _closeMcpKeepalivePort() {
125
+ const port = _mcpKeepalivePort;
126
+ _mcpKeepalivePort = null;
127
+ if (port) {
128
+ try { port.disconnect(); } catch (_) {}
129
+ }
130
+ }
131
+
132
+ function _clearMcpKeepalivePingTimer() {
133
+ if (_mcpKeepalivePingTimer) clearTimeout(_mcpKeepalivePingTimer);
134
+ _mcpKeepalivePingTimer = null;
135
+ }
136
+
137
+ function _sendMcpKeepalivePing() {
138
+ if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return;
139
+ _clearMcpKeepalivePingTimer();
140
+ const port = _openMcpKeepalivePort();
141
+ if (port) {
142
+ try { port.postMessage({ type: "ping" }); }
143
+ catch (_) {
144
+ if (_mcpKeepalivePort === port) _mcpKeepalivePort = null;
145
+ try { port.disconnect(); } catch (_) {}
146
+ _scheduleMcpKeepaliveReconnect();
147
+ }
148
+ }
149
+ _mcpKeepalivePingTimer = setTimeout(_sendMcpKeepalivePing, _MCP_KEEPALIVE_PING_MS);
150
+ }
151
+
152
+ function _stopMcpKeepaliveLeadership() {
153
+ _mcpKeepaliveLeader = false;
154
+ _clearMcpKeepaliveLeaseTimer();
155
+ if (_mcpKeepaliveReconnectTimer) clearTimeout(_mcpKeepaliveReconnectTimer);
156
+ _mcpKeepaliveReconnectTimer = null;
157
+ _clearMcpKeepalivePingTimer();
158
+ _closeMcpKeepalivePort();
159
+ }
160
+
161
+ async function _renewMcpKeepaliveLeaseAndPing() {
162
+ if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled || !_isMcpKeepaliveTopFrame()) return;
163
+ const stored = await browser.storage.local.get(_MCP_KEEPALIVE_LEASE_KEY).catch(() => ({}));
164
+ if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return;
165
+ const current = _validMcpKeepaliveLease(stored?.[_MCP_KEEPALIVE_LEASE_KEY]);
166
+ if (!current || current.id !== _mcpKeepaliveInstanceId) {
167
+ _stopMcpKeepaliveLeadership();
168
+ _considerMcpKeepaliveLeadership(current);
169
+ return;
170
+ }
171
+ const lease = {
172
+ id: _mcpKeepaliveInstanceId,
173
+ expiresAt: Date.now() + _MCP_KEEPALIVE_LEASE_MS,
174
+ visible: _mcpKeepaliveVisible(),
175
+ };
176
+ await browser.storage.local.set({ [_MCP_KEEPALIVE_LEASE_KEY]: lease }).catch(() => {});
177
+ if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return;
178
+ _sendMcpKeepalivePing();
179
+ _scheduleMcpKeepaliveLeaseCheck(_MCP_KEEPALIVE_RENEW_MS);
180
+ }
181
+
182
+ function _becomeMcpKeepaliveLeader() {
183
+ if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled || !_isMcpKeepaliveTopFrame()) return;
184
+ _mcpKeepaliveLeader = true;
185
+ _clearMcpKeepaliveLeaseTimer();
186
+ _renewMcpKeepaliveLeaseAndPing();
187
+ }
188
+
189
+ async function _attemptMcpKeepaliveClaim() {
190
+ if (_mcpKeepaliveDisposed || _mcpKeepaliveClaiming || _mcpKeepaliveLeader || !_mcpKeepaliveEnabled || !_isMcpKeepaliveTopFrame()) return;
191
+ _mcpKeepaliveClaiming = true;
55
192
  try {
56
- // Respect the popup's OFF state without waking the service worker merely to ask.
57
- const stored = await browser.storage.local.get("mcpEnabled").catch(() => null);
58
- if (!stored || stored.mcpEnabled === false) {
59
- if (_mcpKeepalivePort) {
60
- try { _mcpKeepalivePort.disconnect(); } catch (_) {}
61
- _mcpKeepalivePort = null;
62
- }
193
+ const stored = await browser.storage.local.get(_MCP_KEEPALIVE_LEASE_KEY).catch(() => ({}));
194
+ if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return;
195
+ const current = _validMcpKeepaliveLease(stored?.[_MCP_KEEPALIVE_LEASE_KEY]);
196
+ const mayPreemptHidden = _mcpKeepaliveVisible() && current && current.visible !== true;
197
+ if (current && current.id !== _mcpKeepaliveInstanceId && !mayPreemptHidden) {
198
+ const jitter = _mcpKeepaliveVisible() ? Math.random() * 100 : 250 + Math.random() * 1000;
199
+ _scheduleMcpKeepaliveLeaseCheck(current.expiresAt - Date.now() + jitter);
63
200
  return;
64
201
  }
65
- const port = _openMcpKeepalivePort();
66
- if (!port) return;
67
- try {
68
- port.postMessage({ type: "ping" });
69
- } catch (_) {
70
- if (_mcpKeepalivePort === port) _mcpKeepalivePort = null;
71
- _scheduleMcpKeepaliveReconnect();
202
+ const candidate = {
203
+ id: _mcpKeepaliveInstanceId,
204
+ expiresAt: Date.now() + _MCP_KEEPALIVE_LEASE_MS,
205
+ visible: _mcpKeepaliveVisible(),
206
+ };
207
+ const storedCandidate = await browser.storage.local
208
+ .set({ [_MCP_KEEPALIVE_LEASE_KEY]: candidate })
209
+ .then(() => true)
210
+ .catch(() => false);
211
+ if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return;
212
+ if (!storedCandidate) {
213
+ _scheduleMcpKeepaliveLeaseCheck(1000 + Math.random() * 1000);
214
+ return;
72
215
  }
216
+ // Storage writes are serialized but not compare-and-swap. A short settle window
217
+ // lets simultaneous candidates overwrite one another; only the final stored id
218
+ // is allowed to open a Port.
219
+ await new Promise((resolve) => setTimeout(resolve, 75 + Math.random() * 75));
220
+ if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return;
221
+ const verified = await browser.storage.local.get(_MCP_KEEPALIVE_LEASE_KEY).catch(() => ({}));
222
+ if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return;
223
+ if (verified?.[_MCP_KEEPALIVE_LEASE_KEY]?.id === _mcpKeepaliveInstanceId) {
224
+ _becomeMcpKeepaliveLeader();
225
+ return;
226
+ }
227
+ _considerMcpKeepaliveLeadership(verified?.[_MCP_KEEPALIVE_LEASE_KEY]);
73
228
  } finally {
74
- _mcpKeepaliveTickRunning = false;
229
+ _mcpKeepaliveClaiming = false;
230
+ }
231
+ }
232
+
233
+ function _considerMcpKeepaliveLeadership(knownLease) {
234
+ if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled || !_isMcpKeepaliveTopFrame()) return;
235
+ const current = _validMcpKeepaliveLease(knownLease);
236
+ if (current?.id === _mcpKeepaliveInstanceId) {
237
+ if (!_mcpKeepaliveLeader && !_mcpKeepaliveClaiming) _becomeMcpKeepaliveLeader();
238
+ return;
239
+ }
240
+ if (current) {
241
+ if (_mcpKeepaliveVisible() && current.visible !== true) {
242
+ _scheduleMcpKeepaliveLeaseCheck(Math.random() * 100);
243
+ } else {
244
+ const jitter = _mcpKeepaliveVisible() ? Math.random() * 100 : 250 + Math.random() * 1000;
245
+ _scheduleMcpKeepaliveLeaseCheck(current.expiresAt - Date.now() + jitter);
246
+ }
247
+ return;
75
248
  }
249
+ // Let a visible page win first. If Safari reports every background-app page as
250
+ // hidden, randomized fallback candidates still converge on one verified lease.
251
+ const initialDelay = _mcpKeepaliveVisible()
252
+ ? Math.random() * 100
253
+ : 1000 + Math.random() * 1500;
254
+ _scheduleMcpKeepaliveLeaseCheck(initialDelay);
76
255
  }
77
256
 
78
- _mcpKeepaliveTick();
79
- setInterval(_mcpKeepaliveTick, _MCP_KEEPALIVE_INTERVAL_MS);
257
+ function _onMcpKeepaliveVisibilityChange() {
258
+ if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return;
259
+ if (_mcpKeepaliveLeader) {
260
+ _renewMcpKeepaliveLeaseAndPing();
261
+ } else {
262
+ browser.storage.local.get(_MCP_KEEPALIVE_LEASE_KEY)
263
+ .then((stored) => _considerMcpKeepaliveLeadership(stored?.[_MCP_KEEPALIVE_LEASE_KEY]))
264
+ .catch(() => {});
265
+ }
266
+ }
267
+
268
+ async function _initializeMcpKeepalive() {
269
+ if (_mcpKeepaliveDisposed || !_isMcpKeepaliveTopFrame()) return;
270
+ document.addEventListener("visibilitychange", _onMcpKeepaliveVisibilityChange);
271
+ browser.storage.onChanged.addListener(_onMcpKeepaliveStorageChanged);
272
+ const enabledRevision = _mcpKeepaliveEnabledRevision;
273
+ const stored = await browser.storage.local.get(["mcpEnabled", _MCP_KEEPALIVE_LEASE_KEY]).catch(() => ({}));
274
+ if (_mcpKeepaliveDisposed || enabledRevision !== _mcpKeepaliveEnabledRevision) return;
275
+ _mcpKeepaliveEnabled = stored.mcpEnabled !== false;
276
+ if (_mcpKeepaliveEnabled) {
277
+ _considerMcpKeepaliveLeadership(stored[_MCP_KEEPALIVE_LEASE_KEY]);
278
+ }
279
+ }
80
280
 
81
- browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {
82
- if (!message) return false;
83
- if (message.type === "mcp-content-click") {
84
- sendResponse(handleClick(message.payload || {}));
85
- return true;
281
+ async function _onMcpKeepaliveStorageChanged(changes, areaName) {
282
+ if (_mcpKeepaliveDisposed || areaName !== "local") return;
283
+ if (changes.mcpEnabled) {
284
+ _mcpKeepaliveEnabledRevision += 1;
285
+ _mcpKeepaliveEnabled = changes.mcpEnabled.newValue !== false;
286
+ if (!_mcpKeepaliveEnabled) {
287
+ _stopMcpKeepaliveLeadership();
288
+ return;
289
+ }
86
290
  }
87
- if (message.type === "mcp-content-fill") {
88
- sendResponse(handleFill(message.payload || {}));
89
- return true;
291
+ if (changes[_MCP_KEEPALIVE_LEASE_KEY]) {
292
+ const next = _validMcpKeepaliveLease(changes[_MCP_KEEPALIVE_LEASE_KEY].newValue);
293
+ if (_mcpKeepaliveLeader && next?.id !== _mcpKeepaliveInstanceId) {
294
+ _stopMcpKeepaliveLeadership();
295
+ }
296
+ _considerMcpKeepaliveLeadership(next);
297
+ return;
90
298
  }
91
- // Last-resort path for evaluate. On hardened SPAs (business.facebook.com) BOTH
92
- // scripting.executeScript worlds hang forever instead of rejecting, while this
93
- // already-injected listener keeps answering — clicks kept working there the whole
94
- // time. Nothing is injected here, so there is no injection to stall on.
95
- if (message.type === "mcp-content-eval") {
96
- // The relayed function is often async (the evaluate handler awaits inside it), so
97
- // this must resolve before answering — returning the promise itself serialised to
98
- // "{}" and silently produced an empty result.
99
- handleEval(message.payload || {}).then(sendResponse);
100
- return true;
299
+ if (changes.mcpEnabled && _mcpKeepaliveEnabled) {
300
+ const stored = await browser.storage.local.get(_MCP_KEEPALIVE_LEASE_KEY).catch(() => ({}));
301
+ if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled) return;
302
+ _considerMcpKeepaliveLeadership(stored?.[_MCP_KEEPALIVE_LEASE_KEY]);
101
303
  }
102
- return false;
103
- });
304
+ }
305
+
306
+ function _cleanupMcpKeepalive() {
307
+ _mcpKeepaliveDisposed = true;
308
+ _mcpKeepaliveEnabled = false;
309
+ _stopMcpKeepaliveLeadership();
310
+ document.removeEventListener("visibilitychange", _onMcpKeepaliveVisibilityChange);
311
+ try { browser.storage.onChanged.removeListener(_onMcpKeepaliveStorageChanged); } catch (_) {}
312
+ }
313
+
314
+ globalThis.__mcpKeepaliveState = {
315
+ leaseId: _mcpKeepaliveInstanceId,
316
+ cleanup: _cleanupMcpKeepalive,
317
+ };
318
+ _initializeMcpKeepalive();
319
+ })();
320
+
321
+ (() => {
322
+ const previousCommandState = globalThis.__mcpContentCommandState;
323
+ const commandEvent = browser.runtime.onMessage;
324
+ const commandState = {
325
+ active: true,
326
+ event: commandEvent,
327
+ listener: null,
328
+ cleanup() {
329
+ this.active = false;
330
+ try { this.event.removeListener(this.listener); } catch (_) {}
331
+ },
332
+ };
333
+
334
+ const listener = (message, _sender, sendResponse) => {
335
+ if (!commandState.active || globalThis.__mcpContentCommandState !== commandState) return false;
336
+ if (!message) return false;
337
+ if (message.type === "mcp-content-click") {
338
+ sendResponse(handleClick(message.payload || {}));
339
+ return true;
340
+ }
341
+ if (message.type === "mcp-content-fill") {
342
+ sendResponse(handleFill(message.payload || {}));
343
+ return true;
344
+ }
345
+ // Last-resort path for evaluate. On hardened SPAs (business.facebook.com) BOTH
346
+ // scripting.executeScript worlds hang forever instead of rejecting, while this
347
+ // already-injected listener keeps answering — clicks kept working there the whole
348
+ // time. Nothing is injected here, so there is no injection to stall on.
349
+ if (message.type === "mcp-content-eval") {
350
+ // The relayed function is often async (the evaluate handler awaits inside it), so
351
+ // this must resolve before answering — returning the promise itself serialised to
352
+ // "{}" and silently produced an empty result.
353
+ handleEval(message.payload || {}).then(sendResponse);
354
+ return true;
355
+ }
356
+ return false;
357
+ };
358
+
359
+ commandState.listener = listener;
360
+ browser.runtime.onMessage.addListener(listener);
361
+ globalThis.__mcpContentCommandState = commandState;
362
+ if (previousCommandState && previousCommandState !== commandState) {
363
+ previousCommandState.cleanup?.();
364
+ }
365
+ })();
104
366
 
105
367
  // We are in the ISOLATED world: we can talk to the background, but the PAGE's CSP
106
368
  // governs eval here, and a hardened page (business.facebook.com) forbids it — an
@@ -217,31 +479,31 @@ function handleClick(payload) {
217
479
  let el = null;
218
480
 
219
481
  if (ref) {
220
- el = document.querySelector('[data-mcp-ref="' + cssString(ref) + '"]');
482
+ el = deepQuery('[data-mcp-ref="' + cssString(ref) + '"]');
221
483
  } else if (selector) {
222
- try { el = document.querySelector(selector); } catch (_) {}
484
+ el = deepQuery(selector);
223
485
  } else if (text) {
224
- const candidates = document.querySelectorAll(
225
- "button,a,[role='button'],[role='link'],[role='tab'],[role='combobox'],input[type='submit'],input[type='button']"
486
+ el = deepFind(
487
+ "button,a,[role='button'],[role='link'],[role='tab'],[role='combobox'],input[type='submit'],input[type='button']",
488
+ (candidate) => isVisible(candidate) && (candidate.innerText || candidate.textContent || "").trim() === text
226
489
  );
227
- for (const candidate of candidates) {
228
- if (isVisible(candidate) && (candidate.innerText || candidate.textContent || "").trim() === text) {
229
- el = candidate;
230
- break;
231
- }
232
- }
233
490
  if (!el) {
234
- const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
235
- while (walker.nextNode()) {
236
- const value = (walker.currentNode.textContent || "").trim();
237
- if (value === text && isVisible(walker.currentNode.parentElement)) {
238
- el = walker.currentNode.parentElement;
239
- break;
491
+ for (const root of rootsIncludingOpenShadow()) {
492
+ const treeRoot = root === document ? document.body : root;
493
+ if (!treeRoot) continue;
494
+ const walker = document.createTreeWalker(treeRoot, NodeFilter.SHOW_TEXT);
495
+ while (walker.nextNode()) {
496
+ const value = (walker.currentNode.textContent || "").trim();
497
+ if (value === text && isVisible(walker.currentNode.parentElement)) {
498
+ el = walker.currentNode.parentElement;
499
+ break;
500
+ }
240
501
  }
502
+ if (el) break;
241
503
  }
242
504
  }
243
505
  } else if (x !== undefined && y !== undefined) {
244
- el = document.elementFromPoint(Number(x), Number(y));
506
+ el = deepElementFromPoint(Number(x), Number(y));
245
507
  }
246
508
 
247
509
  if (!el) return { ok: false, result: "Element not found" };
@@ -254,7 +516,7 @@ function handleClick(payload) {
254
516
  const rect = el.getBoundingClientRect();
255
517
  const cx = rect.left + rect.width / 2;
256
518
  const cy = rect.top + rect.height / 2;
257
- let from = document.elementFromPoint(cx, cy);
519
+ let from = deepElementFromPoint(cx, cy);
258
520
  if (!from || !(from === el || el.contains(from))) from = el;
259
521
 
260
522
  const anchor = el.closest ? el.closest("a[href]") : null;
@@ -315,6 +577,57 @@ function cssString(value) {
315
577
  return String(value).replace(/["\\]/g, "\\$&");
316
578
  }
317
579
 
580
+ function rootsIncludingOpenShadow(root = document, roots = []) {
581
+ roots.push(root);
582
+ let descendants = [];
583
+ try { descendants = root.querySelectorAll("*"); } catch (_) {}
584
+ for (const descendant of descendants) {
585
+ let shadow = null;
586
+ try { shadow = descendant.shadowRoot; } catch (_) {}
587
+ if (shadow) rootsIncludingOpenShadow(shadow, roots);
588
+ }
589
+ return roots;
590
+ }
591
+
592
+ function deepQuery(selector) {
593
+ return deepFind(selector, () => true);
594
+ }
595
+
596
+ function deepFind(selector, predicate, root = document) {
597
+ let candidates;
598
+ try { candidates = root.querySelectorAll(selector); }
599
+ catch (_) { return null; }
600
+ for (const candidate of candidates) {
601
+ if (predicate(candidate)) return candidate;
602
+ }
603
+ let descendants = [];
604
+ try { descendants = root.querySelectorAll("*"); } catch (_) {}
605
+ for (const descendant of descendants) {
606
+ let shadow = null;
607
+ try { shadow = descendant.shadowRoot; } catch (_) {}
608
+ if (!shadow) continue;
609
+ const nested = deepFind(selector, predicate, shadow);
610
+ if (nested) return nested;
611
+ }
612
+ return null;
613
+ }
614
+
615
+ function deepElementFromPoint(x, y) {
616
+ let root = document;
617
+ let deepest = null;
618
+ for (let depth = 0; depth < 32; depth += 1) {
619
+ let hit = null;
620
+ try { hit = root.elementFromPoint(x, y); } catch (_) {}
621
+ if (!hit || hit === deepest) break;
622
+ deepest = hit;
623
+ let shadow = null;
624
+ try { shadow = hit.shadowRoot; } catch (_) {}
625
+ if (!shadow || typeof shadow.elementFromPoint !== "function") break;
626
+ root = shadow;
627
+ }
628
+ return deepest;
629
+ }
630
+
318
631
  function isVisible(el) {
319
632
  if (!el) return false;
320
633
  const style = getComputedStyle(el);
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "name": "Safari MCP Bridge",
4
4
  "description": "Connects AI agents to Safari — fast JS execution, screenshots, clicks with your real cookies/logins",
5
- "version": "2.10.3",
5
+ "version": "2.10.4",
6
6
  "icons": {
7
7
  "48": "images/icon-48.png",
8
8
  "96": "images/icon-96.png",
package/index.js CHANGED
@@ -2371,8 +2371,8 @@ server.tool(
2371
2371
  // tracked index is not proof either: `_openedTabs` is keyed by INDEX
2372
2372
  // and Safari renumbers every index whenever any tab closes, so a stale
2373
2373
  // key can point straight at a user's tab. Require BOTH — the index we
2374
- // opened AND the origin we opened it on. That is the same second factor
2375
- // the extension enforces in _canAdoptMarkedOwnedTab, so nothing that
2374
+ // opened AND the origin we opened it on. That mirrors the origin
2375
+ // boundary the extension keeps on its own receipts, so nothing that
2376
2376
  // would survive the extension's check is lost here, and the AppleScript
2377
2377
  // fallback (which has no ownership check of its own) stays guarded.
2378
2378
  const trackedOrigin = _originOf(_openedTabs.get(index)?.url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.17.0",
3
+ "version": "2.17.1",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 97 tools via AppleScript + JavaScript.",
6
6
  "type": "module",