safari-mcp 2.22.5 → 2.22.6

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.
Files changed (4) hide show
  1. package/index.js +309 -115
  2. package/ownership-state.js +31 -12
  3. package/package.json +1 -1
  4. package/safari.js +543 -177
package/index.js CHANGED
@@ -228,7 +228,7 @@ async function _evictOldestTab(sessionId) {
228
228
  let open = mine.length;
229
229
  for (const [key, info] of mine) {
230
230
  if (open < MAX_TABS) break;
231
- const outcome = await _closeTrackedTab(info);
231
+ const outcome = await _oneCloseAtATime(() => _closeTrackedTab(info));
232
232
  // It may still be open: keep counting it and retry it at the next new tab. Close the next
233
233
  // only through the extension, which fails fast: an AppleScript close that failed, a page that
234
234
  // never answers the marker check, would cost every tracked tab its own timeout.
@@ -267,6 +267,59 @@ function _untrackClosedTab({ receipt = "", marker = "" }) {
267
267
  }
268
268
  }
269
269
 
270
+ // One tab close at a time in this process. closeTabByMarker() checks a tab's marker and closes the
271
+ // tab in one script, but as two AppleEvents, and a close sent in between renumbered the window: the
272
+ // index the check found then named the next tab, the user's included. Two tab-cap evictions at once
273
+ // could do it (safari_new_tab and run_script newTab, in parallel), as could an eviction next to
274
+ // safari_close_tab. An extension close goes by tab id, but it renumbers the window all the same.
275
+ // ponytail: this orders the closes of one server process (every session of an HTTP daemon); a close
276
+ // by another MCP process can still land between those two events. A slow extension close holds up
277
+ // the rest, shutdown cleanup included, which then leaves the tabs open after its 3s.
278
+ let _closeQueue = Promise.resolve();
279
+ function _oneCloseAtATime(close) {
280
+ const run = _closeQueue.then(() => close());
281
+ _closeQueue = run.catch(() => {});
282
+ return run;
283
+ }
284
+
285
+ // A URL a call takes the session's tab to, and with no scheme its https:// form, which is what
286
+ // safari.js navigate() loads. Strings only: run_script step args are not validated. Closing a tab
287
+ // opened on the URL releases both forms (_claimedForms in ownership-state.js).
288
+ function _claimURL(url) {
289
+ if (typeof url !== "string" || !url) return;
290
+ _addOwnedURL(url);
291
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) _addOwnedURL("https://" + url);
292
+ }
293
+
294
+ // Open a tab through AppleScript as one of the session's own: its URL claimed, and the tab tracked
295
+ // by the marker safari.newTab() mints for it, so the session's tab cap, shutdown cleanup and the
296
+ // memory sweep count and close it, and a close forgets it. The URL the caller asked for stays the
297
+ // session's current one wherever the page redirected: the site chose the redirect's target.
298
+ // newTab() names the marker (onMarker) as soon as the tab exists, and can still throw after that,
299
+ // when marking or reading the new tab fails; the tab is claimed and tracked then too. Left out, it
300
+ // outlived the cap and cleanup, and the next write was refused in it as "not opened by this MCP
301
+ // session". A newTab() that threw before the tab existed named no marker. The marker newTab()
302
+ // names, not the session's after the call: a parallel call of the same session changes that one,
303
+ // and a switch that adopted the user's tab (SAFARI_MCP_ALLOW_USER_TABS) meanwhile got the user's
304
+ // tab recorded among the ones the cap and cleanup close.
305
+ async function _openTabViaAppleScript(url) {
306
+ const mySession = `${SESSION_ID}:${currentSessionId()}`;
307
+ let minted = null;
308
+ try {
309
+ return await safari.newTab(url, { onMarker: (marker) => { minted = marker; } });
310
+ } finally {
311
+ if (minted) {
312
+ _trackTab(safari.getActiveTabIndex(), url, mySession, minted); // the index is only a hint
313
+ if (!url || url === "about:blank") {
314
+ _markBlankTabOpened();
315
+ } else {
316
+ safari.setActiveTabURL(url);
317
+ _claimURL(url);
318
+ }
319
+ }
320
+ }
321
+ }
322
+
270
323
  // Only the extension can tell which tab a receipt names: AppleScript proves a tab by the
271
324
  // marker stamped on it (#112), and a tab the extension opened carries none. A close or switch
272
325
  // by receipt whose extension attempt failed fell back to the session's current tab instead,
