safari-mcp 2.18.1 → 2.19.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 -0
- package/extension/background.js +7 -0
- package/extension/manifest.json +1 -1
- package/index.js +95 -6
- package/package.json +1 -1
- package/safari.js +29 -0
package/README.md
CHANGED
|
@@ -356,6 +356,9 @@ Prefer stdio (one process per agent) over a persistent daemon? That works too
|
|
|
356
356
|
| `SAFARI_MCP_HTTP_PORT` | `9225` | Port for that daemon. |
|
|
357
357
|
| `SAFARI_PROFILE` | unset | Bind sessions to a named Safari profile. Unset = your ordinary windows. |
|
|
358
358
|
| `SAFARI_MCP_RAISE_ON_NAVIGATE` | off | Let navigation bring Safari to the front, and stop the focus guard from putting your previous app back. |
|
|
359
|
+
| `SAFARI_MCP_SCREENSHOT_MAX_WIDTH` | unset | Downscale every `safari_screenshot` to this pixel width (Retina captures are 2× the viewport). Per-call `maxWidth` overrides it. |
|
|
360
|
+
| `SAFARI_MCP_KEEPALIVE_TAB` | off | Keep one daemon-served page open in the profile window so Safari never parks the extension worker between commands. |
|
|
361
|
+
| `SAFARI_MCP_OPEN_WINDOW_CMD` | unset | Command run with the profile name when the profile window is absent (e.g. after a reboot). Must open the window without focusing Safari. |
|
|
359
362
|
|
|
360
363
|
`SAFARI_MCP_RAISE_ON_NAVIGATE=1` is for agents whose whole point is *showing* you a page — a voice assistant answering "open YouTube", a demo driver. Everything else should leave it off: by default Safari MCP works in the background and hands focus back to whatever app you were using, so an agent can drive a page while you keep typing somewhere else.
|
|
361
364
|
|
package/extension/background.js
CHANGED
|
@@ -452,6 +452,13 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
|
452
452
|
if (Object.keys(msg).length !== 1) return false;
|
|
453
453
|
if (!Number.isInteger(sender?.tab?.id)) return false;
|
|
454
454
|
if (!_enabled || _bridgeWorkerSuperseded || _bridgeWorkerRetiring) return false;
|
|
455
|
+
// A page pinging while this worker sits in reconnect backoff is the earliest sign that
|
|
456
|
+
// the bridge is reachable again (the daemon releases wake polls while it has no verified
|
|
457
|
+
// worker). Retry now instead of waiting out a backoff of up to a minute.
|
|
458
|
+
if (!isConnected && !_connecting) {
|
|
459
|
+
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
|
|
460
|
+
connect();
|
|
461
|
+
}
|
|
455
462
|
sendResponse({ ok: true });
|
|
456
463
|
return false;
|
|
457
464
|
}
|
package/extension/manifest.json
CHANGED
|
@@ -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.
|
|
5
|
+
"version": "2.10.9",
|
|
6
6
|
"icons": {
|
|
7
7
|
"48": "images/icon-48.png",
|
|
8
8
|
"96": "images/icon-96.png",
|
package/index.js
CHANGED
|
@@ -417,6 +417,10 @@ const _HTTP_WORKER_TTL_MS = 60 * 1000;
|
|
|
417
417
|
const _HTTP_RELOAD_HANDOFF_TTL_MS = 30 * 1000;
|
|
418
418
|
const _HTTP_WORKER_SUCCESSOR_GRACE_MS = 20 * 1000;
|
|
419
419
|
const _HTTP_WORKER_DISCONNECT_MS = 30 * 1000;
|
|
420
|
+
// Safari parks an idle profile worker every ~30s and its 1-minute alarm wakes it. Declaring
|
|
421
|
+
// it gone in between bought nothing (a profile has no fallback) and cost a full
|
|
422
|
+
// re-verification per minute — 61 flaps in two hours. Wait one alarm cycle plus margin.
|
|
423
|
+
const _HTTP_WORKER_PARK_GRACE_MS = 75 * 1000;
|
|
420
424
|
let _extensionLastPollTime = 0;
|
|
421
425
|
let _extensionLastHeartbeat = 0;
|
|
422
426
|
let _reloadHttpWorkerHandoff = null;
|
|
@@ -742,6 +746,16 @@ try {
|
|
|
742
746
|
return;
|
|
743
747
|
}
|
|
744
748
|
|
|
749
|
+
// GET /keepalive — a plain page for the keepalive tab (SAFARI_MCP_KEEPALIVE_TAB=1).
|
|
750
|
+
// Any http(s) page of the profile hosts the content script, whose wake long-poll and
|
|
751
|
+
// runtime pings are what keep Safari's MV3 worker from parking; this page exists so
|
|
752
|
+
// there is always one, even after every owned tab was closed or Safari was relaunched.
|
|
753
|
+
if (req.method === "GET" && req.url.split("?")[0].split("#")[0] === "/keepalive") {
|
|
754
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
755
|
+
res.end(KEEPALIVE_HTML);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
745
759
|
// Every Safari-extension HTTP endpoint is authenticated in addition to its
|
|
746
760
|
// CORS check. Origin alone is not authority: another extension has its own
|
|
747
761
|
// extension-scheme origin, and a local process can forge or omit Origin.
|
|
@@ -880,6 +894,7 @@ try {
|
|
|
880
894
|
_extensionConnected = true;
|
|
881
895
|
console.error("[Safari MCP] Extension connected via HTTP polling");
|
|
882
896
|
}
|
|
897
|
+
_scheduleKeepaliveTab();
|
|
883
898
|
}
|
|
884
899
|
if (!process.env.SAFARI_PROFILE) _extensionLastPollTime = Date.now();
|
|
885
900
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -933,6 +948,7 @@ try {
|
|
|
933
948
|
console.error("[Safari MCP] Extension connected and profile-verified via HTTP polling");
|
|
934
949
|
}
|
|
935
950
|
_extensionLastPollTime = Date.now();
|
|
951
|
+
_scheduleKeepaliveTab();
|
|
936
952
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
937
953
|
res.end(JSON.stringify({ status: "verified" }));
|
|
938
954
|
return;
|
|
@@ -1164,8 +1180,16 @@ async function _proxyToExtension(type, payload, timeoutMs = 30000) {
|
|
|
1164
1180
|
// Detect stale HTTP connection (no poll in 30s = disconnected)
|
|
1165
1181
|
// Only applies to primary instance (extension host) — not proxy mode
|
|
1166
1182
|
const _staleHttpTimer = setInterval(() => {
|
|
1183
|
+
// After a daemon restart the profile worker can sit in its reconnect backoff for minutes
|
|
1184
|
+
// (measured 9 min on 4.9.26) while pages of the profile are already long-polling us for
|
|
1185
|
+
// wake-ups. Releasing those polls makes each page ping the worker, and a pinged worker
|
|
1186
|
+
// retries /connect at once (extension ≥2.10.9) instead of waiting out the backoff.
|
|
1187
|
+
if (_isExtensionHost && process.env.SAFARI_PROFILE && !_profileExtensionVerified && _contentWakePolls.size) {
|
|
1188
|
+
_releaseContentWakePolls();
|
|
1189
|
+
}
|
|
1167
1190
|
if (_isExtensionHost && _extensionConnected && !_extensionWs && _extensionLastPollTime > 0) {
|
|
1168
1191
|
if (Date.now() - _extensionLastPollTime > _HTTP_WORKER_DISCONNECT_MS) {
|
|
1192
|
+
if (process.env.SAFARI_PROFILE && Date.now() - _extensionLastPollTime <= _HTTP_WORKER_PARK_GRACE_MS) return;
|
|
1169
1193
|
// A command already delivered to this worker is an at-most-once fence.
|
|
1170
1194
|
// Its own bounded request timer releases the fence; disconnect cleanup must
|
|
1171
1195
|
// not make an ordinary successor eligible while the old worker may finish it.
|
|
@@ -1329,6 +1353,44 @@ const _commandTimeouts = {
|
|
|
1329
1353
|
// that typically arrive first after idle must outlive that cycle instead of failing at 30s.
|
|
1330
1354
|
if (process.env.SAFARI_PROFILE) { _commandTimeouts.new_tab = 75000; _commandTimeouts.list_tabs = 75000; }
|
|
1331
1355
|
|
|
1356
|
+
// ========== KEEPALIVE TAB (opt-in) ==========
|
|
1357
|
+
// Safari parks an idle extension worker; the first command afterwards then waits for the
|
|
1358
|
+
// 1-minute alarm (measured: new_tab 18s, or a hard failure before the queue fix). A page of
|
|
1359
|
+
// the profile that carries the content script relays wake-ups instantly and keeps the worker
|
|
1360
|
+
// alive — so keep exactly one such page open, served by this daemon itself.
|
|
1361
|
+
const KEEPALIVE_TAB_ENABLED = process.env.SAFARI_MCP_KEEPALIVE_TAB === "1";
|
|
1362
|
+
const KEEPALIVE_HTML = '<!doctype html><html lang="he" dir="rtl"><head><meta charset="utf-8"><title>Safari MCP · keepalive</title><meta name="robots" content="noindex"><style>body{font-family:-apple-system,system-ui,sans-serif;margin:3rem auto;max-width:36rem;padding:0 1rem;color:#1a2230;background:#f3f5f8;line-height:1.6}code{background:#ddeff2;padding:.1em .4em;border-radius:3px}</style></head><body><h1>Safari MCP</h1><p>הטאב הזה משאיר את ההרחבה ערה ברקע, כדי שהפקודה הראשונה אחרי הפסקה תרוץ מיד. אפשר להשאיר אותו פתוח; אם סוגרים אותו, הוא ייפתח שוב לבד.</p><p>לביטול: <code>SAFARI_MCP_KEEPALIVE_TAB=0</code> בהגדרות הדמון.</p></body></html>';
|
|
1363
|
+
const KEEPALIVE_URL = `http://127.0.0.1:${HTTP_PORT}/keepalive`;
|
|
1364
|
+
const KEEPALIVE_SESSION = `${SESSION_ID}:keepalive`;
|
|
1365
|
+
let _keepaliveBusy = false;
|
|
1366
|
+
let _keepaliveTimer = null;
|
|
1367
|
+
async function _ensureKeepaliveTab() {
|
|
1368
|
+
if (!KEEPALIVE_TAB_ENABLED || _keepaliveBusy || !_isExtensionHost || !_extensionConnected) return;
|
|
1369
|
+
if (_preferAppleScript && !_profileExtensionVerified) return;
|
|
1370
|
+
_keepaliveBusy = true;
|
|
1371
|
+
try {
|
|
1372
|
+
const tabs = await sendToExtension("list_tabs", { sessionId: KEEPALIVE_SESSION }, 30000);
|
|
1373
|
+
const list = Array.isArray(tabs) ? tabs : [];
|
|
1374
|
+
// No window at all → new_tab would have to create one, and a WebExtension-created
|
|
1375
|
+
// window is not guaranteed to land in this profile. Wait for a real window instead.
|
|
1376
|
+
if (!list.length) return;
|
|
1377
|
+
if (list.some((t) => String(t?.safeUrl || t?.url || "").startsWith(KEEPALIVE_URL))) return;
|
|
1378
|
+
await sendToExtension("new_tab", { url: KEEPALIVE_URL, sessionId: KEEPALIVE_SESSION }, 45000);
|
|
1379
|
+
console.error(`[Safari MCP] keepalive tab opened (${KEEPALIVE_URL})`);
|
|
1380
|
+
} catch (err) {
|
|
1381
|
+
console.error(`[Safari MCP] keepalive tab check skipped: ${err.message}`);
|
|
1382
|
+
} finally {
|
|
1383
|
+
_keepaliveBusy = false;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
function _scheduleKeepaliveTab(delayMs = 3000) {
|
|
1387
|
+
if (!KEEPALIVE_TAB_ENABLED) return;
|
|
1388
|
+
if (_keepaliveTimer) clearTimeout(_keepaliveTimer);
|
|
1389
|
+
_keepaliveTimer = setTimeout(() => { _keepaliveTimer = null; void _ensureKeepaliveTab(); }, delayMs);
|
|
1390
|
+
_keepaliveTimer.unref();
|
|
1391
|
+
}
|
|
1392
|
+
if (KEEPALIVE_TAB_ENABLED) setInterval(() => void _ensureKeepaliveTab(), 10 * 60 * 1000).unref();
|
|
1393
|
+
|
|
1332
1394
|
// Commands where null result means failure (should fall back to AppleScript)
|
|
1333
1395
|
const _nullMeansFailure = new Set([
|
|
1334
1396
|
"click", "double_click", "right_click", "fill",
|
|
@@ -1426,11 +1488,26 @@ function _safeUrlForOutput(rawUrl) {
|
|
|
1426
1488
|
}
|
|
1427
1489
|
}
|
|
1428
1490
|
|
|
1491
|
+
// Receipts this daemon rotated on the caller's behalf (a cross-origin safari_navigate) stay
|
|
1492
|
+
// usable under their old name: callers routinely keep the first receipt they saw and ignore
|
|
1493
|
+
// the fresh one in the navigate result (nine consecutive "not valid for this origin" failures
|
|
1494
|
+
// in one session on 4.9.26). Whoever held the old capability held the tab.
|
|
1495
|
+
const _receiptAliases = new Map(); // old token → newer token
|
|
1496
|
+
function _aliasReceipt(oldToken, newToken) {
|
|
1497
|
+
if (!oldToken || !newToken || oldToken === newToken) return;
|
|
1498
|
+
_receiptAliases.set(oldToken, newToken);
|
|
1499
|
+
if (_receiptAliases.size > 500) _receiptAliases.delete(_receiptAliases.keys().next().value);
|
|
1500
|
+
}
|
|
1429
1501
|
function _receiptToken(value) {
|
|
1430
1502
|
const raw = String(value || "");
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1503
|
+
let token = "";
|
|
1504
|
+
if (/^[A-Za-z0-9_-]{24,}$/.test(raw)) token = raw;
|
|
1505
|
+
else {
|
|
1506
|
+
const legacy = raw.match(/(?:[?#&])mcp-tab=([A-Za-z0-9_-]{24,})(?:[&#]|$)/);
|
|
1507
|
+
token = legacy ? legacy[1] : "";
|
|
1508
|
+
}
|
|
1509
|
+
for (let hops = 0; token && _receiptAliases.has(token) && hops < 8; hops++) token = _receiptAliases.get(token);
|
|
1510
|
+
return token;
|
|
1434
1511
|
}
|
|
1435
1512
|
|
|
1436
1513
|
// A caller-supplied receipt is the documented way for a stateless caller to name its
|
|
@@ -1936,12 +2013,17 @@ server.tool(
|
|
|
1936
2013
|
// origin", ~80×/week). The caller chose the destination, so rotating here is safe —
|
|
1937
2014
|
// hand back the new receipt instead of letting the caller discover the trap.
|
|
1938
2015
|
const landed = result && typeof result === "object" ? result.url : "";
|
|
1939
|
-
|
|
2016
|
+
const usedReceipt = _receiptToken(receipt || _getActiveReceipt());
|
|
2017
|
+
if (landed && _originOf(landed) !== _originOf(oldUrl) && usedReceipt) {
|
|
1940
2018
|
try {
|
|
1941
2019
|
const fresh = _sanitizeTabResult(await extensionOrFallback(
|
|
1942
2020
|
"get_tab_receipt", { ..._explicitReceipt({ receipt }) }, () => null
|
|
1943
2021
|
));
|
|
1944
|
-
if (fresh?.receipt) {
|
|
2022
|
+
if (fresh?.receipt) {
|
|
2023
|
+
_aliasReceipt(usedReceipt, fresh.receipt);
|
|
2024
|
+
_setActiveReceipt(fresh.receipt);
|
|
2025
|
+
result = { ...result, receipt: fresh.receipt };
|
|
2026
|
+
}
|
|
1945
2027
|
} catch { /* the navigation itself succeeded — a failed rotation must not fail the tool */ }
|
|
1946
2028
|
}
|
|
1947
2029
|
return textResult(result);
|
|
@@ -2568,6 +2650,7 @@ server.tool(
|
|
|
2568
2650
|
// session now gets its own MAX_TABS and can only evict its own.
|
|
2569
2651
|
const mySession = `${SESSION_ID}:${currentSessionId()}`;
|
|
2570
2652
|
const myTabs = [..._openedTabs].filter(([, info]) => (info.sessionId || "") === mySession);
|
|
2653
|
+
let evictedTab = null;
|
|
2571
2654
|
if (myTabs.length >= MAX_TABS) {
|
|
2572
2655
|
let oldestIdx = null, oldestTime = Infinity;
|
|
2573
2656
|
for (const [idx, info] of myTabs) {
|
|
@@ -2583,6 +2666,7 @@ server.tool(
|
|
|
2583
2666
|
);
|
|
2584
2667
|
} catch {}
|
|
2585
2668
|
_untrackTab(oldestIdx);
|
|
2669
|
+
evictedTab = oldestIdx;
|
|
2586
2670
|
}
|
|
2587
2671
|
}
|
|
2588
2672
|
|
|
@@ -2610,7 +2694,12 @@ server.tool(
|
|
|
2610
2694
|
}
|
|
2611
2695
|
if (!requestedUrl) _markBlankTabOpened();
|
|
2612
2696
|
if (safeResult?.receipt) _setActiveReceipt(safeResult.receipt);
|
|
2613
|
-
|
|
2697
|
+
// The eviction used to be stderr-only, so a session learned about its closed tab from
|
|
2698
|
+
// the next "receipt … stale" error. Say it in the response instead.
|
|
2699
|
+
const out = evictedTab !== null && safeResult && typeof safeResult === "object"
|
|
2700
|
+
? { ...safeResult, evictedTab, note: `Tab cap ${MAX_TABS}/session reached — your oldest tab #${evictedTab} was closed. Close tabs you are done with (safari_close_tab).` }
|
|
2701
|
+
: safeResult;
|
|
2702
|
+
return { content: [{ type: "text", text: typeof out === 'string' ? out : JSON.stringify(out) }] };
|
|
2614
2703
|
}
|
|
2615
2704
|
);
|
|
2616
2705
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
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",
|
package/safari.js
CHANGED
|
@@ -304,6 +304,7 @@ async function refreshTargetWindow(force = false) {
|
|
|
304
304
|
_targetWindowCacheTime = 0;
|
|
305
305
|
_profileWindowMissing = true;
|
|
306
306
|
_profileMisses++;
|
|
307
|
+
_maybeOpenProfileWindow();
|
|
307
308
|
// Log on transition into the missing state, then at most once per 5 minutes —
|
|
308
309
|
// this steady state used to append one line per poll, unbounded (#81).
|
|
309
310
|
if (_profileMisses === 1 || now - _lastMissingLogTime > 300000) {
|
|
@@ -313,6 +314,34 @@ async function refreshTargetWindow(force = false) {
|
|
|
313
314
|
}
|
|
314
315
|
}
|
|
315
316
|
|
|
317
|
+
// ponytail: opt-in self-heal for the "profile window is closed" steady state (198 refusals in
|
|
318
|
+
// one log, every morning after a reboot). SAFARI_MCP_OPEN_WINDOW_CMD is run with the profile
|
|
319
|
+
// name once absence is established (3 consecutive misses), at most every 2 minutes; the poll
|
|
320
|
+
// below then rediscovers the window. The command must open the window WITHOUT focusing
|
|
321
|
+
// Safari (e.g. ~/bin/safari-bg-window, which presses the File-menu item via Accessibility).
|
|
322
|
+
const OPEN_WINDOW_CMD = process.env.SAFARI_MCP_OPEN_WINDOW_CMD || "";
|
|
323
|
+
let _lastOpenWindowAttempt = 0;
|
|
324
|
+
function _maybeOpenProfileWindow() {
|
|
325
|
+
if (!OPEN_WINDOW_CMD || !SAFARI_PROFILE || _profileMisses < 3) return;
|
|
326
|
+
const now = Date.now();
|
|
327
|
+
if (now - _lastOpenWindowAttempt < 120000) return;
|
|
328
|
+
_lastOpenWindowAttempt = now;
|
|
329
|
+
_logProfile(`Profile "${SAFARI_PROFILE}" window absent — running ${OPEN_WINDOW_CMD}`);
|
|
330
|
+
// The opener presses a File-menu item, which needs Safari running and NOT hidden
|
|
331
|
+
// (`open -g -j` leaves the menu bar inaccessible). Launch it in the background first.
|
|
332
|
+
const ensureRunning = execFileAsync("/usr/bin/pgrep", ["-x", "Safari"]).then(() => null, () =>
|
|
333
|
+
execFileAsync("/usr/bin/open", ["-g", "-a", "Safari"])
|
|
334
|
+
.then(() => new Promise((r) => setTimeout(r, 5000)))
|
|
335
|
+
.catch(() => null));
|
|
336
|
+
ensureRunning
|
|
337
|
+
.then(() => execFileAsync(OPEN_WINDOW_CMD, [SAFARI_PROFILE], { timeout: 30000 }))
|
|
338
|
+
.then(() => {
|
|
339
|
+
_profileMisses = 0; // re-arm the subprocess retry for the fresh window
|
|
340
|
+
setTimeout(() => { refreshTargetWindow(true).catch(() => {}); }, 3000).unref();
|
|
341
|
+
})
|
|
342
|
+
.catch((err) => _logProfile(`Open-window command failed: ${err.message}`));
|
|
343
|
+
}
|
|
344
|
+
|
|
316
345
|
// Background verification: periodically check that cached window ID still belongs to profile
|
|
317
346
|
if (SAFARI_PROFILE) {
|
|
318
347
|
// Self-scheduling poll with exponential backoff (#81): 3s while the window is
|