safari-mcp 2.22.2 → 2.22.4

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
@@ -381,6 +381,7 @@ What the flag deliberately does *not* do:
381
381
  | `SAFARI_MCP_HTTP_PORT` | `9225` | Port for that daemon. |
382
382
  | `SAFARI_PROFILE` | unset | Bind sessions to a named Safari profile. Unset = your ordinary windows. |
383
383
  | `SAFARI_MCP_ALLOW_USER_TABS` | off | Let `safari_switch_tab` adopt a tab **you** already had open, instead of refusing it (see below). |
384
+ | `MCP_MAX_TABS` | `6` | Tabs one session may keep open. Opening one more closes that session's oldest tab, and `safari_new_tab` names it in `evictedTab` (its receipt). |
384
385
  | `SAFARI_MCP_RAISE_ON_NAVIGATE` | off | Let navigation bring Safari to the front, and stop the focus guard from putting your previous app back. |
385
386
  | `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. |
386
387
  | `SAFARI_MCP_KEEPALIVE_TAB` | off | Keep one daemon-served page open in the profile window so Safari never parks the extension worker between commands. |
package/index.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  OWNERSHIP_DIR, BLANK_TAB_SENTINEL,
17
17
  _openedTabs, _ownedTabURLs,
18
18
  _isURLOwned, _markBlankTabOpened, _addOwnedURL, _removeOwnedURL, _trackTab, _untrackTab,
19
+ _sessionTabs, _trackedAtIndex,
19
20
  allowUserTabs, _adoptUserTab, _isAdoptedURL,
20
21
  } from "./ownership-state.js";
21
22
  import { WebSocketServer } from "ws";
@@ -179,6 +180,96 @@ async function _verifiedTabIndex(info) {
179
180
  try { return await safari.findTabByMarker(info.marker); } catch { return null; }
180
181
  }
181
182
 
183
+ // Close one tab this process opened, found by the identity recorded when it was opened:
184
+ // the receipt the extension minted for it, or the AppleScript marker. Never by position.
185
+ // The tab cap used to close the index recorded at opening, and the close also carried the
186
+ // session's CURRENT receipt, so the extension either refused it or closed whatever tab
187
+ // sat at that position by then — while the caller was told its oldest tab was gone.
188
+ // Returns "closed"; "lost" when the tab is gone or can no longer be shown to be ours (the
189
+ // extension forgets a receipt when its tab closes; an unprovable tab is left open, as #112
190
+ // leaves it); or "failed" when the close itself failed and the tab may still be open.
191
+ async function _closeTrackedTab(info) {
192
+ const receipt = _receiptToken(info?.receipt);
193
+ if (receipt) {
194
+ if (!_extensionConnected || (_preferAppleScript && !_profileExtensionVerified)) return "failed";
195
+ const send = (type, token) =>
196
+ sendToExtension(type, { receipt: token, sessionId: info.sessionId }, _commandTimeouts.close_tab);
197
+ try {
198
+ await send("close_tab", receipt);
199
+ return "closed";
200
+ } catch (err) {
201
+ const msg = String(err?.message || err);
202
+ if (msg.includes("not valid for this origin")) {
203
+ // The tab left the origin its receipt was minted on (a click through, a redirect)
204
+ // and is still this session's tab: rotate the receipt, as a caller would, and close.
205
+ try {
206
+ const fresh = _receiptToken((await send("get_tab_receipt", receipt))?.receipt);
207
+ if (fresh) {
208
+ await send("close_tab", fresh);
209
+ return "closed";
210
+ }
211
+ } catch {}
212
+ }
213
+ return msg.includes("Tab safety:") ? "lost" : "failed";
214
+ }
215
+ }
216
+ // Opened through AppleScript: prove it by its marker. A named profile never closes a tab
217
+ // through AppleScript.
218
+ if (process.env.SAFARI_PROFILE) return "lost";
219
+ const idx = await _verifiedTabIndex(info);
220
+ if (!idx) return "lost";
221
+ try {
222
+ await safari.closeTab(idx);
223
+ return "closed";
224
+ } catch {
225
+ return "failed";
226
+ }
227
+ }
228
+
229
+ // Per-session tab cap: before a new tab opens, close this session's oldest tab when it
230
+ // already has MAX_TABS. Scoped to the caller — in HTTP daemon mode _openedTabs is
231
+ // process-wide, and a global cap closed whichever tab was oldest overall, routinely another
232
+ // session's live page. A tab that is already gone, or can no longer be proven ours, is
233
+ // only dropped from the count. Returns the tab it closed, or null. At most one per call.
234
+ async function _evictOldestTab(sessionId) {
235
+ const mine = _sessionTabs(sessionId);
236
+ let open = mine.length;
237
+ for (const [key, info] of mine) {
238
+ if (open < MAX_TABS) break;
239
+ 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;
242
+ _untrackTab(key);
243
+ open--;
244
+ if (outcome === "closed") {
245
+ console.error(`[Safari MCP] Tab limit (${MAX_TABS}) reached for this session — closed its oldest tab (${_safeUrlForOutput(info.url)})`);
246
+ return info;
247
+ }
248
+ }
249
+ return null;
250
+ }
251
+
252
+ // What safari_new_tab tells the caller about the tab the cap closed. The receipt is how the
253
+ // caller knows which of its tabs is gone; the position it had is meaningless by now.
254
+ function _evictionReport(info) {
255
+ const receipt = _receiptToken(info.receipt);
256
+ const safeUrl = _safeUrlForOutput(info.url);
257
+ const opened = safeUrl === "unknown" ? "" : ` (opened on ${safeUrl})`;
258
+ return {
259
+ evictedTab: { ...(receipt ? { receipt } : {}), ...(opened ? { safeUrl } : {}) },
260
+ note: `Tab cap ${MAX_TABS}/session reached — your oldest tab${opened} was closed, and its receipt no longer works. Close tabs you are done with (safari_close_tab).`,
261
+ };
262
+ }
263
+
264
+ // Forget the tab a close just removed, by the identity that close used. An index says
265
+ // nothing here: every close renumbers the tabs after it.
266
+ function _untrackClosedTab({ receipt = "", marker = "" }) {
267
+ const token = _receiptToken(receipt);
268
+ for (const [key, info] of _openedTabs) {
269
+ if ((token && _receiptToken(info.receipt) === token) || (marker && info.marker === marker)) _untrackTab(key);
270
+ }
271
+ }
272
+
182
273
  // Close all MCP-opened tabs on process exit
183
274
  async function _cleanupTabs() {
184
275
  if (_openedTabs.size === 0) return;
@@ -262,10 +353,9 @@ async function _closeOldestMCPTab() {
262
353
  if (info.openedAt < oldestTime) { oldestTime = info.openedAt; oldestIdx = idx; }
263
354
  }
264
355
  if (oldestIdx !== null) {
265
- try {
266
- const idx = await _verifiedTabIndex(_openedTabs.get(oldestIdx));
267
- if (idx) await safari.closeTab(idx);
268
- } catch {}
356
+ // By its identity, like the tab cap: an extension-opened tab carries a receipt, not a
357
+ // marker, and a marker-only close forgot it here without freeing anything.
358
+ await _closeTrackedTab(_openedTabs.get(oldestIdx));
269
359
  _untrackTab(oldestIdx);
270
360
  }
271
361
  }
@@ -1741,6 +1831,9 @@ function _explicitReceipt(args) {
1741
1831
  return { receipt: token };
1742
1832
  }
1743
1833
 
1834
+ // The session's current tab: its receipt, or "" for a tab picked without one (an index
1835
+ // switch, a tab opened without a receipt), which the extension resolves from its own
1836
+ // cache. No entry means the session has no current tab — it was closed.
1744
1837
  const _activeReceipts = new Map();
1745
1838
  function _receiptSessionKey() {
1746
1839
  return `${SESSION_ID}:${currentSessionId()}`;
@@ -1749,10 +1842,29 @@ function _getActiveReceipt() {
1749
1842
  return _activeReceipts.get(_receiptSessionKey()) || "";
1750
1843
  }
1751
1844
  function _setActiveReceipt(receipt) {
1752
- const token = _receiptToken(receipt);
1753
- const key = _receiptSessionKey();
1754
- if (token) _activeReceipts.set(key, token);
1755
- else _activeReceipts.delete(key);
1845
+ _activeReceipts.set(_receiptSessionKey(), _receiptToken(receipt));
1846
+ }
1847
+ function _clearActiveReceipt() {
1848
+ _activeReceipts.delete(_receiptSessionKey());
1849
+ }
1850
+
1851
+ // Commands that name no tab; every other command targets the session's current tab.
1852
+ const _UNTARGETED_COMMANDS = new Set(["new_tab", "list_tabs", "switch_tab", "reload_extension"]);
1853
+
1854
+ // In a named profile, a command with no receipt from a session whose current tab was closed
1855
+ // went to the extension's guess — its cached tab, else the session's newest — and with
1856
+ // several tabs open that guess read another query's page (geo-audit, 24.9.26). A current
1857
+ // tab, or a single open tab, is never ambiguous; only this case is refused.
1858
+ function _refuseUnnamedTab(extensionType, extensionPayload) {
1859
+ if (!_preferAppleScript || _UNTARGETED_COMMANDS.has(extensionType)) return;
1860
+ if (_receiptToken(extensionPayload?.receipt) || _activeReceipts.has(_receiptSessionKey())) return;
1861
+ const open = _sessionTabs(_receiptSessionKey()).length;
1862
+ if (open < 2) return;
1863
+ throw new Error(
1864
+ `Tab safety: this session's current tab was closed and ${open} of its tabs are still open, ` +
1865
+ `so "${extensionType}" does not say which one to use. Pass that tab's receipt ` +
1866
+ "(from safari_new_tab or safari_list_tabs), or pick it with safari_switch_tab."
1867
+ );
1756
1868
  }
