safari-mcp 2.22.5 → 2.22.7
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/index.js +332 -122
- package/ownership-state.js +31 -12
- package/package.json +1 -1
- 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
|
}
|
|
@@ -1808,13 +1861,34 @@ function _safeUrlForOutput(rawUrl) {
|
|
|
1808
1861
|
// Receipts this daemon rotated on the caller's behalf (a cross-origin safari_navigate) stay
|
|
1809
1862
|
// usable under their old name: callers routinely keep the first receipt they saw and ignore
|
|
1810
1863
|
// the fresh one in the navigate result (nine consecutive "not valid for this origin" failures
|
|
1811
|
-
// in one session on 4.9.26). Whoever held the old capability held the tab.
|
|
1864
|
+
// in one session on 4.9.26). Whoever held the old capability held the tab. Every session that
|
|
1865
|
+
// held it follows the alias, other clients of an HTTP daemon included (_getActiveReceipt), so an
|
|
1866
|
+
// alias may only join two receipts of one tab: each rotation names the receipt it replaces.
|
|
1812
1867
|
const _receiptAliases = new Map(); // old token → newer token
|
|
1813
1868
|
function _aliasReceipt(oldToken, newToken) {
|
|
1814
1869
|
if (!oldToken || !newToken || oldToken === newToken) return;
|
|
1870
|
+
// Every older name of the tab points straight at its newest receipt. As a chain of one link per
|
|
1871
|
+
// rotation, a receipt stopped resolving after eight (_receiptToken's hop limit): a handful of
|
|
1872
|
+
// cross-origin navigations in one tab.
|
|
1873
|
+
for (const [older, newer] of _receiptAliases) if (newer === oldToken) _receiptAliases.set(older, newToken);
|
|
1815
1874
|
_receiptAliases.set(oldToken, newToken);
|
|
1816
1875
|
if (_receiptAliases.size > 500) _receiptAliases.delete(_receiptAliases.keys().next().value);
|
|
1817
1876
|
}
|
|
1877
|
+
|
|
1878
|
+
// The origin a receipt is valid on: the page the extension reported with it (_sanitizeTabResult).
|
|
1879
|
+
// safari_navigate rotates a receipt only when the tab lands off that origin. Judged by the URL
|
|
1880
|
+
// safari.js holds instead (another tab's when the call names its tab by receipt, unknown after a
|
|
1881
|
+
// switch the extension served, stale after a navigation it served), a navigate that stayed on the
|
|
1882
|
+
// receipt's origin counted as a change and retired the receipt the caller kept.
|
|
1883
|
+
const _receiptOrigins = new Map(); // token → origin
|
|
1884
|
+
function _noteReceiptOrigin(receipt, url) {
|
|
1885
|
+
const token = _receiptToken(receipt);
|
|
1886
|
+
const origin = _originOf(url);
|
|
1887
|
+
if (!token || !origin) return;
|
|
1888
|
+
_receiptOrigins.set(token, origin);
|
|
1889
|
+
if (_receiptOrigins.size > 500) _receiptOrigins.delete(_receiptOrigins.keys().next().value);
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1818
1892
|
function _receiptToken(value) {
|
|
1819
1893
|
const raw = String(value || "");
|
|
1820
1894
|
let token = "";
|
|
@@ -1852,8 +1926,10 @@ const _activeReceipts = new Map();
|
|
|
1852
1926
|
function _receiptSessionKey() {
|
|
1853
1927
|
return `${SESSION_ID}:${currentSessionId()}`;
|
|
1854
1928
|
}
|
|
1929
|
+
// Resolved on every read: another client of an HTTP daemon can rotate this session's receipt
|
|
1930
|
+
// after it was stored, and the extension then knows only the new one.
|
|
1855
1931
|
function _getActiveReceipt() {
|
|
1856
|
-
return _activeReceipts.get(_receiptSessionKey())
|
|
1932
|
+
return _receiptToken(_activeReceipts.get(_receiptSessionKey()));
|
|
1857
1933
|
}
|
|
1858
1934
|
function _setActiveReceipt(receipt) {
|
|
1859
1935
|
_activeReceipts.set(_receiptSessionKey(), _receiptToken(receipt));
|
|
@@ -1890,6 +1966,8 @@ function _sanitizeTabResult(value) {
|
|
|
1890
1966
|
if (!normalized || typeof normalized !== "object") return normalized;
|
|
1891
1967
|
const safeUrl = _safeUrlForOutput(normalized.safeUrl || normalized.url || normalized.requestedUrl || "");
|
|
1892
1968
|
const receipt = _receiptToken(normalized.receipt || normalized.receiptUrl || "");
|
|
1969
|
+
// The extension hands every receipt out here, with the page it is valid on.
|
|
1970
|
+
_noteReceiptOrigin(receipt, safeUrl);
|
|
1893
1971
|
return {
|
|
1894
1972
|
...(normalized.index !== undefined ? { index: normalized.index } : {}),
|
|
1895
1973
|
...(normalized.tabIndex !== undefined ? { tabIndex: normalized.tabIndex } : {}),
|
|
@@ -1997,12 +2075,20 @@ async function _runExtensionBatchAction(action, args = {}) {
|
|
|
1997
2075
|
return _sanitizeTabResult(await extensionOrFallback("list_tabs", {}, () => safari.listTabs()));
|
|
1998
2076
|
|
|
1999
2077
|
case "getReceipt": {
|
|
2000
|
-
|
|
2078
|
+
// A receipt that is not one (a token cut short, the advice's "<old receipt>" left in) fell
|
|
2079
|
+
// through to the current tab's receipt, and the step rotated that tab instead.
|
|
2080
|
+
if (args.receipt && !_receiptToken(args.receipt)) {
|
|
2081
|
+
throw new Error("Tab safety: getReceipt requires an extension-issued receipt");
|
|
2082
|
+
}
|
|
2083
|
+
const previous = _receiptToken(args.receipt || _getActiveReceipt());
|
|
2084
|
+
// The payload names the receipt aliased below. extensionOrFallback would otherwise attach the
|
|
2085
|
+
// session's receipt as it stands after its own awaits, which a call running alongside can change.
|
|
2001
2086
|
const value = normalize(await extensionOrFallback(
|
|
2002
|
-
"get_tab_receipt", {
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2087
|
+
"get_tab_receipt", previous ? { receipt: previous } : {},
|
|
2088
|
+
() => {
|
|
2089
|
+
throw new Error("getReceipt: the Safari extension did not hand back a new receipt (the server log has its " +
|
|
2090
|
+
"reason; safari_doctor shows whether it is connected). AppleScript touched no tab.");
|
|
2091
|
+
}
|
|
2006
2092
|
));
|
|
2007
2093
|
const safeValue = _sanitizeTabResult(value);
|
|
2008
2094
|
// The rotated tab is now the current one. Its URL stays unknown in safari.js: a receipt
|
|
@@ -2011,7 +2097,7 @@ async function _runExtensionBatchAction(action, args = {}) {
|
|
|
2011
2097
|
if (safeValue?.receipt) {
|
|
2012
2098
|
// Rotation retires the old token. Keep it resolving to the new one, as navigate does,
|
|
2013
2099
|
// so the tab stays reachable — and closable by the tab cap — by the name it was opened with.
|
|
2014
|
-
_aliasReceipt(previous, safeValue.receipt);
|
|
2100
|
+
_aliasReceipt(_receiptToken(previous), safeValue.receipt); // the receipt extensionOrFallback sent
|
|
2015
2101
|
_setActiveReceipt(safeValue.receipt);
|
|
2016
2102
|
}
|
|
2017
2103
|
return safeValue;
|
|
@@ -2051,7 +2137,6 @@ async function _runExtensionBatchAction(action, args = {}) {
|
|
|
2051
2137
|
case "navigate": {
|
|
2052
2138
|
const url = String(args.url || "");
|
|
2053
2139
|
if (!url) throw new Error("navigate requires url");
|
|
2054
|
-
_addOwnedURL(url);
|
|
2055
2140
|
const raw = await extensionOrFallback(
|
|
2056
2141
|
"navigate", { url, timeout: args.timeout },
|
|
2057
2142
|
() => safari.navigate(url)
|
|
@@ -2062,7 +2147,6 @@ async function _runExtensionBatchAction(action, args = {}) {
|
|
|
2062
2147
|
case "navigateAndRead": {
|
|
2063
2148
|
const url = String(args.url || "");
|
|
2064
2149
|
if (!url) throw new Error("navigateAndRead requires url");
|
|
2065
|
-
_addOwnedURL(url);
|
|
2066
2150
|
const raw = await extensionOrFallback(
|
|
2067
2151
|
"navigate_and_read", { url, maxLength: args.maxLength, timeout: args.timeout },
|
|
2068
2152
|
async () => { await safari.navigate(url); return safari.readPage({ maxLength: args.maxLength }); }
|
|
@@ -2214,8 +2298,8 @@ async function _runExtensionBatchAction(action, args = {}) {
|
|
|
2214
2298
|
// Tab-ownership assertion — shared by extensionOrFallback AND the tools that bypass it
|
|
2215
2299
|
// (safari_run_script, native_*). Throws if the operation would land on a tab this MCP
|
|
2216
2300
|
// session didn't open. Read-only / tab-management ops (in _noOwnershipCheck) are exempt.
|
|
2217
|
-
//
|
|
2218
|
-
//
|
|
2301
|
+
// A session with no tab of its own may only read: without one, AppleScript acts on the
|
|
2302
|
+
// front document, the tab the user is looking at.
|
|
2219
2303
|
function _assertTabOwnership(opType, extensionPayload = {}) {
|
|
2220
2304
|
if (_noOwnershipCheck.has(opType)) return;
|
|
2221
2305
|
// Closing is the one op the opt-in never unlocks (#92, condition 2). Adoption makes a
|
|
@@ -2232,26 +2316,36 @@ function _assertTabOwnership(opType, extensionPayload = {}) {
|
|
|
2232
2316
|
// presented after a stateless reconnect; only the extension can validate its exact
|
|
2233
2317
|
// tab binding, freshness, digest, and origin.
|
|
2234
2318
|
if (_preferAppleScript && _receiptToken(extensionPayload.receipt || _getActiveReceipt())) return;
|
|
2319
|
+
// A rotation that names its receipt touches no page and never falls back to AppleScript, so the
|
|
2320
|
+
// extension alone judges it, as a profile does every receipt. Without a profile, the URL safari.js
|
|
2321
|
+
// holds can be where a redirect took the tab during an AppleScript load, which the session never
|
|
2322
|
+
// registered, or, after safari_navigate, the URL the tab left, which the navigation released: this
|
|
2323
|
+
// check refused the rotation on both.
|
|
2324
|
+
if (opType === "get_tab_receipt" && _receiptToken(extensionPayload.receipt)) return;
|
|
2235
2325
|
const currentUrl = safari.getActiveTabURL();
|
|
2236
2326
|
// An adopted tab (#92) is the session's target even though the session opened nothing.
|
|
2237
2327
|
if (safari.isActiveTabAdopted()) {
|
|
2238
2328
|
console.error(`[Safari MCP] "${opType}" on ${_safeUrlForOutput(currentUrl)} (user tab, opted-in)`);
|
|
2239
2329
|
return;
|
|
2240
2330
|
}
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2331
|
+
// Asked of THIS session: whether it opened, claimed or adopted a tab (safari.js records that per
|
|
2332
|
+
// session, for AppleScript's tabs and the extension's alike), or names one by the receipt passed
|
|
2333
|
+
// with this call — a session with no state of its own yet, such as a subagent or the same agent
|
|
2334
|
+
// after a reconnect. The extension applies the same rule on its own path (handleCommand in
|
|
2335
|
+
// extension/background.js). It was asked of _ownedTabURLs and _openedTabs, which hold every
|
|
2336
|
+
// session's tabs in this process, and through owned-tabs.json every process's, so a tab opened
|
|
2337
|
+
// anywhere on the machine let a session that had none write, into the user's tab.
|
|
2338
|
+
if (!_receiptToken(extensionPayload.receipt) && !safari.hasOwnedTab()) {
|
|
2339
|
+
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
2340
|
console.error(`[Safari MCP] ${msg}`);
|
|
2245
2341
|
throw new Error(msg);
|
|
2246
2342
|
}
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
throw new Error(msg);
|
|
2254
|
-
}
|
|
2343
|
+
// A blank tab has no URL to own; the current tab of a session that has one is its own.
|
|
2344
|
+
const blank = currentUrl === 'about:blank' || currentUrl === 'missing value';
|
|
2345
|
+
if (currentUrl && !blank && !_isURLOwned(currentUrl)) {
|
|
2346
|
+
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.`;
|
|
2347
|
+
console.error(`[Safari MCP] ${msg}`);
|
|
2348
|
+
throw new Error(msg);
|
|
2255
2349
|
}
|
|
2256
2350
|
}
|
|
2257
2351
|
|
|
@@ -2261,6 +2355,11 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
|
|
|
2261
2355
|
_refuseUnnamedTab(extensionType, extensionPayload);
|
|
2262
2356
|
// Tab-ownership guard — extracted to _assertTabOwnership so run_script / native_* share it.
|
|
2263
2357
|
_assertTabOwnership(extensionType, extensionPayload);
|
|
2358
|
+
// A navigation claims its destination before it starts, so a load that throws halfway still
|
|
2359
|
+
// leaves the tab reachable (safari_switch_tab recovery). Only once the guard has passed: the
|
|
2360
|
+
// callers used to claim it first, and the destination vouched for itself — on a clean machine
|
|
2361
|
+
// it was the one owned URL the guard looked for, and a refused call left it in owned-tabs.json.
|
|
2362
|
+
if (extensionType === "navigate" || extensionType === "navigate_and_read") _addOwnedURL(extensionPayload.url);
|
|
2264
2363
|
|
|
2265
2364
|
// Safari may terminate an otherwise healthy profile worker between two tool
|
|
2266
2365
|
// calls. If no command has been sent yet, waiting for its verified reconnect is
|
|
@@ -2297,9 +2396,13 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
|
|
|
2297
2396
|
const reloadHandoff = extensionType === "reload_extension" && _isExtensionHost
|
|
2298
2397
|
? _prepareReloadHttpWorkerHandoff()
|
|
2299
2398
|
: null;
|
|
2399
|
+
const namedReceipt = _receiptToken(extensionPayload.receipt);
|
|
2300
2400
|
const payload = {
|
|
2301
2401
|
...(attachActiveReceipt && activeReceipt ? { receipt: activeReceipt } : {}),
|
|
2302
2402
|
...extensionPayload,
|
|
2403
|
+
// A receipt named when the call started follows a rotation of its tab made while the call
|
|
2404
|
+
// waited above (by another client of an HTTP daemon): an alias joins receipts of one tab.
|
|
2405
|
+
...(namedReceipt ? { receipt: namedReceipt } : {}),
|
|
2303
2406
|
...(reloadHandoff ? { reloadHandoff: reloadHandoff.token } : {}),
|
|
2304
2407
|
sessionId: `${SESSION_ID}:${currentSessionId()}`,
|
|
2305
2408
|
};
|
|
@@ -2346,8 +2449,10 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
|
|
|
2346
2449
|
// the extension's tab-id proof and could switch to or mutate a user's tab.
|
|
2347
2450
|
if (String(err?.message || err).includes("Tab safety:")) {
|
|
2348
2451
|
if (/not valid for this origin/.test(err.message)) {
|
|
2452
|
+
// The receipt goes in "args": the tool's schema drops a key beside "action", and
|
|
2453
|
+
// getReceipt then rotated the session's current tab, or was refused after a reconnect.
|
|
2349
2454
|
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.";
|
|
2455
|
+
"Rotate it with safari_run_script [{\"action\":\"getReceipt\",\"args\":{\"receipt\":\"<old receipt>\"}}], or reopen with safari_new_tab.";
|
|
2351
2456
|
}
|
|
2352
2457
|
throw err;
|
|
2353
2458
|
}
|
|
@@ -2460,13 +2565,12 @@ server.tool(
|
|
|
2460
2565
|
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
2566
|
}
|
|
2462
2567
|
const oldUrl = safari.getActiveTabURL();
|
|
2463
|
-
//
|
|
2464
|
-
//
|
|
2465
|
-
//
|
|
2466
|
-
|
|
2467
|
-
_addOwnedURL(url);
|
|
2568
|
+
// extensionOrFallback claims the destination for this session once its guard passes.
|
|
2569
|
+
// Read once, before anything waits: a call alongside that names another tab makes that tab the
|
|
2570
|
+
// session's current one, and the rotation below has to name the tab this call navigated.
|
|
2571
|
+
const usedReceipt = _receiptToken(receipt || _getActiveReceipt());
|
|
2468
2572
|
let result = await extensionOrFallback(
|
|
2469
|
-
"navigate", { url, ..._explicitReceipt({ receipt }) },
|
|
2573
|
+
"navigate", { url, ..._explicitReceipt({ receipt: usedReceipt }) },
|
|
2470
2574
|
() => safari.navigate(url)
|
|
2471
2575
|
);
|
|
2472
2576
|
// Tab kept its identity, just changed URL — drop the stale old URL from ownership.
|
|
@@ -2476,14 +2580,15 @@ server.tool(
|
|
|
2476
2580
|
// origin", ~80×/week). The caller chose the destination, so rotating here is safe —
|
|
2477
2581
|
// hand back the new receipt instead of letting the caller discover the trap.
|
|
2478
2582
|
const landed = result && typeof result === "object" ? result.url : "";
|
|
2479
|
-
|
|
2480
|
-
if (landed && _originOf(landed) !== _originOf(oldUrl) && usedReceipt) {
|
|
2583
|
+
// The origin of the receipt the navigation carried: extensionOrFallback sends the newest one.
|
|
2584
|
+
if (landed && _originOf(landed) !== (_receiptOrigins.get(_receiptToken(usedReceipt)) || _originOf(oldUrl)) && usedReceipt) {
|
|
2481
2585
|
try {
|
|
2586
|
+
// Named for the alias below, as run_script getReceipt names it (see _receiptAliases).
|
|
2482
2587
|
const fresh = _sanitizeTabResult(await extensionOrFallback(
|
|
2483
|
-
"get_tab_receipt", { ..._explicitReceipt({ receipt }) }, () => null
|
|
2588
|
+
"get_tab_receipt", { ..._explicitReceipt({ receipt: usedReceipt }) }, () => null
|
|
2484
2589
|
));
|
|
2485
2590
|
if (fresh?.receipt) {
|
|
2486
|
-
_aliasReceipt(usedReceipt, fresh.receipt);
|
|
2591
|
+
_aliasReceipt(_receiptToken(usedReceipt), fresh.receipt); // the receipt extensionOrFallback sent
|
|
2487
2592
|
_setActiveReceipt(fresh.receipt);
|
|
2488
2593
|
result = { ...result, receipt: fresh.receipt };
|
|
2489
2594
|
}
|
|
@@ -2580,13 +2685,10 @@ server.tool(
|
|
|
2580
2685
|
maxLength: z.coerce.number().optional().describe("Max chars to return (default: 50000)"),
|
|
2581
2686
|
timeout: z.coerce.number().optional().describe("Load timeout in ms (default: 30000)"),
|
|
2582
2687
|
},
|
|
2583
|
-
async ({ url, maxLength, timeout }) => {
|
|
2688
|
+
async ({ url, maxLength, timeout, receipt }) => {
|
|
2584
2689
|
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
2690
|
const result = await extensionOrFallback(
|
|
2589
|
-
"navigate_and_read", { url, maxLength, timeout },
|
|
2691
|
+
"navigate_and_read", { url, maxLength, timeout, ..._explicitReceipt({ receipt }) },
|
|
2590
2692
|
async () => {
|
|
2591
2693
|
await safari.navigate(url);
|
|
2592
2694
|
return safari.readPage({ maxLength });
|
|
@@ -2848,8 +2950,11 @@ server.tool(
|
|
|
2848
2950
|
value: z.string().describe("Option value or visible label to select"),
|
|
2849
2951
|
},
|
|
2850
2952
|
async (args) => {
|
|
2851
|
-
// The
|
|
2852
|
-
|
|
2953
|
+
// The receipt first: for a caller with no state of its own it is what names the tab, and the
|
|
2954
|
+
// guard has to see it. The engine calls below can bypass extensionOrFallback — assert
|
|
2955
|
+
// ownership here.
|
|
2956
|
+
const pin = _explicitReceipt(args);
|
|
2957
|
+
_assertTabOwnership("select_option", pin);
|
|
2853
2958
|
// ref path: resolve via mcpFindRef (reaches iframes/shadow DOM) on the AppleScript
|
|
2854
2959
|
// engine — the extension's select_option handler is selector-only.
|
|
2855
2960
|
if (args.ref) {
|
|
@@ -2857,7 +2962,7 @@ server.tool(
|
|
|
2857
2962
|
return { content: [{ type: "text", text: typeof refResult === 'string' ? refResult : JSON.stringify(refResult) }] };
|
|
2858
2963
|
}
|
|
2859
2964
|
let result = await extensionOrFallback(
|
|
2860
|
-
"select_option", { selector: args.selector, value: args.value, ...
|
|
2965
|
+
"select_option", { selector: args.selector, value: args.value, ...pin },
|
|
2861
2966
|
() => safari.selectOption(args)
|
|
2862
2967
|
);
|
|
2863
2968
|
// If extension returned "Selected: " with empty/default value, fuzzy match may have failed.
|
|
@@ -3112,7 +3217,7 @@ server.tool(
|
|
|
3112
3217
|
let viaAppleScript = false;
|
|
3113
3218
|
const rawResult = await extensionOrFallback(
|
|
3114
3219
|
"new_tab", { url: requestedUrl },
|
|
3115
|
-
() => { viaAppleScript = true; return
|
|
3220
|
+
() => { viaAppleScript = true; return _openTabViaAppleScript(requestedUrl); }
|
|
3116
3221
|
);
|
|
3117
3222
|
// AppleScript fallback returns a JSON string; extension returns an object — normalize
|
|
3118
3223
|
let result = rawResult;
|
|
@@ -3124,9 +3229,8 @@ server.tool(
|
|
|
3124
3229
|
// remains internal and is never copied into the MCP response.
|
|
3125
3230
|
if (safeResult?.tabIndex) {
|
|
3126
3231
|
safari.setActiveTabIndex(safeResult.tabIndex);
|
|
3127
|
-
//
|
|
3128
|
-
|
|
3129
|
-
_trackTab(safeResult.tabIndex, requestedUrl, mySession, viaAppleScript ? safari.getActiveTabMarker() : "", safeResult.receipt);
|
|
3232
|
+
// A tab AppleScript opened is tracked by _openTabViaAppleScript, by its marker.
|
|
3233
|
+
if (!viaAppleScript) _trackTab(safeResult.tabIndex, requestedUrl, mySession, "", safeResult.receipt);
|
|
3130
3234
|
// The new tab is the current one, with or without a receipt.
|
|
3131
3235
|
_setActiveReceipt(safeResult.receipt);
|
|
3132
3236
|
}
|
|
@@ -3162,18 +3266,23 @@ server.tool(
|
|
|
3162
3266
|
// tab's receipt, and the tab the caller was still working in closed instead of the one it named.
|
|
3163
3267
|
const token = supplied ? _receiptToken(supplied) : _getActiveReceipt();
|
|
3164
3268
|
if (supplied && !token) return errorResult("Tab safety: invalid tab receipt");
|
|
3165
|
-
|
|
3166
|
-
const marker = safari.getActiveTabMarker();
|
|
3269
|
+
let closesCurrent = false, marker = null;
|
|
3167
3270
|
let viaAppleScript = false;
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
()
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3271
|
+
// One close at a time (_oneCloseAtATime). Which tab is current is read when this close's turn
|
|
3272
|
+
// comes: a parallel call can switch tabs while it waits, and AppleScript closes the one current then.
|
|
3273
|
+
const result = await _oneCloseAtATime(() => {
|
|
3274
|
+
closesCurrent = !token || token === _getActiveReceipt();
|
|
3275
|
+
marker = safari.getActiveTabMarker();
|
|
3276
|
+
return extensionOrFallback(
|
|
3277
|
+
"close_tab",
|
|
3278
|
+
token ? { receipt: token } : {},
|
|
3279
|
+
() => {
|
|
3280
|
+
if (token) throw _receiptNeedsExtension("close_tab");
|
|
3281
|
+
viaAppleScript = true;
|
|
3282
|
+
return safari.closeTab();
|
|
3283
|
+
}
|
|
3284
|
+
);
|
|
3285
|
+
});
|
|
3177
3286
|
// The extension closed the receipt's tab; AppleScript, the tab carrying the session's marker.
|
|
3178
3287
|
_untrackClosedTab(viaAppleScript ? { marker } : { receipt: token });
|
|
3179
3288
|
// Closing another tab by its receipt leaves the current one current.
|
|
@@ -3271,16 +3380,19 @@ server.tool(
|
|
|
3271
3380
|
return claimed;
|
|
3272
3381
|
}
|
|
3273
3382
|
);
|
|
3274
|
-
// Adopted just now, or earlier: switchTab() keeps an adopted tab
|
|
3383
|
+
// Adopted just now, or earlier: switchTab() keeps an adopted tab in the adoption family.
|
|
3275
3384
|
const adopted = viaAppleScript && safari.isActiveTabAdopted();
|
|
3276
3385
|
const safeResult = _sanitizeTabResult(result);
|
|
3277
3386
|
// Sync safari.js state so AppleScript fallback targets the correct tab
|
|
3278
3387
|
const resolvedIndex = safeResult?.tabIndex || index;
|
|
3279
3388
|
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.
|
|
3389
|
+
if (viaAppleScript && safeResult?.safeUrl) safari.setActiveTabURL(safeResult.safeUrl);
|
|
3390
|
+
// …including the marker, which still names the last tab AppleScript claimed. The URL is left
|
|
3391
|
+
// unknown, as run_script's switchTab leaves it: the extension reports where the tab is now,
|
|
3392
|
+
// which after a redirect or a login bounce is a page this session never registered, and the
|
|
3393
|
+
// ownership guard refused every write after it, though the extension had proven the tab by its id.
|
|
3282
3394
|
if (!viaAppleScript && safeResult && typeof safeResult === "object") {
|
|
3283
|
-
safari.setActiveTabFromExtension(resolvedIndex,
|
|
3395
|
+
safari.setActiveTabFromExtension(resolvedIndex, null);
|
|
3284
3396
|
}
|
|
3285
3397
|
if (safeResult?.receipt || token) _setActiveReceipt(safeResult?.receipt || token);
|
|
3286
3398
|
// A switch by INDEX gets no receipt back, and the session was still holding the one it
|
|
@@ -3326,39 +3438,50 @@ server.tool(
|
|
|
3326
3438
|
},
|
|
3327
3439
|
async ({ timeout, urlContains }) => {
|
|
3328
3440
|
const timeoutMs = timeout || 10000;
|
|
3329
|
-
// Every listing says where it came from. AppleScript lists one window
|
|
3330
|
-
//
|
|
3331
|
-
//
|
|
3332
|
-
//
|
|
3333
|
-
// comparison over instead of comparing tabs
|
|
3441
|
+
// Every listing says where it came from. AppleScript lists one window by id (`win`): the one
|
|
3442
|
+
// this session's own tab is in, proven by its marker when AppleScript first lists, and a claim
|
|
3443
|
+
// through AppleScript goes to that window. Listings of whichever window was in front made a
|
|
3444
|
+
// tab of the user's other window look new, and the claim stamped the session's own marker on
|
|
3445
|
+
// it. A listing from the other source starts the comparison over instead of comparing tabs
|
|
3446
|
+
// across them.
|
|
3334
3447
|
let win = null;
|
|
3335
3448
|
const list = async () => {
|
|
3336
3449
|
let via = "extension";
|
|
3337
3450
|
const raw = await extensionOrFallback("list_tabs", {}, async () => {
|
|
3338
3451
|
via = "applescript";
|
|
3452
|
+
if (!win) win = await safari.sessionTabWindow();
|
|
3339
3453
|
const listed = await safari.listWindowTabs(win);
|
|
3340
3454
|
win = listed.win;
|
|
3341
3455
|
return listed.tabs;
|
|
3342
3456
|
});
|
|
3343
3457
|
return { via, tabs: typeof raw === "string" ? JSON.parse(raw) : raw };
|
|
3344
3458
|
};
|
|
3459
|
+
// A listed tab's URL: AppleScript lists `url`, the extension `safeUrl` (origin and path).
|
|
3460
|
+
const urlOf = (t) => t.url ?? t.safeUrl;
|
|
3345
3461
|
// Switch to the new tab and own it for THIS session, else the next interaction trips the
|
|
3346
3462
|
// 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.
|
|
3348
|
-
//
|
|
3349
|
-
// the extension's window,
|
|
3463
|
+
// it); the extension answers with the tab's receipt instead. A tab AppleScript listed is
|
|
3464
|
+
// claimed through AppleScript, in the pinned window, at once: its index is a position there,
|
|
3465
|
+
// not in the extension's window, and waiting out the extension's switch_tab first (30 s
|
|
3466
|
+
// while its worker is busy) left time for another tab to slide under that index. AppleScript
|
|
3467
|
+
// never claims a tab only the extension's listing saw.
|
|
3350
3468
|
const adopt = async (t, via) => {
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
()
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3469
|
+
const viaAppleScript = via === "applescript";
|
|
3470
|
+
let switched;
|
|
3471
|
+
if (viaAppleScript) {
|
|
3472
|
+
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.");
|
|
3473
|
+
switched = _sanitizeTabResult(await safari.switchTab(t.index, { claim: true, win, expectUrl: t.url }));
|
|
3474
|
+
} else {
|
|
3475
|
+
switched = _sanitizeTabResult(await extensionOrFallback("switch_tab", { index: t.index }, () => {
|
|
3476
|
+
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.");
|
|
3477
|
+
}));
|
|
3478
|
+
// The extension switches to whatever tab sits at the index when it runs the command, and
|
|
3479
|
+
// answers "Tab not found" as if it had switched: a tab that closed or moved in between
|
|
3480
|
+
// handed the session another tab of its own, or none, under the new tab's name.
|
|
3481
|
+
if (switched?.safeUrl !== _safeUrlForOutput(urlOf(t))) {
|
|
3482
|
+
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
3483
|
}
|
|
3361
|
-
|
|
3484
|
+
}
|
|
3362
3485
|
safari.setActiveTabIndex(t.index);
|
|
3363
3486
|
safari.setActiveTabURL(t.url);
|
|
3364
3487
|
if (!viaAppleScript) safari.setActiveTabFromExtension(t.index, t.url);
|
|
@@ -3369,49 +3492,102 @@ server.tool(
|
|
|
3369
3492
|
_trackTab(t.index, t.url, `${SESSION_ID}:${currentSessionId()}`, viaAppleScript ? safari.getActiveTabMarker() : "", switched?.receipt);
|
|
3370
3493
|
}
|
|
3371
3494
|
_setActiveReceipt(switched?.receipt);
|
|
3372
|
-
return { content: [{ type: "text", text: `Found new tab: ${t.title} (${t
|
|
3495
|
+
return { content: [{ type: "text", text: `Found new tab: ${t.title} (${urlOf(t)})` }] };
|
|
3496
|
+
};
|
|
3497
|
+
// Whether every tab of `was` is still there on its URL, in order, in `is`: only then can a tab
|
|
3498
|
+
// `is` added be told from the old ones. The first `index:url` pair not seen before was often a
|
|
3499
|
+
// tab of the user's that had navigated, or had moved when another closed, and the claim stamped
|
|
3500
|
+
// the session's own marker on it.
|
|
3501
|
+
// ponytail: tabs are told apart by URL alone (AppleScript has no tab id and no opener), so any
|
|
3502
|
+
// tab the user opens in the window during the wait counts as new, a new tab that opens on the
|
|
3503
|
+
// very URL a tab of the user's leaves in the same poll can pass for it (as can a tab of the
|
|
3504
|
+
// user's that lands on the URL of a new tab closing next to it, while another tab opens further
|
|
3505
|
+
// along, which leaves the same listings as that new tab staying put), and the claim stamps
|
|
3506
|
+
// whatever tab sits at the listed index a script later unless switchTab checks `expectUrl`.
|
|
3507
|
+
const intact = (was, is) => {
|
|
3508
|
+
const seen = new Set(was.map(urlOf));
|
|
3509
|
+
const kept = is.filter((t) => seen.has(urlOf(t)));
|
|
3510
|
+
return kept.length === was.length && kept.every((t, i) => urlOf(t) === urlOf(was[i]));
|
|
3511
|
+
};
|
|
3512
|
+
// Where the new tabs `last` showed are in `is`: each still on its URL, shown by no more and no
|
|
3513
|
+
// fewer tabs than before, and in its place while the other tabs only navigated (as many tabs as
|
|
3514
|
+
// before), or still there while the others only closed (the rest on their URLs, in order) and
|
|
3515
|
+
// none next to it. A tab that went next to a new one could instead have landed on its URL as the
|
|
3516
|
+
// new one went, and a tab that joins it on its URL could be taken for it; a neighbour counts as
|
|
3517
|
+
// still there only while as many tabs as before show its URL, or a twin could stand in for it.
|
|
3518
|
+
// Null when unsure.
|
|
3519
|
+
const stillNew = (last, urls, is) => {
|
|
3520
|
+
const count = (tabs, u) => tabs.filter((t) => urlOf(t) === u).length;
|
|
3521
|
+
const at = (tabs, u) => tabs.findIndex((t) => urlOf(t) === u);
|
|
3522
|
+
if (urls.some((u) => count(is, u) !== count(last, u))) return null;
|
|
3523
|
+
if (is.length === last.length) return urls.some((u) => at(is, u) !== at(last, u)) ? null : urls.map((u) => is[at(is, u)]);
|
|
3524
|
+
const there = new Set();
|
|
3525
|
+
let j = 0;
|
|
3526
|
+
last.forEach((t, k) => { if (j < is.length && urlOf(is[j]) === urlOf(t)) { there.add(k); j++; } });
|
|
3527
|
+
const stays = (k) => count(is, urlOf(last[k])) === count(last, urlOf(last[k]));
|
|
3528
|
+
const neighbours = (u) => [at(last, u) - 1, at(last, u) + 1].every((k) => k < 0 || k >= last.length || (there.has(k) && stays(k)));
|
|
3529
|
+
return j === is.length && urls.every(neighbours) ? urls.map((u) => is[at(is, u)]) : null;
|
|
3373
3530
|
};
|
|
3374
3531
|
// Get current tab list
|
|
3375
3532
|
let before = await list();
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3533
|
+
// Every URL a baseline showed. A new tab is one on a URL none of them showed, so a tab that
|
|
3534
|
+
// left the window and came back (reopened, or dragged out and in again) is not new either.
|
|
3535
|
+
const shown = new Set(before.tabs.map(urlOf));
|
|
3536
|
+
// The new tabs not claimed yet (still on about:blank, not matching urlContains, or refused),
|
|
3537
|
+
// by URL, and the listing that showed them; and whether any tab opened at all.
|
|
3538
|
+
let pending = { last: before.tabs, urls: [] };
|
|
3539
|
+
let opened = false;
|
|
3540
|
+
|
|
3541
|
+
// Poll for new tab — detect by count increase + tabs on new URLs
|
|
3379
3542
|
const deadline = Date.now() + timeoutMs;
|
|
3380
3543
|
while (Date.now() < deadline) {
|
|
3381
3544
|
await new Promise(r => setTimeout(r, 500));
|
|
3382
|
-
const now = await list()
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3545
|
+
const now = await list().catch((err) => {
|
|
3546
|
+
// A listing that fails (the window pin refused, AppleScript failed) takes the wait's record
|
|
3547
|
+
// of a tab that opened with it: say so, as the timeout would.
|
|
3548
|
+
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.";
|
|
3549
|
+
throw err;
|
|
3550
|
+
});
|
|
3551
|
+
// A listing where a tab closed or navigated becomes the baseline, so a tab of the user's that
|
|
3552
|
+
// navigated meanwhile is not new once one opens; so does one from the other source. A new
|
|
3553
|
+
// tab not claimed yet stays out of it while the other tabs only navigated in place or only
|
|
3554
|
+
// closed: taken into it, it was never new again, and the wait timed out on a popup that
|
|
3555
|
+
// was still loading when another tab changed. Any other change, or one as a tab opened,
|
|
3556
|
+
// takes every tab in: held against the older baseline, whichever of two tabs that changed
|
|
3557
|
+
// navigated first looked new, the user's too.
|
|
3558
|
+
if (now.via !== before.via || !intact(before.tabs, now.tabs)) {
|
|
3559
|
+
const same = now.via === before.via;
|
|
3560
|
+
const kept = !pending.urls.length ? [] : same ? stillNew(pending.last, pending.urls, now.tabs) : null;
|
|
3561
|
+
// Only AppleScript lists one window by id: the extension can list whichever window is in front.
|
|
3562
|
+
if (same && now.via === "applescript" && now.tabs.length > before.tabs.length) opened = true;
|
|
3563
|
+
before = { ...now, tabs: now.tabs.filter((t) => !kept?.includes(t)) };
|
|
3564
|
+
for (const t of before.tabs) shown.add(urlOf(t));
|
|
3565
|
+
pending = { last: now.tabs, urls: kept ? pending.urls : [] };
|
|
3386
3566
|
continue;
|
|
3387
3567
|
}
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
}
|
|
3406
|
-
continue;
|
|
3407
|
-
}
|
|
3408
|
-
if (urlContains && !tab.url.includes(urlContains)) continue;
|
|
3409
|
-
return await adopt(tab, now.via);
|
|
3410
|
-
}
|
|
3568
|
+
const old = new Set(before.tabs.map(urlOf));
|
|
3569
|
+
pending = { last: now.tabs, urls: now.tabs.map(urlOf).filter((u) => !old.has(u)) };
|
|
3570
|
+
if (pending.urls.length) opened = true;
|
|
3571
|
+
for (const tab of now.tabs) {
|
|
3572
|
+
// A new tab still on about:blank (an OAuth popup before it loads) is claimed at a later
|
|
3573
|
+
// poll, once it shows its URL: a later listing found it again by index, and by then that
|
|
3574
|
+
// could be another tab.
|
|
3575
|
+
if (urlOf(tab) === 'about:blank' || shown.has(urlOf(tab))) continue;
|
|
3576
|
+
if (urlContains && !String(urlOf(tab)).includes(urlContains)) continue;
|
|
3577
|
+
try {
|
|
3578
|
+
return await adopt(tab, now.via);
|
|
3579
|
+
} catch (err) {
|
|
3580
|
+
// A tab another session opened in this window meanwhile, whose marker the claim refuses
|
|
3581
|
+
// to overwrite (`otherSession`), or a tab that no longer shows the page the listing saw
|
|
3582
|
+
// (`moved`: one closed in between and slid another under the index). Neither is the
|
|
3583
|
+
// tab this waits for, and a later listing can still show it.
|
|
3584
|
+
if (!err?.otherSession && !err?.moved) throw err;
|
|
3411
3585
|
}
|
|
3412
3586
|
}
|
|
3413
3587
|
}
|
|
3414
|
-
return { content: [{ type: "text", text:
|
|
3588
|
+
return { content: [{ type: "text", text: opened
|
|
3589
|
+
? "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."
|
|
3590
|
+
: "TIMEOUT: no new tab appeared" }] };
|
|
3415
3591
|
}
|
|
3416
3592
|
);
|
|
3417
3593
|
|
|
@@ -3689,7 +3865,7 @@ server.tool(
|
|
|
3689
3865
|
|
|
3690
3866
|
server.tool(
|
|
3691
3867
|
"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
|
|
3868
|
+
"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
3869
|
{
|
|
3694
3870
|
steps: z.array(z.object({
|
|
3695
3871
|
action: z.string().describe("Action name (e.g. 'navigate', 'click', 'fill')"),
|
|
@@ -3702,15 +3878,16 @@ server.tool(
|
|
|
3702
3878
|
// AppleScript and loses the verified profile/tab-id boundary; they use the
|
|
3703
3879
|
// extension-only dispatcher below.
|
|
3704
3880
|
if (!process.env.SAFARI_PROFILE) {
|
|
3881
|
+
const mySession = `${SESSION_ID}:${currentSessionId()}`;
|
|
3705
3882
|
const onStep = (action, stepArgs) => {
|
|
3706
|
-
|
|
3883
|
+
// getReceipt is guarded in extensionOrFallback, which sees the receipt the step names and leaves
|
|
3884
|
+
// a rotation that names one to the extension. The check below is given no payload, so it would not
|
|
3885
|
+
// see that receipt and would judge the URL safari.js holds: after a redirect during an AppleScript
|
|
3886
|
+
// load, one the session never registered.
|
|
3887
|
+
if (action === "getReceipt") return;
|
|
3707
3888
|
if (action === "navigate" || action === "navigateAndRead") {
|
|
3708
3889
|
_assertTabOwnership(`run_script:${action}`);
|
|
3709
|
-
|
|
3710
|
-
if (url) {
|
|
3711
|
-
_addOwnedURL(url);
|
|
3712
|
-
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) _addOwnedURL("https://" + url);
|
|
3713
|
-
}
|
|
3890
|
+
_claimURL(stepArgs?.url);
|
|
3714
3891
|
return;
|
|
3715
3892
|
}
|
|
3716
3893
|
// These steps run through AppleScript here, which cannot tell which tab a receipt names:
|
|
@@ -3747,7 +3924,40 @@ server.tool(
|
|
|
3747
3924
|
return _runExtensionBatchAction("switchTab", { index: Number(index) });
|
|
3748
3925
|
}
|
|
3749
3926
|
};
|
|
3750
|
-
|
|
3927
|
+
// A newTab step opens its tab as safari_new_tab does: under the session's tab cap, which first
|
|
3928
|
+
// closes the session's oldest tab when it already has MAX_TABS, and as one of the session's
|
|
3929
|
+
// own tabs, claimed and tracked (_openTabViaAppleScript). It used to claim nothing, so every
|
|
3930
|
+
// write step after newTab{url} was refused on the session's own tab, and to track nothing, so
|
|
3931
|
+
// a batch's tabs escaped the cap, shutdown cleanup and the memory sweep.
|
|
3932
|
+
const newTab = async ({ url }) => {
|
|
3933
|
+
const evicted = await _evictOldestTab(mySession);
|
|
3934
|
+
const result = await _openTabViaAppleScript(String(url || ""));
|
|
3935
|
+
if (!evicted) return result;
|
|
3936
|
+
// Name the tab the cap closed, as safari_new_tab does.
|
|
3937
|
+
let tab = {};
|
|
3938
|
+
try { tab = JSON.parse(result); } catch {}
|
|
3939
|
+
return { ...tab, ..._evictionReport(evicted) };
|
|
3940
|
+
};
|
|
3941
|
+
// A closeTab step forgets the tab it closed, as safari_close_tab does after an AppleScript
|
|
3942
|
+
// close: closeOwnTab() closes only the tab that carries the session's marker. One close at a
|
|
3943
|
+
// time (_oneCloseAtATime).
|
|
3944
|
+
const closeTab = ({ index }) => _oneCloseAtATime(async () => {
|
|
3945
|
+
const marker = safari.getActiveTabMarker();
|
|
3946
|
+
const result = await safari.closeOwnTab(index);
|
|
3947
|
+
_untrackClosedTab({ marker });
|
|
3948
|
+
return result;
|
|
3949
|
+
});
|
|
3950
|
+
// Only the extension can rotate a receipt, and the legacy action table had no getReceipt, so
|
|
3951
|
+
// the step the origin refusal advises came back "Unknown action" and the tab stayed stranded.
|
|
3952
|
+
// It has to name its tab here: this batch's AppleScript steps (newTab, switchTab) move to a
|
|
3953
|
+
// tab without touching the session's receipt, which then names a tab the batch has left.
|
|
3954
|
+
const getReceipt = (stepArgs) => {
|
|
3955
|
+
if (!stepArgs?.receipt) {
|
|
3956
|
+
throw new Error("Tab safety: without SAFARI_PROFILE, getReceipt needs the receipt of the tab it rotates, in args.receipt.");
|
|
3957
|
+
}
|
|
3958
|
+
return _runExtensionBatchAction("getReceipt", stepArgs);
|
|
3959
|
+
};
|
|
3960
|
+
return textResult(await safari.runScript({ steps, onStep, actions: { switchTab, newTab, closeTab, getReceipt } }));
|
|
3751
3961
|
}
|
|
3752
3962
|
|
|
3753
3963
|
// Keep every step in one MCP request/session and route it through
|