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