safari-mcp 2.22.3 → 2.22.5

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.
@@ -1114,6 +1114,22 @@ async function handleCommand(type, payload) {
1114
1114
  };
1115
1115
  }
1116
1116
 
1117
+ // Puts the MCP session's identity marker on its tab. The server's AppleScript side knows tabs
1118
+ // only by their position in a window, so it acts only on the tab that carries the session's
1119
+ // marker, and a tab this extension opened or picked gets it here, where the tab is known by
1120
+ // its id. Like any write, it reaches only a tab this session owns or a receipt names (the
1121
+ // ownership guard above), never merely the tab in front. A fixed function, unlike evaluate,
1122
+ // so no page CSP can refuse it.
1123
+ case "mark_tab": {
1124
+ const marker = String(payload.marker || "");
1125
+ if (!tabId || !/^MCP_[A-Za-z0-9_]+$/.test(marker)) throw new Error("mark_tab requires a tab and an MCP_ marker");
1126
+ return await execInTab((m) => {
1127
+ window.name = m;
1128
+ window.__mcpTabMarker = m;
1129
+ return window.name === m;
1130
+ }, [marker], tabId);
1131
+ }
1132
+
1117
1133
  case "get_title": {
1118
1134
  return targetTab.title;
1119
1135
  }
@@ -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.25",
5
+ "version": "2.10.26",
6
6
  "icons": {
7
7
  "48": "images/icon-48.png",
8
8
  "96": "images/icon-96.png",
package/index.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  _openedTabs, _ownedTabURLs,
18
18
  _isURLOwned, _markBlankTabOpened, _addOwnedURL, _removeOwnedURL, _trackTab, _untrackTab,
19
19
  _sessionTabs, _trackedAtIndex,
20
- allowUserTabs, _adoptUserTab, _isAdoptedURL,
20
+ allowUserTabs,
21
21
  } from "./ownership-state.js";
22
22
  import { WebSocketServer } from "ws";
23
23
  import { createServer } from "node:http";
@@ -170,16 +170,6 @@ const WEBKIT_MEMORY_LIMIT_MB = parseInt(process.env.MCP_WEBKIT_LIMIT_MB || "3000
170
170
  // _removeOwnedURL, _trackTab, _untrackTab, BLANK_TAB_SENTINEL) are imported from
171
171
  // ownership-state.js at the top of this file.
172
172
 
173
- // Resolve a tab we recorded earlier to its CURRENT index, proving identity by the marker
174
- // stamped on it. Returns null when the tab carries no marker we can find — the caller then
175
- // leaves it alone. Never matches by URL: the tab navigates after we open it, and the user
176
- // can have a tab of their own on the URL we recorded, so a URL match closed THEIR tab
177
- // (#112). A stray MCP tab left open costs nothing; a closed user tab costs their work.
178
- async function _verifiedTabIndex(info) {
179
- if (!info?.marker) return null;
180
- try { return await safari.findTabByMarker(info.marker); } catch { return null; }
181
- }
182
-
183
173
  // Close one tab this process opened, found by the identity recorded when it was opened:
184
174
  // the receipt the extension minted for it, or the AppleScript marker. Never by position.
185
175
  // The tab cap used to close the index recorded at opening, and the close also carried the
@@ -213,14 +203,16 @@ async function _closeTrackedTab(info) {
213
203
  return msg.includes("Tab safety:") ? "lost" : "failed";
214
204
  }
215
205
  }
216
- // Opened through AppleScript: prove it by its marker. A named profile never closes a tab
217
- // through AppleScript.
206
+ // Opened through AppleScript: prove it by its marker, in the script that closes it. Never by
207
+ // URL: the tab navigates after we open it, and the user can have a tab of their own on the URL
208
+ // we recorded, so a URL match closed THEIR tab (#112). Nor by an index an earlier script found:
209
+ // a close in between renumbered the window, and a stray MCP tab left open costs nothing next
210
+ // to a closed user tab. A named profile never closes a tab through AppleScript.
211
+ // A tab blanked instead of closed (its window's last) is open still, but no marker names it any
212
+ // more: it counts as lost, not as closed.
218
213
  if (process.env.SAFARI_PROFILE) return "lost";
219
- const idx = await _verifiedTabIndex(info);
220
- if (!idx) return "lost";
221
214
  try {
222
- await safari.closeTab(idx);
223
- return "closed";
215
+ return (await safari.closeTabByMarker(info?.marker)) === "closed" ? "closed" : "lost";
224
216
  } catch {
225
217
  return "failed";
226
218
  }