1757
1869
 
1758
1870
  function _sanitizeTabResult(value) {
@@ -1808,14 +1920,16 @@ async function _runExtensionBatchAction(action, args = {}) {
1808
1920
  switch (action) {
1809
1921
  case "newTab": {
1810
1922
  const requestedUrl = String(args.url || "");
1923
+ let viaAppleScript = false;
1811
1924
  const raw = await extensionOrFallback(
1812
1925
  "new_tab", { url: requestedUrl },
1813
- () => safari.newTab(requestedUrl)
1926
+ () => { viaAppleScript = true; return safari.newTab(requestedUrl); }
1814
1927
  );
1815
1928
  const value = _sanitizeTabResult(normalize(raw));
1816
1929
  if (value && typeof value === "object" && value.tabIndex) {
1817
1930
  safari.setActiveTabIndex(value.tabIndex);
1818
- _trackTab(value.tabIndex, requestedUrl, mySession, safari.getActiveTabMarker());
1931
+ _trackTab(value.tabIndex, requestedUrl, mySession, viaAppleScript ? safari.getActiveTabMarker() : "", value.receipt);
1932
+ _setActiveReceipt(value.receipt);
1819
1933
  }
1820
1934
  const trackUrl = requestedUrl || "about:blank";
1821
1935
  if (trackUrl) {
@@ -1824,7 +1938,6 @@ async function _runExtensionBatchAction(action, args = {}) {
1824
1938
  } else {
1825
1939
  _markBlankTabOpened();
1826
1940
  }
1827
- if (value?.receipt) _setActiveReceipt(value.receipt);
1828
1941
  return value;
1829
1942
  }
1830
1943
 
@@ -1848,7 +1961,9 @@ async function _runExtensionBatchAction(action, args = {}) {
1848
1961
  const value = _sanitizeTabResult(raw);
1849
1962
  const resolvedIndex = value?.tabIndex || index;
1850
1963
  if (resolvedIndex) safari.setActiveTabIndex(resolvedIndex);
1851
- if (value?.receipt || receipt) _setActiveReceipt(value?.receipt || receipt);
1964
+ // A switch by index that returns no receipt still makes that tab the current one.
1965
+ // Keeping the previous tab's receipt sent every later step back to the previous tab.
1966
+ if (value && typeof value === "object") _setActiveReceipt(value.receipt || receipt);
1852
1967
  return value;
1853
1968
  }
1854
1969
 
@@ -1856,6 +1971,7 @@ async function _runExtensionBatchAction(action, args = {}) {
1856
1971
  return _sanitizeTabResult(await extensionOrFallback("list_tabs", {}, () => safari.listTabs()));
1857
1972
 
1858
1973
  case "getReceipt": {
1974
+ const previous = _receiptToken(args.receipt || "") || _getActiveReceipt();
1859
1975
  const value = normalize(await extensionOrFallback(
1860
1976
  "get_tab_receipt", {
1861
1977
  ...(_receiptToken(args.receipt || "") ? { receipt: _receiptToken(args.receipt) } : {}),
@@ -1864,22 +1980,31 @@ async function _runExtensionBatchAction(action, args = {}) {
1864
1980
  ));
1865
1981
  const safeValue = _sanitizeTabResult(value);
1866
1982
  if (safeValue?.index) safari.setActiveTabIndex(safeValue.index);
1867
- if (safeValue?.receipt) _setActiveReceipt(safeValue.receipt);
1983
+ if (safeValue?.receipt) {
1984
+ // Rotation retires the old token. Keep it resolving to the new one, as navigate does,
1985
+ // so the tab stays reachable — and closable by the tab cap — by the name it was opened with.
1986
+ _aliasReceipt(previous, safeValue.receipt);
1987
+ _setActiveReceipt(safeValue.receipt);
1988
+ }
1868
1989
  return safeValue;
1869
1990
  }
1870
1991
 
1871
1992
  case "closeTab": {
1872
1993
  const index = args.index === undefined ? undefined : Number(args.index);
1873
1994
  const receipt = _receiptToken(args.receipt || args.receiptUrl || args.url || "") || _getActiveReceipt();
1995
+ const closesCurrent = !receipt || receipt === _getActiveReceipt();
1874
1996
  const raw = await extensionOrFallback(
1875
1997
  "close_tab", { ...(index ? { index } : {}), ...(receipt ? { receipt } : {}) },
1876
1998
  () => safari.closeTab(index)
1877
1999
  );
1878
- const activeIndex = safari.getActiveTabIndex();
1879
- if (activeIndex !== null) _untrackTab(activeIndex);
1880
- safari.setActiveTabIndex(null);
1881
- safari.setActiveTabURL(null);
1882
- _setActiveReceipt("");
2000
+ if (/^Tab not found/.test(String(raw))) return normalize(raw);
2001
+ // A named profile closes through the extension only: the tab that closed is the receipt's.
2002
+ _untrackClosedTab({ receipt });
2003
+ if (closesCurrent) {
2004
+ safari.setActiveTabIndex(null);
2005
+ safari.setActiveTabURL(null);
2006
+ _clearActiveReceipt();
2007
+ }
1883
2008
  return normalize(raw);
1884
2009
  }
1885
2010
 
@@ -2091,6 +2216,7 @@ function _assertTabOwnership(opType, extensionPayload = {}) {
2091
2216
  // Try the profile-verified extension first. Named profiles are extension-only:
2092
2217
  // crossing to AppleScript would lose the extension's profile and tab-id proof.
2093
2218
  async function extensionOrFallback(extensionType, extensionPayload, fallbackFn) {
2219
+ _refuseUnnamedTab(extensionType, extensionPayload);
2094
2220
  // Tab-ownership guard — extracted to _assertTabOwnership so run_script / native_* share it.
2095
2221
  _assertTabOwnership(extensionType, extensionPayload);
2096
2222
 
@@ -2125,7 +2251,7 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
2125
2251
  // into "_default" — so both parts are needed.
2126
2252
  // A receipt is an opaque capability, not a URL. Keep it out of page state and
2127
2253
  // attach it automatically only to commands that target the current logical tab.
2128
- const attachActiveReceipt = !["new_tab", "list_tabs", "switch_tab", "reload_extension"].includes(extensionType);
2254
+ const attachActiveReceipt = !_UNTARGETED_COMMANDS.has(extensionType);
2129
2255
  const reloadHandoff = extensionType === "reload_extension" && _isExtensionHost
2130
2256
  ? _prepareReloadHttpWorkerHandoff()
2131
2257
  : null;
@@ -2910,45 +3036,15 @@ server.tool(
2910
3036
  // itself when the previous last window was closed.
2911
3037
  if (process.env.SAFARI_PROFILE) await _waitForVerifiedProfileExtension(30000);
2912
3038
 
2913
- // Enforce tab limit — close this session's oldest MCP tab if at max.
2914
- // Scoped to the caller: in HTTP daemon mode _openedTabs is process-wide, so a
2915
- // global cap meant N concurrent sessions shared one budget and the eviction closed
2916
- // whichever tab was oldest overall — routinely another session's live page. Each
2917
- // session now gets its own MAX_TABS and can only evict its own.
3039
+ // Enforce the tab cap: close this session's oldest tab, by its identity, if at max.
2918
3040
  const mySession = `${SESSION_ID}:${currentSessionId()}`;
2919
- const myTabs = [..._openedTabs].filter(([, info]) => (info.sessionId || "") === mySession);
2920
- let evictedTab = null;
2921
- if (myTabs.length >= MAX_TABS) {
2922
- let oldestIdx = null, oldestTime = Infinity;
2923
- for (const [idx, info] of myTabs) {
2924
- if (info.openedAt < oldestTime) { oldestTime = info.openedAt; oldestIdx = idx; }
2925
- }
2926
- if (oldestIdx !== null) {
2927
- console.error(`[Safari MCP] Tab limit (${MAX_TABS}) reached for this session — closing its oldest tab #${oldestIdx}`);
2928
- try {
2929
- // The recorded index has shifted if any tab closed since; on the AppleScript path
2930
- // re-prove it by marker first, and skip the eviction when it cannot be proven
2931
- // (#112). The extension resolves its own owned tab ids, so it keeps the recorded one.
2932
- const evictIdx = process.env.SAFARI_PROFILE
2933
- ? oldestIdx
2934
- : await _verifiedTabIndex(_openedTabs.get(oldestIdx));
2935
- if (evictIdx) {
2936
- await extensionOrFallback(
2937
- "close_tab",
2938
- { index: evictIdx },
2939
- () => safari.closeTab(evictIdx)
2940
- );
2941
- }
2942
- } catch {}
2943
- _untrackTab(oldestIdx);
2944
- evictedTab = oldestIdx;
2945
- }
2946
- }
3041
+ const evicted = await _evictOldestTab(mySession);
2947
3042
 
2948
3043
  const requestedUrl = String(url || "");
3044
+ let viaAppleScript = false;
2949
3045
  const rawResult = await extensionOrFallback(
2950
3046
  "new_tab", { url: requestedUrl },
2951
- () => safari.newTab(requestedUrl)
3047
+ () => { viaAppleScript = true; return safari.newTab(requestedUrl); }
2952
3048
  );
2953
3049
  // AppleScript fallback returns a JSON string; extension returns an object — normalize
2954
3050
  let result = rawResult;
@@ -2960,7 +3056,11 @@ server.tool(
2960
3056
  // remains internal and is never copied into the MCP response.
2961
3057
  if (safeResult?.tabIndex) {
2962
3058
  safari.setActiveTabIndex(safeResult.tabIndex);
2963
- _trackTab(safeResult.tabIndex, requestedUrl, mySession, safari.getActiveTabMarker());
3059
+ // safari.js holds the marker of the last tab AppleScript opened, so it names THIS tab
3060
+ // only when AppleScript just opened it.
3061
+ _trackTab(safeResult.tabIndex, requestedUrl, mySession, viaAppleScript ? safari.getActiveTabMarker() : "", safeResult.receipt);
3062
+ // The new tab is the current one, with or without a receipt.
3063
+ _setActiveReceipt(safeResult.receipt);
2964
3064
  }
2965
3065
  if (requestedUrl) {
2966
3066
  const trackUrl = requestedUrl;
@@ -2968,11 +3068,10 @@ server.tool(
2968
3068
  _addOwnedURL(trackUrl);
2969
3069
  }
2970
3070
  if (!requestedUrl) _markBlankTabOpened();
2971
- if (safeResult?.receipt) _setActiveReceipt(safeResult.receipt);
2972
3071
  // The eviction used to be stderr-only, so a session learned about its closed tab from
2973
3072
  // the next "receipt … stale" error. Say it in the response instead.
2974
- const out = evictedTab !== null && safeResult && typeof safeResult === "object"
2975
- ? { ...safeResult, evictedTab, note: `Tab cap ${MAX_TABS}/session reached — your oldest tab #${evictedTab} was closed. Close tabs you are done with (safari_close_tab).` }
3073
+ const out = evicted && safeResult && typeof safeResult === "object"
3074
+ ? { ...safeResult, ..._evictionReport(evicted) }
2976
3075
  : safeResult;
2977
3076
  return { content: [{ type: "text", text: typeof out === 'string' ? out : JSON.stringify(out) }] };
2978
3077
  }
@@ -2986,17 +3085,21 @@ server.tool(
2986
3085
  url: z.string().optional().describe("Deprecated legacy receipt URL"),
2987
3086
  },
2988
3087
  async ({ receipt, url }) => {
2989
- const activeIdx = safari.getActiveTabIndex();
2990
3088
  const supplied = receipt || url || "";
2991
3089
  const token = _receiptToken(supplied) || _getActiveReceipt();
2992
3090
  if (supplied && !token) return errorResult("Tab safety: invalid tab receipt");
3091
+ const closesCurrent = !token || token === _getActiveReceipt();
3092
+ const marker = safari.getActiveTabMarker();
3093
+ let viaAppleScript = false;
2993
3094
  const result = await extensionOrFallback(
2994
3095
  "close_tab",
2995
3096
  token ? { receipt: token } : {},
2996
- () => safari.closeTab()
3097
+ () => { viaAppleScript = true; return safari.closeTab(); }
2997
3098
  );
2998
- if (activeIdx !== null) _untrackTab(activeIdx);
2999
- _setActiveReceipt("");
3099
+ // AppleScript closed the tab carrying the session's marker; the extension, the receipt's.
3100
+ _untrackClosedTab(viaAppleScript ? { marker } : { receipt: token });
3101
+ // Closing another tab by its receipt leaves the current one current.
3102
+ if (viaAppleScript || closesCurrent) _clearActiveReceipt();
3000
3103
  return textResult(result);
3001
3104
  }
3002
3105
  );
@@ -3041,18 +3144,18 @@ server.tool(
3041
3144
  const target = parsed.find(t => t.index === index);
3042
3145
  if (target && target.url && !_isURLOwned(target.url)) {
3043
3146
  // about:blank / missing value tabs are owned if tracked in _openedTabs
3044
- const isBlankOwned = (target.url === 'about:blank' || target.url === 'missing value') && (_openedTabs.has(index) || _ownedTabURLs.has(BLANK_TAB_SENTINEL));
3147
+ const isBlankOwned = (target.url === 'about:blank' || target.url === 'missing value') && (!!_trackedAtIndex(index) || _ownedTabURLs.has(BLANK_TAB_SENTINEL));
3045
3148
  // A tab THIS session opened may have redirected away from the URL we
3046
3149
  // registered (/dashboard -> /login, any 302), so the URL alone stops
3047
3150
  // being proof and the tab became permanently un-switchable. But a
3048
- // tracked index is not proof either: `_openedTabs` is keyed by INDEX
3049
- // and Safari renumbers every index whenever any tab closes, so a stale
3050
- // key can point straight at a user's tab. Require BOTH — the index we
3051
- // opened AND the origin we opened it on. That mirrors the origin
3151
+ // recorded index is not proof either: Safari renumbers every index
3152
+ // whenever any tab closes, so a stale one can point straight at a
3153
+ // user's tab. Require BOTH — the index we opened AND the origin we
3154
+ // opened it on. That mirrors the origin
3052
3155
  // boundary the extension keeps on its own receipts, so nothing that
3053
3156
  // would survive the extension's check is lost here, and the AppleScript
3054
3157
  // fallback (which has no ownership check of its own) stays guarded.
3055
- const trackedOrigin = _originOf(_openedTabs.get(index)?.url);
3158
+ const trackedOrigin = _originOf(_trackedAtIndex(index)?.url);
3056
3159
  const isTrackedRedirect = !!trackedOrigin && trackedOrigin === _originOf(target.url);
3057
3160
  if (!isBlankOwned && !isTrackedRedirect && allowUserTabs()) {
3058
3161
  // The opt-in turns this refusal into a deliberate, named adoption (#92). Both
@@ -3123,6 +3226,21 @@ server.tool(
3123
3226
  },
3124
3227
  async ({ timeout, urlContains }) => {
3125
3228
  const timeoutMs = timeout || 10000;
3229
+ // Switch to the new tab and own it for THIS session, else the next interaction trips the
3230
+ // 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) => {
3233
+ let viaAppleScript = false;
3234
+ const switched = _sanitizeTabResult(await extensionOrFallback(
3235
+ "switch_tab", { index: t.index },
3236
+ () => { viaAppleScript = true; return safari.switchTab(t.index); }
3237
+ ));
3238
+ safari.setActiveTabIndex(t.index);
3239
+ safari.setActiveTabURL(t.url);
3240
+ _trackTab(t.index, t.url, `${SESSION_ID}:${currentSessionId()}`, viaAppleScript ? safari.getActiveTabMarker() : "", switched?.receipt);
3241
+ _setActiveReceipt(switched?.receipt);
3242
+ return { content: [{ type: "text", text: `Found new tab: ${t.title} (${t.url})` }] };
3243
+ };
3126
3244
  // Get current tab list
3127
3245
  const beforeRaw = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
3128
3246
  const beforeTabs = typeof beforeRaw === 'string' ? JSON.parse(beforeRaw) : beforeRaw;
@@ -3152,20 +3270,12 @@ server.tool(
3152
3270
  }
3153
3271
  if (resolved && resolved.url !== 'about:blank') {
3154
3272
  if (urlContains && !resolved.url.includes(urlContains)) continue;
3155
- await extensionOrFallback("switch_tab", { index: resolved.index }, () => safari.switchTab(resolved.index));
3156
- safari.setActiveTabIndex(resolved.index);
3157
- safari.setActiveTabURL(resolved.url);
3158
- _trackTab(resolved.index, resolved.url, `${SESSION_ID}:${currentSessionId()}`, safari.getActiveTabMarker()); // own the popup for THIS session, else the next interaction trips the tab-safety guard
3159
- return { content: [{ type: "text", text: `Found new tab: ${resolved.title} (${resolved.url})` }] };
3273
+ return await adopt(resolved);
3160
3274
  }
3161
3275
  continue;
3162
3276
  }
3163
3277
  if (urlContains && !tab.url.includes(urlContains)) continue;
3164
- await extensionOrFallback("switch_tab", { index: tab.index }, () => safari.switchTab(tab.index));
3165
- safari.setActiveTabIndex(tab.index);
3166
- safari.setActiveTabURL(tab.url);
3167
- _trackTab(tab.index, tab.url, `${SESSION_ID}:${currentSessionId()}`, safari.getActiveTabMarker()); // own the new tab for THIS session, else the next interaction trips the tab-safety guard
3168
- return { content: [{ type: "text", text: `Found new tab: ${tab.title} (${tab.url})` }] };
3278
+ return await adopt(tab);
3169
3279
  }
3170
3280
  }
3171
3281
  }
@@ -209,18 +209,39 @@ export function _removeOwnedURL(url) {
209
209
  }
210
210
  }
211
211
 
212
- export function _trackTab(tabIndex, url, sessionId = "", marker = "") {
212
+ export function _trackTab(tabIndex, url, sessionId = "", marker = "", receipt = "") {
213
213
  // sessionId is what keeps the tab cap per-session. In HTTP daemon mode one process
214
214
  // serves many Claude sessions, and this map is process-wide: without it, the cap is
215
215
  // the SUM of every session's tabs, and the "close the oldest" eviction happily closed
216
216
  // a tab another session was still working in.
217
- // marker is the tab's identity stamp (window.name / __mcpTabMarker). The index and the
218
- // URL both go stale — indices shift, and the tab navigates — so every path that later
219
- // CLOSES this tab resolves it through the marker instead (#112).
220
- _openedTabs.set(tabIndex, { url: url || "", openedAt: Date.now(), sessionId, marker: marker || "" });
217
+ // The index and the URL both go stale — indices shift whenever a tab of the window
218
+ // closes or moves, and the tab navigates — so the entry carries the tab's identity: the
219
+ // receipt the extension minted for it, or the marker (window.name / __mcpTabMarker)
220
+ // stamped on a tab AppleScript opened. Every path that later CLOSES this tab resolves it
221
+ // through that identity (#112), and the entry is keyed by it too: keyed by index, a new
222
+ // tab that landed on a shifted position overwrote a live tab's entry.
223
+ _openedTabs.set(receipt || marker || tabIndex, {
224
+ index: tabIndex, url: url || "", openedAt: Date.now(), sessionId,
225
+ marker: marker || "", receipt: receipt || "",
226
+ });
221
227
  _addOwnedURL(url);
222
228
  }
223
229
 
230
+ // This session's tracked tabs as [key, info], oldest first.
231
+ export function _sessionTabs(sessionId) {
232
+ return [..._openedTabs]
233
+ .filter(([, info]) => (info.sessionId || "") === sessionId)
234
+ .sort(([, a], [, b]) => a.openedAt - b.openedAt);
235
+ }
236
+
237
+ // The newest tab recorded at `index` when it was opened. A hint, never proof: Safari
238
+ // renumbers indices on every close, so a caller must pair it with other evidence.
239
+ export function _trackedAtIndex(index) {
240
+ let hit;
241
+ for (const info of _openedTabs.values()) if (info.index === index) hit = info;
242
+ return hit;
243
+ }
244
+
224
245
  export function _untrackTab(tabIndex) {
225
246
  const info = _openedTabs.get(tabIndex);
226
247
  if (info?.url) _removeOwnedURL(info.url);
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.22.2",
3
+ "version": "2.22.4",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
- "description": "Safari browser automation for AI agents \u2014 native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.",
5
+ "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
7
7
  "main": "index.js",
8
8
  "bin": {
@@ -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 }