@@ -296,7 +349,7 @@ async function _cleanupTabs() {
296
349
  // with no index would close only this session's active tab (#68).
297
350
  for (const info of [..._openedTabs.values()]) {
298
351
  try {
299
- await safari.closeTabByMarker(info.marker);
352
+ await _oneCloseAtATime(() => safari.closeTabByMarker(info.marker));
300
353
  } catch {}
301
354
  }
302
355
  _openedTabs.clear();
@@ -362,7 +415,7 @@ async function _closeOldestMCPTab() {
362
415
  if (oldestIdx !== null) {
363
416
  // By its identity, like the tab cap: an extension-opened tab carries a receipt, not a
364
417
  // marker, and a marker-only close forgot it here without freeing anything.
365
- await _closeTrackedTab(_openedTabs.get(oldestIdx));
418
+ await _oneCloseAtATime(() => _closeTrackedTab(_openedTabs.get(oldestIdx)));
366
419
  _untrackTab(oldestIdx);
367
420
  }
368
421
  }
@@ -1815,6 +1868,21 @@ function _aliasReceipt(oldToken, newToken) {
1815
1868
  _receiptAliases.set(oldToken, newToken);
1816
1869
  if (_receiptAliases.size > 500) _receiptAliases.delete(_receiptAliases.keys().next().value);
1817
1870
  }
1871
+
1872
+ // The origin a receipt is valid on: the page the extension reported with it (_sanitizeTabResult).
1873
+ // safari_navigate rotates a receipt only when the tab lands off that origin. Judged by the URL
1874
+ // safari.js holds instead (another tab's when the call names its tab by receipt, unknown after a
1875
+ // switch the extension served, stale after a navigation it served), a navigate that stayed on the
1876
+ // receipt's origin counted as a change and retired the receipt the caller kept.
1877
+ const _receiptOrigins = new Map(); // token → origin
1878
+ function _noteReceiptOrigin(receipt, url) {
1879
+ const token = _receiptToken(receipt);
1880
+ const origin = _originOf(url);
1881
+ if (!token || !origin) return;
1882
+ _receiptOrigins.set(token, origin);
1883
+ if (_receiptOrigins.size > 500) _receiptOrigins.delete(_receiptOrigins.keys().next().value);
1884
+ }
1885
+
1818
1886
  function _receiptToken(value) {
1819
1887
  const raw = String(value || "");
1820
1888
  let token = "";
@@ -1890,6 +1958,8 @@ function _sanitizeTabResult(value) {
1890
1958
  if (!normalized || typeof normalized !== "object") return normalized;
1891
1959
  const safeUrl = _safeUrlForOutput(normalized.safeUrl || normalized.url || normalized.requestedUrl || "");
1892
1960
  const receipt = _receiptToken(normalized.receipt || normalized.receiptUrl || "");
1961
+ // The extension hands every receipt out here, with the page it is valid on.
1962
+ _noteReceiptOrigin(receipt, safeUrl);
1893
1963
  return {
1894
1964
  ...(normalized.index !== undefined ? { index: normalized.index } : {}),
1895
1965
  ...(normalized.tabIndex !== undefined ? { tabIndex: normalized.tabIndex } : {}),
@@ -1997,12 +2067,20 @@ async function _runExtensionBatchAction(action, args = {}) {
1997
2067
  return _sanitizeTabResult(await extensionOrFallback("list_tabs", {}, () => safari.listTabs()));
1998
2068
 
1999
2069
  case "getReceipt": {
2000
- const previous = _receiptToken(args.receipt || "") || _getActiveReceipt();
2070
+ // A receipt that is not one (a token cut short, the advice's "<old receipt>" left in) fell
2071
+ // through to the current tab's receipt, and the step rotated that tab instead.
2072
+ if (args.receipt && !_receiptToken(args.receipt)) {
2073
+ throw new Error("Tab safety: getReceipt requires an extension-issued receipt");
2074
+ }
2075
+ const previous = _receiptToken(args.receipt || _getActiveReceipt());
2076
+ // The payload names the receipt aliased below. extensionOrFallback would otherwise attach the
2077
+ // session's receipt as it stands after its own awaits, which a call running alongside can change.
2001
2078
  const value = normalize(await extensionOrFallback(
2002
- "get_tab_receipt", {
2003
- ...(_receiptToken(args.receipt || "") ? { receipt: _receiptToken(args.receipt) } : {}),
2004
- },
2005
- () => { throw new Error("getReceipt requires the verified Safari extension"); }
2079
+ "get_tab_receipt", previous ? { receipt: previous } : {},
2080
+ () => {
2081
+ throw new Error("getReceipt: the Safari extension did not hand back a new receipt (the server log has its " +
2082
+ "reason; safari_doctor shows whether it is connected). AppleScript touched no tab.");
2083
+ }
2006
2084
  ));
2007
2085
  const safeValue = _sanitizeTabResult(value);
2008
2086
  // The rotated tab is now the current one. Its URL stays unknown in safari.js: a receipt
@@ -2051,7 +2129,6 @@ async function _runExtensionBatchAction(action, args = {}) {
2051
2129
  case "navigate": {
2052
2130
  const url = String(args.url || "");
2053
2131
  if (!url) throw new Error("navigate requires url");
2054
- _addOwnedURL(url);
2055
2132
  const raw = await extensionOrFallback(
2056
2133
  "navigate", { url, timeout: args.timeout },
2057
2134
  () => safari.navigate(url)
@@ -2062,7 +2139,6 @@ async function _runExtensionBatchAction(action, args = {}) {
2062
2139
  case "navigateAndRead": {
2063
2140
  const url = String(args.url || "");
2064
2141
  if (!url) throw new Error("navigateAndRead requires url");
2065
- _addOwnedURL(url);
2066
2142
  const raw = await extensionOrFallback(
2067
2143
  "navigate_and_read", { url, maxLength: args.maxLength, timeout: args.timeout },
2068
2144
  async () => { await safari.navigate(url); return safari.readPage({ maxLength: args.maxLength }); }
@@ -2214,8 +2290,8 @@ async function _runExtensionBatchAction(action, args = {}) {
2214
2290
  // Tab-ownership assertion — shared by extensionOrFallback AND the tools that bypass it
2215
2291
  // (safari_run_script, native_*). Throws if the operation would land on a tab this MCP
2216
2292
  // session didn't open. Read-only / tab-management ops (in _noOwnershipCheck) are exempt.
2217
- // Once any tab has been opened via new_tab, ALL subsequent page-mutating ops must target
2218
- // an owned tab — this is what prevents navigating/clicking in the user's tabs.
2293
+ // A session with no tab of its own may only read: without one, AppleScript acts on the
2294
+ // front document, the tab the user is looking at.
2219
2295
  function _assertTabOwnership(opType, extensionPayload = {}) {
2220
2296
  if (_noOwnershipCheck.has(opType)) return;
2221
2297
  // Closing is the one op the opt-in never unlocks (#92, condition 2). Adoption makes a
@@ -2232,26 +2308,36 @@ function _assertTabOwnership(opType, extensionPayload = {}) {
2232
2308
  // presented after a stateless reconnect; only the extension can validate its exact
2233
2309
  // tab binding, freshness, digest, and origin.
2234
2310
  if (_preferAppleScript && _receiptToken(extensionPayload.receipt || _getActiveReceipt())) return;
2311
+ // A rotation that names its receipt touches no page and never falls back to AppleScript, so the
2312
+ // extension alone judges it, as a profile does every receipt. Without a profile, the URL safari.js
2313
+ // holds can be where a redirect took the tab during an AppleScript load, which the session never
2314
+ // registered, or, after safari_navigate, the URL the tab left, which the navigation released: this
2315
+ // check refused the rotation on both.
2316
+ if (opType === "get_tab_receipt" && _receiptToken(extensionPayload.receipt)) return;
2235
2317
  const currentUrl = safari.getActiveTabURL();
2236
2318
  // An adopted tab (#92) is the session's target even though the session opened nothing.
2237
2319
  if (safari.isActiveTabAdopted()) {
2238
2320
  console.error(`[Safari MCP] "${opType}" on ${_safeUrlForOutput(currentUrl)} (user tab, opted-in)`);
2239
2321
  return;
2240
2322
  }
2241
- if (_ownedTabURLs.size === 0 && _openedTabs.size === 0) {
2242
- // No tabs opened yet — block everything except read-only ops
2243
- const msg = `⚠️ Tab safety: no tabs opened yet. Call safari_new_tab first before "${opType}".`;
2323
+ // Asked of THIS session: whether it opened, claimed or adopted a tab (safari.js records that per
2324
+ // session, for AppleScript's tabs and the extension's alike), or names one by the receipt passed
2325
+ // with this call — a session with no state of its own yet, such as a subagent or the same agent
2326
+ // after a reconnect. The extension applies the same rule on its own path (handleCommand in
2327
+ // extension/background.js). It was asked of _ownedTabURLs and _openedTabs, which hold every
2328
+ // session's tabs in this process, and through owned-tabs.json every process's, so a tab opened
2329
+ // anywhere on the machine let a session that had none write, into the user's tab.
2330
+ if (!_receiptToken(extensionPayload.receipt) && !safari.hasOwnedTab()) {
2331
+ const msg = `⚠️ Tab safety: refusing "${opType}" — this MCP session has no tab of its own, so it would act on the tab in front, which is the user's. Open one with safari_new_tab, or pass the receipt of a tab you opened, with the call or to safari_switch_tab (a receipt survives reconnects and restarts).`;
2244
2332
  console.error(`[Safari MCP] ${msg}`);
2245
2333
  throw new Error(msg);
2246
2334
  }
2247
- if (currentUrl && !_isURLOwned(currentUrl)) {
2248
- // about:blank tabs are owned if we have any tracked tabs (new_tab creates them at about:blank)
2249
- const isBlankOwned = (currentUrl === 'about:blank' || currentUrl === 'missing value') && (_openedTabs.size > 0 || _ownedTabURLs.has(BLANK_TAB_SENTINEL));
2250
- if (!isBlankOwned) {
2251
- const msg = `⚠️ Tab safety: refusing "${opType}" — current tab (${_safeUrlForOutput(currentUrl)}) was not opened by this MCP session. Use safari_new_tab or safari_switch_tab to target your own tab.`;
2252
- console.error(`[Safari MCP] ${msg}`);
2253
- throw new Error(msg);
2254
- }
2335
+ // A blank tab has no URL to own; the current tab of a session that has one is its own.
2336
+ const blank = currentUrl === 'about:blank' || currentUrl === 'missing value';
2337
+ if (currentUrl && !blank && !_isURLOwned(currentUrl)) {
2338
+ const msg = `⚠️ Tab safety: refusing "${opType}" — current tab (${_safeUrlForOutput(currentUrl)}) was not opened by this MCP session. Use safari_new_tab or safari_switch_tab to target your own tab.`;
2339
+ console.error(`[Safari MCP] ${msg}`);
2340
+ throw new Error(msg);
2255
2341
  }
2256
2342
  }
2257
2343
 
@@ -2261,6 +2347,11 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
2261
2347
  _refuseUnnamedTab(extensionType, extensionPayload);
2262
2348
  // Tab-ownership guard — extracted to _assertTabOwnership so run_script / native_* share it.
2263
2349
  _assertTabOwnership(extensionType, extensionPayload);
2350
+ // A navigation claims its destination before it starts, so a load that throws halfway still
2351
+ // leaves the tab reachable (safari_switch_tab recovery). Only once the guard has passed: the
2352
+ // callers used to claim it first, and the destination vouched for itself — on a clean machine
2353
+ // it was the one owned URL the guard looked for, and a refused call left it in owned-tabs.json.
2354
+ if (extensionType === "navigate" || extensionType === "navigate_and_read") _addOwnedURL(extensionPayload.url);
2264
2355
 
2265
2356
  // Safari may terminate an otherwise healthy profile worker between two tool
2266
2357
  // calls. If no command has been sent yet, waiting for its verified reconnect is
@@ -2346,8 +2437,10 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
2346
2437
  // the extension's tab-id proof and could switch to or mutate a user's tab.
2347
2438
  if (String(err?.message || err).includes("Tab safety:")) {
2348
2439
  if (/not valid for this origin/.test(err.message)) {
2440
+ // The receipt goes in "args": the tool's schema drops a key beside "action", and
2441
+ // getReceipt then rotated the session's current tab, or was refused after a reconnect.
2349
2442
  err.message += " Likely cause: the tab moved to another origin after the receipt was minted (redirect/login). " +
2350
- "Rotate it with safari_run_script [{\"action\":\"getReceipt\",\"receipt\":\"<old receipt>\"}], or reopen with safari_new_tab.";
2443
+ "Rotate it with safari_run_script [{\"action\":\"getReceipt\",\"args\":{\"receipt\":\"<old receipt>\"}}], or reopen with safari_new_tab.";
2351
2444
  }
2352
2445
  throw err;
2353
2446
  }
@@ -2460,11 +2553,7 @@ server.tool(
2460
2553
  throw new Error(`safari_navigate only opens http(s) URLs in a Safari profile ("${url.slice(0, 40)}" would leave the tab unreachable). To free the tab, use safari_close_tab.`);
2461
2554
  }
2462
2555
  const oldUrl = safari.getActiveTabURL();
2463
- // Pre-register the destination as owned BEFORE navigating. We are navigating OUR
2464
- // tab, so the target URL is ours even if navigate() throws mid-load on a slow SPA.
2465
- // Without this, a slow/failed navigate left the new URL unowned and locked the
2466
- // switch_tab recovery out — the very recovery the lock error tells you to use.
2467
- _addOwnedURL(url);
2556
+ // extensionOrFallback claims the destination for this session once its guard passes.
2468
2557
  let result = await extensionOrFallback(
2469
2558
  "navigate", { url, ..._explicitReceipt({ receipt }) },
2470
2559
  () => safari.navigate(url)
@@ -2477,7 +2566,7 @@ server.tool(
2477
2566
  // hand back the new receipt instead of letting the caller discover the trap.
2478
2567
  const landed = result && typeof result === "object" ? result.url : "";
2479
2568
  const usedReceipt = _receiptToken(receipt || _getActiveReceipt());
2480
- if (landed && _originOf(landed) !== _originOf(oldUrl) && usedReceipt) {
2569
+ if (landed && _originOf(landed) !== (_receiptOrigins.get(usedReceipt) || _originOf(oldUrl)) && usedReceipt) {
2481
2570
  try {
2482
2571
  const fresh = _sanitizeTabResult(await extensionOrFallback(
2483
2572
  "get_tab_receipt", { ..._explicitReceipt({ receipt }) }, () => null
@@ -2580,13 +2669,10 @@ server.tool(
2580
2669
  maxLength: z.coerce.number().optional().describe("Max chars to return (default: 50000)"),
2581
2670
  timeout: z.coerce.number().optional().describe("Load timeout in ms (default: 30000)"),
2582
2671
  },
2583
- async ({ url, maxLength, timeout }) => {
2672
+ async ({ url, maxLength, timeout, receipt }) => {
2584
2673
  const oldUrl = safari.getActiveTabURL();
2585
- // Pre-register destination as owned BEFORE navigating (see safari_navigate) so a
2586
- // slow/throwing navigate on a heavy SPA cannot lock switch_tab recovery out.
2587
- _addOwnedURL(url);
2588
2674
  const result = await extensionOrFallback(
2589
- "navigate_and_read", { url, maxLength, timeout },
2675
+ "navigate_and_read", { url, maxLength, timeout, ..._explicitReceipt({ receipt }) },
2590
2676
  async () => {
2591
2677
  await safari.navigate(url);
2592
2678
  return safari.readPage({ maxLength });
@@ -2848,8 +2934,11 @@ server.tool(
2848
2934
  value: z.string().describe("Option value or visible label to select"),
2849
2935
  },
2850
2936
  async (args) => {
2851
- // The engine calls below can bypass extensionOrFallback — assert ownership here.
2852
- _assertTabOwnership("select_option");
2937
+ // The receipt first: for a caller with no state of its own it is what names the tab, and the
2938
+ // guard has to see it. The engine calls below can bypass extensionOrFallback — assert
2939
+ // ownership here.
2940
+ const pin = _explicitReceipt(args);
2941
+ _assertTabOwnership("select_option", pin);
2853
2942
  // ref path: resolve via mcpFindRef (reaches iframes/shadow DOM) on the AppleScript
2854
2943
  // engine — the extension's select_option handler is selector-only.
2855
2944
  if (args.ref) {
@@ -2857,7 +2946,7 @@ server.tool(
2857
2946
  return { content: [{ type: "text", text: typeof refResult === 'string' ? refResult : JSON.stringify(refResult) }] };
2858
2947
  }
2859
2948
  let result = await extensionOrFallback(
2860
- "select_option", { selector: args.selector, value: args.value, ..._explicitReceipt(args) },
2949
+ "select_option", { selector: args.selector, value: args.value, ...pin },
2861
2950
  () => safari.selectOption(args)
2862
2951
  );
2863
2952
  // If extension returned "Selected: " with empty/default value, fuzzy match may have failed.
@@ -3112,7 +3201,7 @@ server.tool(
3112
3201
  let viaAppleScript = false;
3113
3202
  const rawResult = await extensionOrFallback(
3114
3203
  "new_tab", { url: requestedUrl },
3115
- () => { viaAppleScript = true; return safari.newTab(requestedUrl); }
3204
+ () => { viaAppleScript = true; return _openTabViaAppleScript(requestedUrl); }
3116
3205
  );
3117
3206
  // AppleScript fallback returns a JSON string; extension returns an object — normalize
3118
3207
  let result = rawResult;
@@ -3124,9 +3213,8 @@ server.tool(
3124
3213
  // remains internal and is never copied into the MCP response.
3125
3214
  if (safeResult?.tabIndex) {
3126
3215
  safari.setActiveTabIndex(safeResult.tabIndex);
3127
- // safari.js holds the marker of the last tab AppleScript opened, so it names THIS tab
3128
- // only when AppleScript just opened it.
3129
- _trackTab(safeResult.tabIndex, requestedUrl, mySession, viaAppleScript ? safari.getActiveTabMarker() : "", safeResult.receipt);
3216
+ // A tab AppleScript opened is tracked by _openTabViaAppleScript, by its marker.
3217
+ if (!viaAppleScript) _trackTab(safeResult.tabIndex, requestedUrl, mySession, "", safeResult.receipt);
3130
3218
  // The new tab is the current one, with or without a receipt.
3131
3219
  _setActiveReceipt(safeResult.receipt);
3132
3220
  }
@@ -3162,18 +3250,23 @@ server.tool(
3162
3250
  // tab's receipt, and the tab the caller was still working in closed instead of the one it named.
3163
3251
  const token = supplied ? _receiptToken(supplied) : _getActiveReceipt();
3164
3252
  if (supplied && !token) return errorResult("Tab safety: invalid tab receipt");
3165
- const closesCurrent = !token || token === _getActiveReceipt();
3166
- const marker = safari.getActiveTabMarker();
3253
+ let closesCurrent = false, marker = null;
3167
3254
  let viaAppleScript = false;
3168
- const result = await extensionOrFallback(
3169
- "close_tab",
3170
- token ? { receipt: token } : {},
3171
- () => {
3172
- if (token) throw _receiptNeedsExtension("close_tab");
3173
- viaAppleScript = true;
3174
- return safari.closeTab();
3175
- }
3176
- );
3255
+ // One close at a time (_oneCloseAtATime). Which tab is current is read when this close's turn
3256
+ // comes: a parallel call can switch tabs while it waits, and AppleScript closes the one current then.
3257
+ const result = await _oneCloseAtATime(() => {
3258
+ closesCurrent = !token || token === _getActiveReceipt();
3259
+ marker = safari.getActiveTabMarker();
3260
+ return extensionOrFallback(
3261
+ "close_tab",
3262
+ token ? { receipt: token } : {},
3263
+ () => {
3264
+ if (token) throw _receiptNeedsExtension("close_tab");
3265
+ viaAppleScript = true;
3266
+ return safari.closeTab();
3267
+ }
3268
+ );
3269
+ });
3177
3270
  // The extension closed the receipt's tab; AppleScript, the tab carrying the session's marker.
3178
3271
  _untrackClosedTab(viaAppleScript ? { marker } : { receipt: token });
3179
3272
  // Closing another tab by its receipt leaves the current one current.
@@ -3271,16 +3364,19 @@ server.tool(
3271
3364
  return claimed;
3272
3365
  }
3273
3366
  );
3274
- // Adopted just now, or earlier: switchTab() keeps an adopted tab's marker.
3367
+ // Adopted just now, or earlier: switchTab() keeps an adopted tab in the adoption family.
3275
3368
  const adopted = viaAppleScript && safari.isActiveTabAdopted();
3276
3369
  const safeResult = _sanitizeTabResult(result);
3277
3370
  // Sync safari.js state so AppleScript fallback targets the correct tab
3278
3371
  const resolvedIndex = safeResult?.tabIndex || index;
3279
3372
  if (resolvedIndex) safari.setActiveTabIndex(resolvedIndex);
3280
- if (safeResult?.safeUrl) safari.setActiveTabURL(safeResult.safeUrl);
3281
- // …including the marker, which still names the last tab AppleScript claimed.
3373
+ if (viaAppleScript && safeResult?.safeUrl) safari.setActiveTabURL(safeResult.safeUrl);
3374
+ // …including the marker, which still names the last tab AppleScript claimed. The URL is left
3375
+ // unknown, as run_script's switchTab leaves it: the extension reports where the tab is now,
3376
+ // which after a redirect or a login bounce is a page this session never registered, and the
3377
+ // ownership guard refused every write after it, though the extension had proven the tab by its id.
3282
3378
  if (!viaAppleScript && safeResult && typeof safeResult === "object") {
3283
- safari.setActiveTabFromExtension(resolvedIndex, safeResult.safeUrl);
3379
+ safari.setActiveTabFromExtension(resolvedIndex, null);
3284
3380
  }
3285
3381
  if (safeResult?.receipt || token) _setActiveReceipt(safeResult?.receipt || token);
3286
3382
  // A switch by INDEX gets no receipt back, and the session was still holding the one it
@@ -3326,39 +3422,50 @@ server.tool(
3326
3422
  },
3327
3423
  async ({ timeout, urlContains }) => {
3328
3424
  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.
3425
+ // Every listing says where it came from. AppleScript lists one window by id (`win`): the one
3426
+ // this session's own tab is in, proven by its marker when AppleScript first lists, and a claim
3427
+ // through AppleScript goes to that window. Listings of whichever window was in front made a
3428
+ // tab of the user's other window look new, and the claim stamped the session's own marker on
3429
+ // it. A listing from the other source starts the comparison over instead of comparing tabs
3430
+ // across them.
3334
3431
  let win = null;
3335
3432
  const list = async () => {
3336
3433
  let via = "extension";
3337
3434
  const raw = await extensionOrFallback("list_tabs", {}, async () => {
3338
3435
  via = "applescript";
3436
+ if (!win) win = await safari.sessionTabWindow();
3339
3437
  const listed = await safari.listWindowTabs(win);
3340
3438
  win = listed.win;
3341
3439
  return listed.tabs;
3342
3440
  });
3343
3441
  return { via, tabs: typeof raw === "string" ? JSON.parse(raw) : raw };
3344
3442
  };
3443
+ // A listed tab's URL: AppleScript lists `url`, the extension `safeUrl` (origin and path).
3444
+ const urlOf = (t) => t.url ?? t.safeUrl;
3345
3445
  // Switch to the new tab and own it for THIS session, else the next interaction trips the
3346
3446
  // tab-safety guard. Its marker names it only if AppleScript made the switch (and stamped
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.
3447
+ // it); the extension answers with the tab's receipt instead. A tab AppleScript listed is
3448
+ // claimed through AppleScript, in the pinned window, at once: its index is a position there,
3449
+ // not in the extension's window, and waiting out the extension's switch_tab first (30 s
3450
+ // while its worker is busy) left time for another tab to slide under that index. AppleScript
3451
+ // never claims a tab only the extension's listing saw.
3350
3452
  const adopt = async (t, via) => {
3351
- let viaAppleScript = false;
3352
- const switched = _sanitizeTabResult(await extensionOrFallback(
3353
- "switch_tab", { index: 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 });
3453
+ const viaAppleScript = via === "applescript";
3454
+ let switched;
3455
+ if (viaAppleScript) {
3456
+ if (!win) throw new Error("Tab safety: AppleScript did not say which window it listed, so it cannot tell which tab opened, and this session claimed none. The tab stays open, and another safari_wait_for_new_tab will not report it: that call starts from a listing that already has it.");
3457
+ switched = _sanitizeTabResult(await safari.switchTab(t.index, { claim: true, win, expectUrl: t.url }));
3458
+ } else {
3459
+ switched = _sanitizeTabResult(await extensionOrFallback("switch_tab", { index: t.index }, () => {
3460
+ throw new Error("Tab safety: a new tab opened, but the Safari extension could not switch to it and AppleScript cannot tell which tab of its own window that is, so this session did not claim it. The tab stays open, and another safari_wait_for_new_tab will not report it: that call starts from a listing that already has it. Check the extension with safari_doctor.");
3461
+ }));
3462
+ // The extension switches to whatever tab sits at the index when it runs the command, and
3463
+ // answers "Tab not found" as if it had switched: a tab that closed or moved in between
3464
+ // handed the session another tab of its own, or none, under the new tab's name.
3465
+ if (switched?.safeUrl !== _safeUrlForOutput(urlOf(t))) {
3466
+ throw Object.assign(new Error("Tab safety: the Safari extension switched to another tab than the one the listing saw (a tab closed or moved in between), so this session did not claim it."), { moved: true });
3360
3467
  }
3361
- ));
3468
+ }
3362
3469
  safari.setActiveTabIndex(t.index);
3363
3470
  safari.setActiveTabURL(t.url);
3364
3471
  if (!viaAppleScript) safari.setActiveTabFromExtension(t.index, t.url);
@@ -3369,49 +3476,102 @@ server.tool(
3369
3476
  _trackTab(t.index, t.url, `${SESSION_ID}:${currentSessionId()}`, viaAppleScript ? safari.getActiveTabMarker() : "", switched?.receipt);
3370
3477
  }
3371
3478
  _setActiveReceipt(switched?.receipt);
3372
- return { content: [{ type: "text", text: `Found new tab: ${t.title} (${t.url})` }] };
3479
+ return { content: [{ type: "text", text: `Found new tab: ${t.title} (${urlOf(t)})` }] };
3480
+ };
3481
+ // Whether every tab of `was` is still there on its URL, in order, in `is`: only then can a tab
3482
+ // `is` added be told from the old ones. The first `index:url` pair not seen before was often a
3483
+ // tab of the user's that had navigated, or had moved when another closed, and the claim stamped
3484
+ // the session's own marker on it.
3485
+ // ponytail: tabs are told apart by URL alone (AppleScript has no tab id and no opener), so any
3486
+ // tab the user opens in the window during the wait counts as new, a new tab that opens on the
3487
+ // very URL a tab of the user's leaves in the same poll can pass for it (as can a tab of the
3488
+ // user's that lands on the URL of a new tab closing next to it, while another tab opens further
3489
+ // along, which leaves the same listings as that new tab staying put), and the claim stamps
3490
+ // whatever tab sits at the listed index a script later unless switchTab checks `expectUrl`.
3491
+ const intact = (was, is) => {
3492
+ const seen = new Set(was.map(urlOf));
3493
+ const kept = is.filter((t) => seen.has(urlOf(t)));
3494
+ return kept.length === was.length && kept.every((t, i) => urlOf(t) === urlOf(was[i]));
3495
+ };
3496
+ // Where the new tabs `last` showed are in `is`: each still on its URL, shown by no more and no
3497
+ // fewer tabs than before, and in its place while the other tabs only navigated (as many tabs as
3498
+ // before), or still there while the others only closed (the rest on their URLs, in order) and
3499
+ // none next to it. A tab that went next to a new one could instead have landed on its URL as the
3500
+ // new one went, and a tab that joins it on its URL could be taken for it; a neighbour counts as
3501
+ // still there only while as many tabs as before show its URL, or a twin could stand in for it.
3502
+ // Null when unsure.
3503
+ const stillNew = (last, urls, is) => {
3504
+ const count = (tabs, u) => tabs.filter((t) => urlOf(t) === u).length;
3505
+ const at = (tabs, u) => tabs.findIndex((t) => urlOf(t) === u);
3506
+ if (urls.some((u) => count(is, u) !== count(last, u))) return null;
3507
+ if (is.length === last.length) return urls.some((u) => at(is, u) !== at(last, u)) ? null : urls.map((u) => is[at(is, u)]);
3508
+ const there = new Set();
3509
+ let j = 0;
3510
+ last.forEach((t, k) => { if (j < is.length && urlOf(is[j]) === urlOf(t)) { there.add(k); j++; } });
3511
+ const stays = (k) => count(is, urlOf(last[k])) === count(last, urlOf(last[k]));
3512
+ const neighbours = (u) => [at(last, u) - 1, at(last, u) + 1].every((k) => k < 0 || k >= last.length || (there.has(k) && stays(k)));
3513
+ return j === is.length && urls.every(neighbours) ? urls.map((u) => is[at(is, u)]) : null;
3373
3514
  };
3374
3515
  // Get current tab list
3375
3516
  let before = await list();
3376
- let beforeIds = new Set(before.tabs.map(t => `${t.index}:${t.url}`));
3377
-
3378
- // Poll for new tab — detect by count increase + new entries (handles about:blank tabs)
3517
+ // Every URL a baseline showed. A new tab is one on a URL none of them showed, so a tab that
3518
+ // left the window and came back (reopened, or dragged out and in again) is not new either.
3519
+ const shown = new Set(before.tabs.map(urlOf));
3520
+ // The new tabs not claimed yet (still on about:blank, not matching urlContains, or refused),
3521
+ // by URL, and the listing that showed them; and whether any tab opened at all.
3522
+ let pending = { last: before.tabs, urls: [] };
3523
+ let opened = false;
3524
+
3525
+ // Poll for new tab — detect by count increase + tabs on new URLs
3379
3526
  const deadline = Date.now() + timeoutMs;
3380
3527
  while (Date.now() < deadline) {
3381
3528
  await new Promise(r => setTimeout(r, 500));
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}`));
3529
+ const now = await list().catch((err) => {
3530
+ // A listing that fails (the window pin refused, AppleScript failed) takes the wait's record
3531
+ // of a tab that opened with it: say so, as the timeout would.
3532
+ if (opened && err instanceof Error) err.message += " A tab opened during the wait; if it is still open, another safari_wait_for_new_tab will not report it.";
3533
+ throw err;
3534
+ });
3535
+ // A listing where a tab closed or navigated becomes the baseline, so a tab of the user's that
3536
+ // navigated meanwhile is not new once one opens; so does one from the other source. A new
3537
+ // tab not claimed yet stays out of it while the other tabs only navigated in place or only
3538
+ // closed: taken into it, it was never new again, and the wait timed out on a popup that
3539
+ // was still loading when another tab changed. Any other change, or one as a tab opened,
3540
+ // takes every tab in: held against the older baseline, whichever of two tabs that changed
3541
+ // navigated first looked new, the user's too.
3542
+ if (now.via !== before.via || !intact(before.tabs, now.tabs)) {
3543
+ const same = now.via === before.via;
3544
+ const kept = !pending.urls.length ? [] : same ? stillNew(pending.last, pending.urls, now.tabs) : null;
3545
+ // Only AppleScript lists one window by id: the extension can list whichever window is in front.
3546
+ if (same && now.via === "applescript" && now.tabs.length > before.tabs.length) opened = true;
3547
+ before = { ...now, tabs: now.tabs.filter((t) => !kept?.includes(t)) };
3548
+ for (const t of before.tabs) shown.add(urlOf(t));
3549
+ pending = { last: now.tabs, urls: kept ? pending.urls : [] };
3386
3550
  continue;
3387
3551
  }
3388
- if (now.tabs.length > before.tabs.length) {
3389
- // Find the new tab(s) — could be about:blank initially (OAuth popups)
3390
- for (const tab of now.tabs) {
3391
- if (!beforeIds.has(`${tab.index}:${tab.url}`)) {
3392
- // Wait for about:blank to resolve to actual URL — dynamic polling instead of fixed delay
3393
- if (tab.url === 'about:blank') {
3394
- let resolved = null;
3395
- for (let attempt = 0; attempt < 10; attempt++) {
3396
- await new Promise(r => setTimeout(r, 300)); // 300ms intervals, max 3s total
3397
- const refreshed = await list();
3398
- resolved = refreshed.via === now.via ? refreshed.tabs.find(t => t.index === tab.index) : null;
3399
- if (resolved && resolved.url !== 'about:blank') break;
3400
- resolved = null;
3401
- }
3402
- if (resolved && resolved.url !== 'about:blank') {
3403
- if (urlContains && !resolved.url.includes(urlContains)) continue;
3404
- return await adopt(resolved, now.via);
3405
- }
3406
- continue;
3407
- }
3408
- if (urlContains && !tab.url.includes(urlContains)) continue;
3409
- return await adopt(tab, now.via);
3410
- }
3552
+ const old = new Set(before.tabs.map(urlOf));
3553
+ pending = { last: now.tabs, urls: now.tabs.map(urlOf).filter((u) => !old.has(u)) };
3554
+ if (pending.urls.length) opened = true;
3555
+ for (const tab of now.tabs) {
3556
+ // A new tab still on about:blank (an OAuth popup before it loads) is claimed at a later
3557
+ // poll, once it shows its URL: a later listing found it again by index, and by then that
3558
+ // could be another tab.
3559
+ if (urlOf(tab) === 'about:blank' || shown.has(urlOf(tab))) continue;
3560
+ if (urlContains && !String(urlOf(tab)).includes(urlContains)) continue;
3561
+ try {
3562
+ return await adopt(tab, now.via);
3563
+ } catch (err) {
3564
+ // A tab another session opened in this window meanwhile, whose marker the claim refuses
3565
+ // to overwrite (`otherSession`), or a tab that no longer shows the page the listing saw
3566
+ // (`moved`: one closed in between and slid another under the index). Neither is the
3567
+ // tab this waits for, and a later listing can still show it.
3568
+ if (!err?.otherSession && !err?.moved) throw err;
3411
3569
  }
3412
3570
  }
3413
3571
  }
3414
- return { content: [{ type: "text", text: "TIMEOUT: no new tab appeared" }] };
3572
+ return { content: [{ type: "text", text: opened
3573
+ ? "TIMEOUT: a tab opened during the wait, but none this call could claim: it could not be told apart from a tab that changed at the same time, urlContains ruled it out, or its claim was refused. If it is still open, another safari_wait_for_new_tab will not report it: open it again during a new wait, or ask the user."
3574
+ : "TIMEOUT: no new tab appeared" }] };
3415
3575
  }
3416
3576
  );
3417
3577
 
@@ -3689,7 +3849,7 @@ server.tool(
3689
3849
 
3690
3850
  server.tool(
3691
3851
  "safari_run_script",
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.",
3852
+ "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 plus getReceipt; there, closeTab and switchTab take a tab index, not a receipt, and getReceipt needs the tab's receipt in args. After a cross-origin redirect, rotate the tab's receipt with {\"action\":\"getReceipt\",\"args\":{\"receipt\":\"<old receipt>\"}}.",
3693
3853
  {
3694
3854
  steps: z.array(z.object({
3695
3855
  action: z.string().describe("Action name (e.g. 'navigate', 'click', 'fill')"),
@@ -3702,15 +3862,16 @@ server.tool(
3702
3862
  // AppleScript and loses the verified profile/tab-id boundary; they use the
3703
3863
  // extension-only dispatcher below.
3704
3864
  if (!process.env.SAFARI_PROFILE) {
3865
+ const mySession = `${SESSION_ID}:${currentSessionId()}`;
3705
3866
  const onStep = (action, stepArgs) => {
3706
- if (action === "newTab") { _markBlankTabOpened(); return; }
3867
+ // getReceipt is guarded in extensionOrFallback, which sees the receipt the step names and leaves
3868
+ // a rotation that names one to the extension. The check below is given no payload, so it would not
3869
+ // see that receipt and would judge the URL safari.js holds: after a redirect during an AppleScript
3870
+ // load, one the session never registered.
3871
+ if (action === "getReceipt") return;
3707
3872
  if (action === "navigate" || action === "navigateAndRead") {
3708
3873
  _assertTabOwnership(`run_script:${action}`);
3709
- const url = stepArgs && typeof stepArgs.url === "string" ? stepArgs.url : null;
3710
- if (url) {
3711
- _addOwnedURL(url);
3712
- if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) _addOwnedURL("https://" + url);
3713
- }
3874
+ _claimURL(stepArgs?.url);
3714
3875
  return;
3715
3876
  }
3716
3877
  // These steps run through AppleScript here, which cannot tell which tab a receipt names:
@@ -3747,7 +3908,40 @@ server.tool(
3747
3908
  return _runExtensionBatchAction("switchTab", { index: Number(index) });
3748
3909
  }
3749
3910
  };
3750
- return textResult(await safari.runScript({ steps, onStep, actions: { switchTab } }));
3911
+ // A newTab step opens its tab as safari_new_tab does: under the session's tab cap, which first
3912
+ // closes the session's oldest tab when it already has MAX_TABS, and as one of the session's
3913
+ // own tabs, claimed and tracked (_openTabViaAppleScript). It used to claim nothing, so every
3914
+ // write step after newTab{url} was refused on the session's own tab, and to track nothing, so
3915
+ // a batch's tabs escaped the cap, shutdown cleanup and the memory sweep.
3916
+ const newTab = async ({ url }) => {
3917
+ const evicted = await _evictOldestTab(mySession);
3918
+ const result = await _openTabViaAppleScript(String(url || ""));
3919
+ if (!evicted) return result;
3920
+ // Name the tab the cap closed, as safari_new_tab does.
3921
+ let tab = {};
3922
+ try { tab = JSON.parse(result); } catch {}
3923
+ return { ...tab, ..._evictionReport(evicted) };
3924
+ };
3925
+ // A closeTab step forgets the tab it closed, as safari_close_tab does after an AppleScript
3926
+ // close: closeOwnTab() closes only the tab that carries the session's marker. One close at a
3927
+ // time (_oneCloseAtATime).
3928
+ const closeTab = ({ index }) => _oneCloseAtATime(async () => {
3929
+ const marker = safari.getActiveTabMarker();
3930
+ const result = await safari.closeOwnTab(index);
3931
+ _untrackClosedTab({ marker });
3932
+ return result;
3933
+ });
3934
+ // Only the extension can rotate a receipt, and the legacy action table had no getReceipt, so
3935
+ // the step the origin refusal advises came back "Unknown action" and the tab stayed stranded.
3936
+ // It has to name its tab here: this batch's AppleScript steps (newTab, switchTab) move to a
3937
+ // tab without touching the session's receipt, which then names a tab the batch has left.
3938
+ const getReceipt = (stepArgs) => {
3939
+ if (!stepArgs?.receipt) {
3940
+ throw new Error("Tab safety: without SAFARI_PROFILE, getReceipt needs the receipt of the tab it rotates, in args.receipt.");
3941
+ }
3942
+ return _runExtensionBatchAction("getReceipt", stepArgs);
3943
+ };
3944
+ return textResult(await safari.runScript({ steps, onStep, actions: { switchTab, newTab, closeTab, getReceipt } }));
3751
3945
  }
3752
3946
 
3753
3947
  // Keep every step in one MCP request/session and route it through