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 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
 
@@ -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
  }
@@ -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.8",
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
- if (/^[A-Za-z0-9_-]{24,}$/.test(raw)) return raw;
1432
- const legacy = raw.match(/(?:[?#&])mcp-tab=([A-Za-z0-9_-]{24,})(?:[&#]|$)/);
1433
- return legacy ? legacy[1] : "";
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
- if (landed && _originOf(landed) !== _originOf(oldUrl) && _receiptToken(receipt || _getActiveReceipt())) {
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) { _setActiveReceipt(fresh.receipt); result = { ...result, receipt: 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
- return { content: [{ type: "text", text: typeof safeResult === 'string' ? safeResult : JSON.stringify(safeResult) }] };
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.18.1",
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