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