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 +3 -8
- package/extension/background.js +109 -9
- package/extension/command-content.js +521 -71
- package/extension/manifest.json +33 -9
- package/image-size.js +17 -0
- package/index.js +390 -72
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -15,14 +15,9 @@
|
|
|
15
15
|
[](https://opensource.org/licenses/MIT)
|
|
16
16
|
[](https://www.apple.com/macos/)
|
|
17
17
|
|
|
18
|
-
[
|
|
19
|
-
[
|
|
20
|
-
[
|
|
21
|
-
[](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
|
|
package/extension/background.js
CHANGED
|
@@ -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
|
-
},
|
|
594
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
1137
|
-
|
|
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());
|