@@ -237,8 +229,13 @@ async function _evictOldestTab(sessionId) {
237
229
  for (const [key, info] of mine) {
238
230
  if (open < MAX_TABS) break;
239
231
  const outcome = await _closeTrackedTab(info);
240
- // It may still be open: keep counting it, retry it at the next new tab, close the next.
241
- if (outcome === "failed") continue;
232
+ // It may still be open: keep counting it and retry it at the next new tab. Close the next
233
+ // only through the extension, which fails fast: an AppleScript close that failed, a page that
234
+ // never answers the marker check, would cost every tracked tab its own timeout.
235
+ if (outcome === "failed") {
236
+ if (!_receiptToken(info.receipt)) break;
237
+ continue;
238
+ }
242
239
  _untrackTab(key);
243
240
  open--;
244
241
  if (outcome === "closed") {
@@ -270,6 +267,18 @@ function _untrackClosedTab({ receipt = "", marker = "" }) {
270
267
  }
271
268
  }
272
269
 
270
+ // Only the extension can tell which tab a receipt names: AppleScript proves a tab by the
271
+ // marker stamped on it (#112), and a tab the extension opened carries none. A close or switch
272
+ // by receipt whose extension attempt failed fell back to the session's current tab instead,
273
+ // closing it, or claiming a tab with no index at all.
274
+ function _receiptNeedsExtension(tool) {
275
+ return new Error(
276
+ "Tab safety: this call names its tab by a receipt (the one passed, or the current tab's), and only " +
277
+ "the Safari extension can resolve a receipt. It could not take the call, so AppleScript touched no tab. " +
278
+ `Retry safari_${tool} once safari_doctor shows the extension connected.`
279
+ );
280
+ }
281
+
273
282
  // Close all MCP-opened tabs on process exit
274
283
  async function _cleanupTabs() {
275
284
  if (_openedTabs.size === 0) return;
@@ -282,14 +291,12 @@ async function _cleanupTabs() {
282
291
  return;
283
292
  }
284
293
  console.error(`[Safari MCP] Cleanup: closing ${_openedTabs.size} MCP-opened tabs`);
285
- // Resolve each tab by its identity marker right before closing it — indices shift after
286
- // every closure, and the marker is the only coordinate that survives navigation. Name the
287
- // index explicitly: it was just proven ours, and closeTab's own ownership check covers
288
- // only this session's active tab (#68).
294
+ // Find each tab by its identity marker in the script that closes it — indices shift after
295
+ // every closure, and the marker is the only coordinate that survives navigation. closeTab()
296
+ // with no index would close only this session's active tab (#68).
289
297
  for (const info of [..._openedTabs.values()]) {
290
298
  try {
291
- const idx = await _verifiedTabIndex(info);
292
- if (idx) await safari.closeTab(idx);
299
+ await safari.closeTabByMarker(info.marker);
293
300
  } catch {}
294
301
  }
295
302
  _openedTabs.clear();
@@ -1544,7 +1551,7 @@ function _handleExtensionResponse(msg, respondingWorkerId = "") {
1544
1551
  const _THERMAL_FILE = process.env.SAFARI_MCP_THERMAL_FILE || "";
1545
1552
  const _THERMAL_MAX = parseFloat(process.env.SAFARI_MCP_THERMAL_MAX || "80");
1546
1553
  const _THERMAL_WAIT_MS = parseInt(process.env.SAFARI_MCP_THERMAL_WAIT_MS || "20000", 10);
1547
- const _THERMAL_FREE = new Set(["close_tab", "list_tabs", "get_tab_receipt", "get_tab_locus", "switch_tab", "reload_extension"]);
1554
+ const _THERMAL_FREE = new Set(["close_tab", "list_tabs", "get_tab_receipt", "get_tab_locus", "mark_tab", "switch_tab", "reload_extension"]);
1548
1555
 
1549
1556
  function _chipTemp() {
1550
1557
  try {
@@ -1735,7 +1742,9 @@ const _noOwnershipCheck = new Set([
1735
1742
  ]);
1736
1743
 
1737
1744
  // run_script action names (camelCase) that don't require an owned tab — strictly
1738
- // read-only steps, plus newTab/switchTab/listTabs which mirror _noOwnershipCheck.
1745
+ // read-only steps, plus newTab/switchTab/listTabs which mirror _noOwnershipCheck
1746
+ // (switchTab still has to prove its tab: by the session's marker in safari.js switchTab(),
1747
+ // or through the extension, which knows the tabs it opened by their id).
1739
1748
  // Everything that can change page or tab state (navigate, evaluate, reload, goBack,
1740
1749
  // closeTab, ...) is asserted PER STEP while the batch runs (see the run_script
1741
1750
  // handler): a batch can change the active tab mid-run, so a single pre-flight
@@ -1827,6 +1836,11 @@ function _receiptToken(value) {
1827
1836
  function _explicitReceipt(args) {
1828
1837
  const token = _receiptToken(args?.receipt || "");
1829
1838
  if (!token) return {};
1839
+ // A receipt that names another tab makes that tab the current one, so safari.js has to follow.
1840
+ // Otherwise its AppleScript fallback ran in the tab the session had before, or, for a session
1841
+ // re-initialised after a reconnect (no tab of its own yet), in the front document: the tab the
1842
+ // user is looking at. The extension marks the receipt's tab when AppleScript first needs it.
1843
+ if (token !== _getActiveReceipt()) safari.setActiveTabFromExtension(null, null);
1830
1844
  _setActiveReceipt(token);
1831
1845
  return { receipt: token };
1832
1846
  }
@@ -1938,6 +1952,9 @@ async function _runExtensionBatchAction(action, args = {}) {
1938
1952
  } else {
1939
1953
  _markBlankTabOpened();
1940
1954
  }
1955
+ // safari.js still holds the marker of the last tab AppleScript opened; its fallback
1956
+ // would go back there.
1957
+ if (!viaAppleScript) safari.setActiveTabFromExtension(value?.tabIndex, trackUrl);
1941
1958
  return value;
1942
1959
  }
1943
1960
 
@@ -1953,14 +1970,23 @@ async function _runExtensionBatchAction(action, args = {}) {
1953
1970
  if (index !== undefined && (!Number.isInteger(index) || index < 1)) {
1954
1971
  throw new Error("switchTab index must be a positive integer when provided");
1955
1972
  }
1973
+ let viaAppleScript = false;
1956
1974
  const raw = await extensionOrFallback(
1957
1975
  "switch_tab",
1958
1976
  receipt ? { ...(index ? { index } : {}), receipt } : { index },
1959
- () => safari.switchTab(index)
1977
+ () => {
1978
+ if (receipt) throw _receiptNeedsExtension("switch_tab");
1979
+ viaAppleScript = true;
1980
+ return safari.switchTab(index);
1981
+ }
1960
1982
  );
1961
1983
  const value = _sanitizeTabResult(raw);
1962
1984
  const resolvedIndex = value?.tabIndex || index;
1963
1985
  if (resolvedIndex) safari.setActiveTabIndex(resolvedIndex);
1986
+ // Point the AppleScript fallback at this tab, not the last one AppleScript claimed. Its
1987
+ // URL is left unknown rather than set to the reported one: this path never registered
1988
+ // that URL, so the ownership guard would start refusing the steps after it.
1989
+ if (!viaAppleScript && value && typeof value === "object") safari.setActiveTabFromExtension(resolvedIndex, null);
1964
1990
  // A switch by index that returns no receipt still makes that tab the current one.
1965
1991
  // Keeping the previous tab's receipt sent every later step back to the previous tab.
1966
1992
  if (value && typeof value === "object") _setActiveReceipt(value.receipt || receipt);
@@ -1979,7 +2005,9 @@ async function _runExtensionBatchAction(action, args = {}) {
1979
2005
  () => { throw new Error("getReceipt requires the verified Safari extension"); }
1980
2006
  ));
1981
2007
  const safeValue = _sanitizeTabResult(value);
1982
- if (safeValue?.index) safari.setActiveTabIndex(safeValue.index);
2008
+ // The rotated tab is now the current one. Its URL stays unknown in safari.js: a receipt
2009
+ // is rotated because the tab left the origin this session registered.
2010
+ if (safeValue?.index) safari.setActiveTabFromExtension(safeValue.index, null);
1983
2011
  if (safeValue?.receipt) {
1984
2012
  // Rotation retires the old token. Keep it resolving to the new one, as navigate does,
1985
2013
  // so the tab stays reachable — and closable by the tab cap — by the name it was opened with.
@@ -1990,12 +2018,24 @@ async function _runExtensionBatchAction(action, args = {}) {
1990
2018
  }
1991
2019
 
1992
2020
  case "closeTab": {
1993
- const index = args.index === undefined ? undefined : Number(args.index);
1994
- const receipt = _receiptToken(args.receipt || args.receiptUrl || args.url || "") || _getActiveReceipt();
2021
+ const index = args.index == null ? undefined : Number(args.index);
2022
+ // An index that is not a tab number (0, -1, 1.5, "abc") was dropped here or ignored by the
2023
+ // extension, and the close took the current tab. switchTab refuses one, as closeOwnTab does
2024
+ // without a profile.
2025
+ if (index !== undefined && (!Number.isInteger(index) || index < 1)) {
2026
+ throw new Error("Tab safety: closeTab index must be a positive integer when provided");
2027
+ }
2028
+ // As in safari_close_tab: a receipt that is not one is refused, not read as the current tab's.
2029
+ const supplied = args.receipt || args.receiptUrl || args.url || "";
2030
+ const receipt = supplied ? _receiptToken(supplied) : _getActiveReceipt();
2031
+ if (supplied && !receipt) throw new Error("Tab safety: closeTab requires an extension-issued receipt");
1995
2032
  const closesCurrent = !receipt || receipt === _getActiveReceipt();
1996
2033
  const raw = await extensionOrFallback(
1997
2034
  "close_tab", { ...(index ? { index } : {}), ...(receipt ? { receipt } : {}) },
1998
- () => safari.closeTab(index)
2035
+ () => {
2036
+ if (receipt) throw _receiptNeedsExtension("close_tab");
2037
+ return safari.closeOwnTab(index);
2038
+ }
1999
2039
  );
2000
2040
  if (/^Tab not found/.test(String(raw))) return normalize(raw);
2001
2041
  // A named profile closes through the extension only: the tab that closed is the receipt's.
@@ -2181,7 +2221,9 @@ function _assertTabOwnership(opType, extensionPayload = {}) {
2181
2221
  // Closing is the one op the opt-in never unlocks (#92, condition 2). Adoption makes a
2182
2222
  // user's tab writable, not disposable — a wrong close costs work that cannot be undone
2183
2223
  // (#68). Checked before every early return so the batch action and the tool share it.
2184
- if (opType === "close_tab" && _isAdoptedURL(safari.getActiveTabURL())) {
2224
+ // The session's marker on the tab says it was adopted, not its URL: the tab keeps that
2225
+ // marker wherever it navigates, and another tab on the same URL does not carry it.
2226
+ if (opType === "close_tab" && safari.isActiveTabAdopted()) {
2185
2227
  const msg = `⚠️ Tab safety: refusing "close_tab" — this tab was adopted from you via SAFARI_MCP_ALLOW_USER_TABS, not opened by this MCP session. Close it yourself, or open your own tab with safari_new_tab.`;
2186
2228
  console.error(`[Safari MCP] ${msg}`);
2187
2229
  throw new Error(msg);
@@ -2192,7 +2234,7 @@ function _assertTabOwnership(opType, extensionPayload = {}) {
2192
2234
  if (_preferAppleScript && _receiptToken(extensionPayload.receipt || _getActiveReceipt())) return;
2193
2235
  const currentUrl = safari.getActiveTabURL();
2194
2236
  // An adopted tab (#92) is the session's target even though the session opened nothing.
2195
- if (_isAdoptedURL(currentUrl)) {
2237
+ if (safari.isActiveTabAdopted()) {
2196
2238
  console.error(`[Safari MCP] "${opType}" on ${_safeUrlForOutput(currentUrl)} (user tab, opted-in)`);
2197
2239
  return;
2198
2240
  }
@@ -2363,6 +2405,32 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
2363
2405
  // working (geo-audit, 2026-09-20). The fallback safari.js hands us is its original runJS.
2364
2406
  safari.setPageJSRunner((script, fallback) => extensionOrFallback("evaluate", { script }, fallback));
2365
2407
 
2408
+ // safari.js lets AppleScript act only on a tab that carries this session's identity marker. A tab
2409
+ // the extension opened or picked has none, and its index or URL does not say which tab it is now
2410
+ // (a tab the user closed or dragged, another front window, their own tab on the same site), so
2411
+ // the extension, which knows the tab by its id, writes a fresh marker there: safari.js asks when
2412
+ // AppleScript first needs the tab, and again once a cross-site load has cleared window.name.
2413
+ // mark_tab writes it from a fixed function, which no page CSP can refuse; an extension built
2414
+ // before mark_tab can only use evaluate, which a strict CSP (github.com) does refuse.
2415
+ safari.setExtensionTabMarker(async (marker) => {
2416
+ if ((!_extensionConnected && !_primaryHasExtension) || (_preferAppleScript && !_profileExtensionVerified)) return false;
2417
+ // No current tab — it was closed — so nothing to mark.
2418
+ if (!_activeReceipts.has(_receiptSessionKey())) return false;
2419
+ const receipt = _getActiveReceipt();
2420
+ const target = { ...(receipt ? { receipt } : {}), sessionId: `${SESSION_ID}:${currentSessionId()}` };
2421
+ try {
2422
+ return (await sendToExtension("mark_tab", { ...target, marker }, _commandTimeouts.evaluate)) === true;
2423
+ } catch (err) {
2424
+ if (!/Unknown command/.test(String(err?.message || err))) return false;
2425
+ }
2426
+ try {
2427
+ const script = `(window.name=window.__mcpTabMarker=${JSON.stringify(marker)},'1')`;
2428
+ return (await sendToExtension("evaluate", { ...target, script }, _commandTimeouts.evaluate)) === "1";
2429
+ } catch {
2430
+ return false;
2431
+ }
2432
+ });
2433
+
2366
2434
  // Read version from package.json to avoid hardcoded mismatch
2367
2435
  const _pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')).version;
2368
2436
  // Factory so each MCP session (in HTTP mode) gets its own McpServer — McpServer is single-connection.
@@ -3068,6 +3136,9 @@ server.tool(
3068
3136
  _addOwnedURL(trackUrl);
3069
3137
  }
3070
3138
  if (!requestedUrl) _markBlankTabOpened();
3139
+ // safari.js still holds the marker (and, for a blank tab, the URL) of the previous tab;
3140
+ // its AppleScript fallback would go back there.
3141
+ if (!viaAppleScript) safari.setActiveTabFromExtension(safeResult?.tabIndex, requestedUrl);
3071
3142
  // The eviction used to be stderr-only, so a session learned about its closed tab from
3072
3143
  // the next "receipt … stale" error. Say it in the response instead.
3073
3144
  const out = evicted && safeResult && typeof safeResult === "object"
@@ -3086,7 +3157,10 @@ server.tool(
3086
3157
  },
3087
3158
  async ({ receipt, url }) => {
3088
3159
  const supplied = receipt || url || "";
3089
- const token = _receiptToken(supplied) || _getActiveReceipt();
3160
+ // Only a close that names no receipt closes the current tab. One the server cannot parse
3161
+ // ("<old receipt>", a token cut below 24 characters, a page URL) fell through to the current
3162
+ // tab's receipt, and the tab the caller was still working in closed instead of the one it named.
3163
+ const token = supplied ? _receiptToken(supplied) : _getActiveReceipt();
3090
3164
  if (supplied && !token) return errorResult("Tab safety: invalid tab receipt");
3091
3165
  const closesCurrent = !token || token === _getActiveReceipt();
3092
3166
  const marker = safari.getActiveTabMarker();
@@ -3094,12 +3168,23 @@ server.tool(
3094
3168
  const result = await extensionOrFallback(
3095
3169
  "close_tab",
3096
3170
  token ? { receipt: token } : {},
3097
- () => { viaAppleScript = true; return safari.closeTab(); }
3171
+ () => {
3172
+ if (token) throw _receiptNeedsExtension("close_tab");
3173
+ viaAppleScript = true;
3174
+ return safari.closeTab();
3175
+ }
3098
3176
  );
3099
- // AppleScript closed the tab carrying the session's marker; the extension, the receipt's.
3177
+ // The extension closed the receipt's tab; AppleScript, the tab carrying the session's marker.
3100
3178
  _untrackClosedTab(viaAppleScript ? { marker } : { receipt: token });
3101
3179
  // Closing another tab by its receipt leaves the current one current.
3102
3180
  if (viaAppleScript || closesCurrent) _clearActiveReceipt();
3181
+ // AppleScript's closeTab() forgets the tab it closed. After the extension's close, safari.js
3182
+ // still tracked the closed tab by index and URL, and that index now names whichever tab
3183
+ // shifted into it.
3184
+ if (!viaAppleScript && closesCurrent) {
3185
+ safari.setActiveTabIndex(null);
3186
+ safari.setActiveTabURL(null);
3187
+ }
3103
3188
  return textResult(result);
3104
3189
  }
3105
3190
  );
@@ -3128,7 +3213,6 @@ server.tool(
3128
3213
  }
3129
3214
 
3130
3215
  // Tab ownership check: verify target tab is one we opened
3131
- let adopted = false;
3132
3216
  // `_ownedTabURLs.size > 0` alone skipped this whole lookup for a session that had opened
3133
3217
  // nothing — fine while switch_tab could only reach owned tabs, but adoption (#92) has to
3134
3218
  // work from a cold session, which is precisely the "read the article in my current tab"
@@ -3153,35 +3237,51 @@ server.tool(
3153
3237
  // user's tab. Require BOTH — the index we opened AND the origin we
3154
3238
  // opened it on. That mirrors the origin
3155
3239
  // boundary the extension keeps on its own receipts, so nothing that
3156
- // would survive the extension's check is lost here, and the AppleScript
3157
- // fallback (which has no ownership check of its own) stays guarded.
3240
+ // would survive the extension's check is lost here. A URL still proves
3241
+ // nothing about WHICH tab this is, so the AppleScript fallback also needs
3242
+ // the session's marker on the tab (safari.js switchTab()).
3158
3243
  const trackedOrigin = _originOf(_trackedAtIndex(index)?.url);
3159
3244
  const isTrackedRedirect = !!trackedOrigin && trackedOrigin === _originOf(target.url);
3160
- if (!isBlankOwned && !isTrackedRedirect && allowUserTabs()) {
3161
- // The opt-in turns this refusal into a deliberate, named adoption (#92). Both
3162
- // spellings of the URL go in: the tool layer hands callers the query-stripped
3163
- // form and later compares against it, while list_tabs reported the raw one.
3164
- _adoptUserTab(target.url);
3165
- _adoptUserTab(_safeUrlForOutput(target.url));
3166
- adopted = true;
3167
- console.error(`[Safari MCP] switch_tab adopted ${_safeUrlForOutput(target.url)} (user tab, opted-in via SAFARI_MCP_ALLOW_USER_TABS)`);
3168
- } else if (!isBlankOwned && !isTrackedRedirect) {
3245
+ if (!isBlankOwned && !isTrackedRedirect && !allowUserTabs()) {
3169
3246
  const msg = `⚠️ Tab safety: refusing switch_tab to index ${index} (${_safeUrlForOutput(target.url)}) — not opened by this MCP session. Use safari_new_tab to open your own tab, or set SAFARI_MCP_ALLOW_USER_TABS=1 to let switch_tab adopt a tab you already had open.`;
3170
3247
  console.error(`[Safari MCP] ${msg}`);
3171
3248
  return errorResult(msg);
3172
3249
  }
3250
+ // With the opt-in the switch goes ahead, and the AppleScript fallback below adopts the tab (#92).
3173
3251
  }
3174
3252
  } catch {}
3175
3253
  }
3254
+ let viaAppleScript = false;
3176
3255
  const result = await extensionOrFallback(
3177
3256
  "switch_tab", token ? { ...(index ? { index } : {}), receipt: token } : { index },
3178
- () => safari.switchTab(index)
3257
+ async () => {
3258
+ if (token) throw _receiptNeedsExtension("switch_tab");
3259
+ viaAppleScript = true;
3260
+ try {
3261
+ return await safari.switchTab(index);
3262
+ } catch (err) {
3263
+ // switchTab() claims only a tab carrying a marker of this session. With the opt-in, any
3264
+ // other tab is adopted instead (#92): it gets the session's adoption marker, which it
3265
+ // keeps wherever it navigates, and which no close accepts. The URL check above passes
3266
+ // the user's tab on a URL the session owns, so only this marker tells it apart.
3267
+ if (!err?.unproven || !allowUserTabs()) throw err;
3268
+ }
3269
+ const claimed = await safari.switchTab(index, { adopt: true });
3270
+ console.error(`[Safari MCP] switch_tab adopted ${_safeUrlForOutput(safari.getActiveTabURL())} (user tab, opted-in via SAFARI_MCP_ALLOW_USER_TABS)`);
3271
+ return claimed;
3272
+ }
3179
3273
  );
3274
+ // Adopted just now, or earlier: switchTab() keeps an adopted tab's marker.
3275
+ const adopted = viaAppleScript && safari.isActiveTabAdopted();
3180
3276
  const safeResult = _sanitizeTabResult(result);
3181
3277
  // Sync safari.js state so AppleScript fallback targets the correct tab
3182
3278
  const resolvedIndex = safeResult?.tabIndex || index;
3183
3279
  if (resolvedIndex) safari.setActiveTabIndex(resolvedIndex);
3184
3280
  if (safeResult?.safeUrl) safari.setActiveTabURL(safeResult.safeUrl);
3281
+ // …including the marker, which still names the last tab AppleScript claimed.
3282
+ if (!viaAppleScript && safeResult && typeof safeResult === "object") {
3283
+ safari.setActiveTabFromExtension(resolvedIndex, safeResult.safeUrl);
3284
+ }
3185
3285
  if (safeResult?.receipt || token) _setActiveReceipt(safeResult?.receipt || token);
3186
3286
  // A switch by INDEX gets no receipt back, and the session was still holding the one it
3187
3287
  // minted for the PREVIOUS tab. Every later command auto-attaches that receipt, so the
@@ -3226,56 +3326,87 @@ server.tool(
3226
3326
  },
3227
3327
  async ({ timeout, urlContains }) => {
3228
3328
  const timeoutMs = timeout || 10000;
3329
+ // Every listing says where it came from. AppleScript lists one window, the one its first
3330
+ // listing read (`win`), and a claim through AppleScript goes to that window: listings of
3331
+ // whichever window was in front made a tab of the user's other window look new, and the
3332
+ // claim stamped the session's own marker on it. A listing from the other source starts the
3333
+ // comparison over instead of comparing tabs across them.
3334
+ let win = null;
3335
+ const list = async () => {
3336
+ let via = "extension";
3337
+ const raw = await extensionOrFallback("list_tabs", {}, async () => {
3338
+ via = "applescript";
3339
+ const listed = await safari.listWindowTabs(win);
3340
+ win = listed.win;
3341
+ return listed.tabs;
3342
+ });
3343
+ return { via, tabs: typeof raw === "string" ? JSON.parse(raw) : raw };
3344
+ };
3229
3345
  // Switch to the new tab and own it for THIS session, else the next interaction trips the
3230
3346
  // tab-safety guard. Its marker names it only if AppleScript made the switch (and stamped
3231
- // it); the extension answers with the tab's receipt instead.
3232
- const adopt = async (t) => {
3347
+ // it); the extension answers with the tab's receipt instead. AppleScript claims only a tab
3348
+ // its own listing of the pinned window saw: an index the extension listed is a position in
3349
+ // the extension's window, not in the one AppleScript would stamp.
3350
+ const adopt = async (t, via) => {
3233
3351
  let viaAppleScript = false;
3234
3352
  const switched = _sanitizeTabResult(await extensionOrFallback(
3235
3353
  "switch_tab", { index: t.index },
3236
- () => { viaAppleScript = true; return safari.switchTab(t.index); }
3354
+ () => {
3355
+ viaAppleScript = true;
3356
+ if (via !== "applescript" || !win) {
3357
+ throw new Error("Tab safety: the new tab was seen by the Safari extension, which could not switch to it, and AppleScript cannot tell which tab of its own window that is. Retry safari_wait_for_new_tab.");
3358
+ }
3359
+ return safari.switchTab(t.index, { claim: true, win });
3360
+ }
3237
3361
  ));
3238
3362
  safari.setActiveTabIndex(t.index);
3239
3363
  safari.setActiveTabURL(t.url);
3240
- _trackTab(t.index, t.url, `${SESSION_ID}:${currentSessionId()}`, viaAppleScript ? safari.getActiveTabMarker() : "", switched?.receipt);
3364
+ if (!viaAppleScript) safari.setActiveTabFromExtension(t.index, t.url);
3365
+ // A "new" tab can be one adopted from the user that moved or navigated while this waited.
3366
+ // It stays adopted, and recording it among the tabs this session opened would hand it to
3367
+ // the tab cap and the shutdown cleanup, which close those.
3368
+ if (!safari.isActiveTabAdopted()) {
3369
+ _trackTab(t.index, t.url, `${SESSION_ID}:${currentSessionId()}`, viaAppleScript ? safari.getActiveTabMarker() : "", switched?.receipt);
3370
+ }
3241
3371
  _setActiveReceipt(switched?.receipt);
3242
3372
  return { content: [{ type: "text", text: `Found new tab: ${t.title} (${t.url})` }] };
3243
3373
  };
3244
3374
  // Get current tab list
3245
- const beforeRaw = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
3246
- const beforeTabs = typeof beforeRaw === 'string' ? JSON.parse(beforeRaw) : beforeRaw;
3247
- const beforeIds = new Set(beforeTabs.map(t => `${t.index}:${t.url}`));
3248
- const beforeCount = beforeTabs.length;
3375
+ let before = await list();
3376
+ let beforeIds = new Set(before.tabs.map(t => `${t.index}:${t.url}`));
3249
3377
 
3250
3378
  // Poll for new tab — detect by count increase + new entries (handles about:blank tabs)
3251
3379
  const deadline = Date.now() + timeoutMs;
3252
3380
  while (Date.now() < deadline) {
3253
3381
  await new Promise(r => setTimeout(r, 500));
3254
- const nowRaw = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
3255
- const nowTabs = typeof nowRaw === 'string' ? JSON.parse(nowRaw) : nowRaw;
3256
- if (nowTabs.length > beforeCount) {
3382
+ const now = await list();
3383
+ if (now.via !== before.via) {
3384
+ before = now;
3385
+ beforeIds = new Set(now.tabs.map(t => `${t.index}:${t.url}`));
3386
+ continue;
3387
+ }
3388
+ if (now.tabs.length > before.tabs.length) {
3257
3389
  // Find the new tab(s) — could be about:blank initially (OAuth popups)
3258
- for (const tab of nowTabs) {
3390
+ for (const tab of now.tabs) {
3259
3391
  if (!beforeIds.has(`${tab.index}:${tab.url}`)) {
3260
3392
  // Wait for about:blank to resolve to actual URL — dynamic polling instead of fixed delay
3261
3393
  if (tab.url === 'about:blank') {
3262
3394
  let resolved = null;
3263
3395
  for (let attempt = 0; attempt < 10; attempt++) {
3264
3396
  await new Promise(r => setTimeout(r, 300)); // 300ms intervals, max 3s total
3265
- const refreshed = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
3266
- const refreshedTabs = typeof refreshed === 'string' ? JSON.parse(refreshed) : refreshed;
3267
- resolved = refreshedTabs.find(t => t.index === tab.index);
3397
+ const refreshed = await list();
3398
+ resolved = refreshed.via === now.via ? refreshed.tabs.find(t => t.index === tab.index) : null;
3268
3399
  if (resolved && resolved.url !== 'about:blank') break;
3269
3400
  resolved = null;
3270
3401
  }
3271
3402
  if (resolved && resolved.url !== 'about:blank') {
3272
3403
  if (urlContains && !resolved.url.includes(urlContains)) continue;
3273
- return await adopt(resolved);
3404
+ return await adopt(resolved, now.via);
3274
3405
  }
3275
3406
  continue;
3276
3407
  }
3277
3408
  if (urlContains && !tab.url.includes(urlContains)) continue;
3278
- return await adopt(tab);
3409
+ return await adopt(tab, now.via);
3279
3410
  }
3280
3411
  }
3281
3412
  }
@@ -3558,7 +3689,7 @@ server.tool(
3558
3689
 
3559
3690
  server.tool(
3560
3691
  "safari_run_script",
3561
- "Batch Safari actions in one MCP session. Named profiles use the verified extension and support: newTab, switchTab, getReceipt, listTabs, closeTab, navigate, navigateAndRead, readPage, snapshot, getElementInfo, querySelectorAll, waitFor, waitForTime, click, clickAndOpenPopup, fill, fillForm, clearField, typeText, selectOption, pressKey, scroll, scrollTo, scrollToElement, hover, evaluate, reload, goBack, goForward. switchTab accepts an index or an opaque receipt; a valid receipt recovers the exact owned tab across Safari windows without focusing one. clickAndOpenPopup takes exactly one selector or snapshot ref, targets one exact frame, performs one click, captures a blocked HTTP(S) window.open, and opens it as a background tab without focusing Safari; it refuses CAPTCHA/challenge targets and never returns URL query/hash data. Non-profile mode retains the legacy action set. Use getReceipt after a cross-origin redirect.",
3692
+ "Batch Safari actions in one MCP session. Named profiles use the verified extension and support: newTab, switchTab, getReceipt, listTabs, closeTab, navigate, navigateAndRead, readPage, snapshot, getElementInfo, querySelectorAll, waitFor, waitForTime, click, clickAndOpenPopup, fill, fillForm, clearField, typeText, selectOption, pressKey, scroll, scrollTo, scrollToElement, hover, evaluate, reload, goBack, goForward. switchTab accepts an index or an opaque receipt; a valid receipt recovers the exact owned tab across Safari windows without focusing one. clickAndOpenPopup takes exactly one selector or snapshot ref, targets one exact frame, performs one click, captures a blocked HTTP(S) window.open, and opens it as a background tab without focusing Safari; it refuses CAPTCHA/challenge targets and never returns URL query/hash data. Non-profile mode retains the legacy action set; there, closeTab and switchTab take a tab index, not a receipt. Use getReceipt after a cross-origin redirect.",
3562
3693
  {
3563
3694
  steps: z.array(z.object({
3564
3695
  action: z.string().describe("Action name (e.g. 'navigate', 'click', 'fill')"),
@@ -3582,9 +3713,41 @@ server.tool(
3582
3713
  }
3583
3714
  return;
3584
3715
  }
3585
- if (!_RUNSCRIPT_OWNERSHIP_EXEMPT.has(action)) _assertTabOwnership(`run_script:${action}`);
3716
+ // These steps run through AppleScript here, which cannot tell which tab a receipt names:
3717
+ // closeTab closed the session's current tab in its place, and switchTab claimed a tab
3718
+ // with no index at all.
3719
+ if ((action === "closeTab" || action === "switchTab") &&
3720
+ (stepArgs?.receipt || stepArgs?.receiptUrl || stepArgs?.url)) {
3721
+ throw new Error(
3722
+ `Tab safety: without SAFARI_PROFILE, run_script's ${action} runs through AppleScript, which ` +
3723
+ `cannot tell which tab a receipt names. Pass the receipt to safari_${action === "closeTab" ? "close_tab" : "switch_tab"} instead.`
3724
+ );
3725
+ }
3726
+ // A switch that cannot happen stops the batch: the steps after it were meant for the tab
3727
+ // it names, and would run in the current one instead.
3728
+ if (action === "switchTab") {
3729
+ const idx = Number(stepArgs?.index);
3730
+ if (!Number.isInteger(idx) || idx < 1) {
3731
+ throw new Error("Tab safety: run_script's switchTab needs the tab's index (a positive integer).");
3732
+ }
3733
+ }
3734
+ // A closeTab step is a close_tab, so every refusal that belongs to closing applies to it,
3735
+ // the one for a tab adopted from the user included (#92, condition 2).
3736
+ if (!_RUNSCRIPT_OWNERSHIP_EXEMPT.has(action)) {
3737
+ _assertTabOwnership(action === "closeTab" ? "close_tab" : `run_script:${action}`);
3738
+ }
3739
+ };
3740
+ // A tab the extension opened carries a marker only once AppleScript has needed it as the
3741
+ // current tab, so switchTab() cannot prove the others. The extension can, by the tab's id.
3742
+ const switchTab = async ({ index }) => {
3743
+ try {
3744
+ return await safari.switchTab(index);
3745
+ } catch (err) {
3746
+ if (!err?.unproven || !_extensionConnected) throw err;
3747
+ return _runExtensionBatchAction("switchTab", { index: Number(index) });
3748
+ }
3586
3749
  };
3587
- return textResult(await safari.runScript({ steps, onStep }));
3750
+ return textResult(await safari.runScript({ steps, onStep, actions: { switchTab } }));
3588
3751
  }
3589
3752
 
3590
3753
  // Keep every step in one MCP request/session and route it through
@@ -62,10 +62,6 @@ export function _saveOwnershipFile(urls, removed = []) {
62
62
  const mergedTs = new Map();
63
63
  for (const e of _loadOwnershipFile()) mergedTs.set(e.url, e.ts);
64
64
  for (const url of urls) {
65
- // Adopted user tabs (#92) are session-local by construction — see _adoptUserTab. This
66
- // is the one place that writes the file, so the exclusion belongs here rather than at
67
- // each caller: _pruneExpiredOwnership() also saves, and it does not know about adoption.
68
- if (_adoptedTabURLs.has(url)) continue;
69
65
  const localTs = _ownedTabTimestamps.get(url) ?? now;
70
66
  mergedTs.set(url, Math.max(localTs, mergedTs.get(url) ?? 0));
71
67
  }
@@ -160,39 +156,16 @@ export function _markBlankTabOpened() {
160
156
  // opt-in safe, and an ambient op landing on whatever tab is frontmost is the exact failure
161
157
  // the guards were built against. Read from the environment on each call so a host can flip
162
158
  // it without a restart and so tests need no module reload.
159
+ //
160
+ // Nothing here records an adoption. A URL cannot tell the adopted tab from another tab on
161
+ // the same page, and the adopted tab can navigate to a URL the session owns. safari.js marks
162
+ // the tab itself instead, with the session's adoption marker (isActiveTabAdopted()), in the
163
+ // per-session state: nothing reaches the shared ownership file or another session.
163
164
  export function allowUserTabs() {
164
165
  const v = String(process.env.SAFARI_MCP_ALLOW_USER_TABS || "").trim().toLowerCase();
165
166
  return v === "1" || v === "true" || v === "yes" || v === "on";
166
167
  }
167
168
 
168
- // URLs adopted from the user, as opposed to opened by this session. Two rules ride on this
169
- // set: closing is refused for every member (#68 — a wrong close costs the user work that a
170
- // wrong read never does), and nothing in it is ever written to the shared ownership file.
171
- export const _adoptedTabURLs = new Set();
172
-
173
- // Adoption is in-memory ONLY. owned-tabs.json is shared by every safari-mcp process on the
174
- // machine and outlives this session, so a persisted adoption would hand the user's tab to
175
- // the next process — one that has no _adoptedTabURLs entry and would therefore let
176
- // close_tab through. Session-local is also the honest lifetime: the opt-in is "act on the
177
- // tab I pointed you at", not "own it from now on".
178
- export function _adoptUserTab(url) {
179
- if (!allowUserTabs()) return false;
180
- if (!url || url === "about:blank" || url === "missing value" || url === "favorites://") return false;
181
- _adoptedTabURLs.add(url);
182
- if (!_ownedTabTimestamps.has(url)) _ownedTabTimestamps.set(url, Date.now());
183
- _ownedTabURLs.add(url);
184
- return true;
185
- }
186
-
187
- export function _isAdoptedURL(url) {
188
- if (!url) return false;
189
- if (_adoptedTabURLs.has(url)) return true;
190
- // The caller may hold the query-stripped form of the URL (origin+pathname) that the tool
191
- // layer hands back, while adoption recorded the raw one, or the reverse. Both name the
192
- // same adopted document, and only a refusal hangs off this answer.
193
- return findOwnedMatch(url, _adoptedTabURLs) !== null;
194
- }
195
-
196
169
  export function _addOwnedURL(url) {
197
170
  if (url && url !== "about:blank" && url !== "favorites://") {
198
171
  if (!_ownedTabTimestamps.has(url)) _ownedTabTimestamps.set(url, Date.now());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.22.3",
3
+ "version": "2.22.5",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
@@ -781,7 +781,7 @@ func handleLine(_ line: String) {
781
781
  }
782
782
 
783
783
  // Handle window-scoped AX press
784
- // {"axpressWindow": {"window": "בננה בוק", "title": "Save"}}
784
+ // {"axpressWindow": {"window": "Example Window", "title": "Save"}}
785
785
  if let apw = json["axpressWindow"] as? [String: Any],
786
786
  let wm = apw["window"] as? String, let title = apw["title"] as? String {
787
787
  if let denied = ensurePostEventAccess() { respond(denied); return }