safari-mcp 2.15.6 → 2.15.8
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 +17 -1
- package/index.js +34 -25
- package/package.json +1 -1
- package/safari.js +42 -13
package/extension/background.js
CHANGED
|
@@ -226,7 +226,14 @@ async function handleCommand(type, payload) {
|
|
|
226
226
|
if (anyOwned) {
|
|
227
227
|
throw new Error(`⚠️ Tab safety: refusing "${type}" on tab ${tabId} (${targetTab.url || 'unknown'}) — not opened by this MCP session. Use safari_new_tab first.`);
|
|
228
228
|
}
|
|
229
|
-
//
|
|
229
|
+
// No tabs owned yet → allow, for sessions that never call new_tab. That leniency is
|
|
230
|
+
// survivable for a write (worst case: it edits the page the user is already on) but not
|
|
231
|
+
// for a close, which throws their tab away. It also fires for a session re-initialised
|
|
232
|
+
// after a transport drop, which reports owning nothing while its tab is still open —
|
|
233
|
+
// the state in which close_tab destroyed a user's tab (#68).
|
|
234
|
+
if (_destructiveTabCommands.has(type)) {
|
|
235
|
+
throw new Error(`⚠️ Tab safety: refusing "${type}" on tab ${tabId} (${targetTab.url || 'unknown'}) — this session owns no tabs, so it has none to close. Use safari_new_tab first.`);
|
|
236
|
+
}
|
|
230
237
|
}
|
|
231
238
|
|
|
232
239
|
switch (type) {
|
|
@@ -1117,6 +1124,11 @@ async function handleCommand(type, payload) {
|
|
|
1117
1124
|
// could resolve the wrong tab or leave a stale last-tab verdict).
|
|
1118
1125
|
const target = _winTabs[payload.index - 1];
|
|
1119
1126
|
if (target) {
|
|
1127
|
+
// The ownership guard above checked `tabId` — this branch closes a tab chosen by
|
|
1128
|
+
// index instead, so it must be checked on its own or the guard is decorative.
|
|
1129
|
+
if (!_isTabOwnedBySession(sessionId, target.id)) {
|
|
1130
|
+
throw new Error(`⚠️ Tab safety: refusing to close tab ${payload.index} (${target.url || 'unknown'}) — not opened by this MCP session.`);
|
|
1131
|
+
}
|
|
1120
1132
|
_removeOwnedTab(sessionId, target.id);
|
|
1121
1133
|
if (_isLastTab) {
|
|
1122
1134
|
await browser.tabs.update(target.id, { url: "about:blank" });
|
|
@@ -1828,6 +1840,10 @@ function _isTabOwnedBySession(sessionId, tabId) {
|
|
|
1828
1840
|
return set ? set.has(tabId) : false;
|
|
1829
1841
|
}
|
|
1830
1842
|
|
|
1843
|
+
// Commands that destroy a tab rather than change a page. They get no "no tabs owned yet"
|
|
1844
|
+
// leniency: a session that owns nothing has nothing to close (#68).
|
|
1845
|
+
const _destructiveTabCommands = new Set(["close_tab"]);
|
|
1846
|
+
|
|
1831
1847
|
// Read-only commands that don't modify the page — allowed on any tab
|
|
1832
1848
|
const _readOnlyCommands = new Set([
|
|
1833
1849
|
"list_tabs", "read_page", "get_source", "snapshot", "accessibility_snapshot",
|
package/index.js
CHANGED
|
@@ -81,10 +81,9 @@ async function _cleanupTabs() {
|
|
|
81
81
|
const tabs = await safari.listTabs();
|
|
82
82
|
const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
|
|
83
83
|
const match = parsed.find(t => t.url === url);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
84
|
+
// Name the tab explicitly: it was just resolved out of our own opened-tab table, and
|
|
85
|
+
// closeTab refuses anything it cannot prove it owns (#68).
|
|
86
|
+
if (match) await safari.closeTab(match.index);
|
|
88
87
|
} catch {}
|
|
89
88
|
}
|
|
90
89
|
_openedTabs.clear();
|
|
@@ -153,10 +152,7 @@ async function _closeOldestMCPTab() {
|
|
|
153
152
|
const tabs = await safari.listTabs();
|
|
154
153
|
const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
|
|
155
154
|
const match = parsed.find(t => t.url === info.url);
|
|
156
|
-
if (match)
|
|
157
|
-
safari.setActiveTabIndex(match.index);
|
|
158
|
-
await safari.closeTab();
|
|
159
|
-
}
|
|
155
|
+
if (match) await safari.closeTab(match.index);
|
|
160
156
|
}
|
|
161
157
|
} catch {}
|
|
162
158
|
_untrackTab(oldestIdx);
|
|
@@ -210,19 +206,21 @@ function _startMemoryMonitor() {
|
|
|
210
206
|
|
|
211
207
|
// Cleanup on exit
|
|
212
208
|
let _cleaningUp = false;
|
|
209
|
+
async function _shutdown() {
|
|
210
|
+
if (_cleaningUp) return;
|
|
211
|
+
_cleaningUp = true;
|
|
212
|
+
// Restore the user's clipboard synchronously FIRST — if a native paste is mid-flight, its
|
|
213
|
+
// 2s restore timer would never fire once we exit, leaving the tool's text on the clipboard.
|
|
214
|
+
try { safari.flushClipboardRestore(); } catch {}
|
|
215
|
+
// Cap tab cleanup — if the daemon/Safari is wedged at shutdown (exactly when a SIGTERM
|
|
216
|
+
// tends to arrive), listTabs/closeTab can each block for their full timeout; never let
|
|
217
|
+
// that hang the exit past 3s.
|
|
218
|
+
await Promise.race([_cleanupTabs(), new Promise(r => setTimeout(r, 3000))]);
|
|
219
|
+
process.exit(0);
|
|
220
|
+
}
|
|
221
|
+
|
|
213
222
|
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
214
|
-
process.on(sig,
|
|
215
|
-
if (_cleaningUp) return; // Prevent double-exit on rapid signal repeat
|
|
216
|
-
_cleaningUp = true;
|
|
217
|
-
// Restore the user's clipboard synchronously FIRST — if a native paste is mid-flight, its
|
|
218
|
-
// 2s restore timer would never fire once we exit, leaving the tool's text on the clipboard.
|
|
219
|
-
try { safari.flushClipboardRestore(); } catch {}
|
|
220
|
-
// Cap tab cleanup — if the daemon/Safari is wedged at shutdown (exactly when a SIGTERM
|
|
221
|
-
// tends to arrive), listTabs/closeTab can each block for their full timeout; never let
|
|
222
|
-
// that hang the exit past 3s.
|
|
223
|
-
await Promise.race([_cleanupTabs(), new Promise(r => setTimeout(r, 3000))]);
|
|
224
|
-
process.exit(0);
|
|
225
|
-
});
|
|
223
|
+
process.on(sig, _shutdown);
|
|
226
224
|
}
|
|
227
225
|
process.on("exit", () => {
|
|
228
226
|
if (_openedTabs.size > 0) {
|
|
@@ -655,8 +653,12 @@ const _nullMeansFailure = new Set([
|
|
|
655
653
|
|
|
656
654
|
// Operations that don't need tab ownership (read-only or tab management)
|
|
657
655
|
const _noOwnershipCheck = new Set([
|
|
658
|
-
// Tab management
|
|
659
|
-
"
|
|
656
|
+
// Tab management. NOTE: "close_tab" is deliberately NOT here. It sat in this set as
|
|
657
|
+
// "tab management" next to new_tab/list_tabs/switch_tab, but it is the only member that
|
|
658
|
+
// destroys a user tab: new_tab creates one, list_tabs reads, and switch_tab carries its
|
|
659
|
+
// own ownership check at the tool. close_tab had none — exempt from the shared guard and
|
|
660
|
+
// unguarded underneath, which is how it closed a user's tab in #68.
|
|
661
|
+
"new_tab", "list_tabs", "switch_tab",
|
|
660
662
|
// Extension self-management (doesn't touch tabs)
|
|
661
663
|
"reload_extension",
|
|
662
664
|
// Read-only — don't modify the page
|
|
@@ -1376,8 +1378,7 @@ server.tool(
|
|
|
1376
1378
|
if (oldestIdx !== null) {
|
|
1377
1379
|
console.error(`[Safari MCP] Tab limit (${MAX_TABS}) reached — closing oldest tab #${oldestIdx}`);
|
|
1378
1380
|
try {
|
|
1379
|
-
safari.
|
|
1380
|
-
await safari.closeTab();
|
|
1381
|
+
await safari.closeTab(oldestIdx);
|
|
1381
1382
|
} catch {}
|
|
1382
1383
|
_untrackTab(oldestIdx);
|
|
1383
1384
|
}
|
|
@@ -2420,4 +2421,12 @@ _startMemoryMonitor();
|
|
|
2420
2421
|
|
|
2421
2422
|
// Transport is chosen at runtime: default stdio (unchanged), or a shared HTTP instance when
|
|
2422
2423
|
// SAFARI_MCP_HTTP=1 (many Claude sessions → one process). buildServer is the per-session factory.
|
|
2423
|
-
await startTransport(buildServer, process.env);
|
|
2424
|
+
const transportHandle = await startTransport(buildServer, process.env);
|
|
2425
|
+
|
|
2426
|
+
if (transportHandle.kind === "stdio") {
|
|
2427
|
+
// A client can disappear without delivering a signal. Treat its pipe closing as the stdio
|
|
2428
|
+
// server's lifecycle boundary, while leaving the opt-in HTTP daemon independent of stdin.
|
|
2429
|
+
process.stdin.once("end", _shutdown);
|
|
2430
|
+
process.stdin.once("close", _shutdown);
|
|
2431
|
+
if (process.stdin.readableEnded || process.stdin.destroyed) void _shutdown();
|
|
2432
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.15.
|
|
3
|
+
"version": "2.15.8",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
5
|
"description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 96 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|
package/safari.js
CHANGED
|
@@ -3484,8 +3484,37 @@ export async function newTab(url = "") {
|
|
|
3484
3484
|
return info;
|
|
3485
3485
|
}
|
|
3486
3486
|
|
|
3487
|
-
|
|
3487
|
+
// A tab index this session can prove it owns, or null. Destructive paths only: they may
|
|
3488
|
+
// never guess, so "can't prove it" has to read as null rather than as the front document.
|
|
3489
|
+
// `resolveActiveTab()` re-finds the tab through its identity marker (surviving the index
|
|
3490
|
+
// shifts of #54) and fails closed to null when the marker is on no tab at all.
|
|
3491
|
+
async function _provenOwnTabIndex() {
|
|
3492
|
+
return (await resolveActiveTab()) || null;
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
// `explicitIndex` — the caller naming the tab, the same opt-out convention the identity
|
|
3496
|
+
// guard uses. Internal cleanup resolves its own indices out of the opened-tab table and
|
|
3497
|
+
// passes them here; everything else must prove ownership.
|
|
3498
|
+
export async function closeTab(explicitIndex) {
|
|
3488
3499
|
await refreshTargetWindow();
|
|
3500
|
+
|
|
3501
|
+
// ── Guard: close nothing this session cannot prove it owns. There is deliberately no
|
|
3502
|
+
// front-document fallback here, unlike the read paths: `close current tab of window` is
|
|
3503
|
+
// the tab the USER is looking at whenever our index is unknown — and "unknown" is exactly
|
|
3504
|
+
// what a session re-initialised after a transport drop reports, mid-task, while a tab of
|
|
3505
|
+
// its own is still open. That fail-open destroyed a user's tab (#68, the destructive
|
|
3506
|
+
// sibling of #64). An unmarked front document stays readable for a genuinely fresh
|
|
3507
|
+
// session, because a bad read costs information; a bad close costs their work.
|
|
3508
|
+
const idx = explicitIndex || (await _provenOwnTabIndex());
|
|
3509
|
+
if (!idx) {
|
|
3510
|
+
throw new Error(
|
|
3511
|
+
`Tab tracking lost — refusing to close a tab this session cannot prove it opened ` +
|
|
3512
|
+
`(closing "current tab of window" would close the user's active tab). ` +
|
|
3513
|
+
`Call safari_new_tab to open a tab this session owns, or safari_list_tabs and ` +
|
|
3514
|
+
`safari_switch_tab to re-anchor to a known one.`
|
|
3515
|
+
);
|
|
3516
|
+
}
|
|
3517
|
+
|
|
3489
3518
|
// ── Guard: never close a window's LAST tab. Closing it shuts the window —
|
|
3490
3519
|
// which quits Safari if it's the only window, AND (for profile-targeted
|
|
3491
3520
|
// instances) makes the target window vanish so every later op throws
|
|
@@ -3500,10 +3529,11 @@ export async function closeTab() {
|
|
|
3500
3529
|
10
|
|
3501
3530
|
);
|
|
3502
3531
|
if (Number.isFinite(_winTabs) && _winTabs <= 1) {
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3532
|
+
// Blanking is destructive too — it throws away whatever page is loaded — so it
|
|
3533
|
+
// targets the proven index, never the front document.
|
|
3534
|
+
await osascript(
|
|
3535
|
+
`tell application "Safari" to set URL of tab ${idx} of ${getTargetWindowRef()} to "about:blank"`
|
|
3536
|
+
);
|
|
3507
3537
|
_st().activeTabIndex = null;
|
|
3508
3538
|
_st().activeTabURL = null;
|
|
3509
3539
|
_st().lastTabCount = null;
|
|
@@ -3512,16 +3542,15 @@ export async function closeTab() {
|
|
|
3512
3542
|
}
|
|
3513
3543
|
} catch { /* count check failed — fall through to normal close */ }
|
|
3514
3544
|
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3545
|
+
await osascript(
|
|
3546
|
+
`tell application "Safari" to close tab ${idx} of ${getTargetWindowRef()}`
|
|
3547
|
+
);
|
|
3548
|
+
if (!explicitIndex || idx === _st().activeTabIndex) {
|
|
3519
3549
|
_st().activeTabIndex = null;
|
|
3520
3550
|
_st().activeTabURL = null;
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
);
|
|
3551
|
+
// The tab is gone, so its marker is too — keeping it would let a later resolve
|
|
3552
|
+
// match a stale identity.
|
|
3553
|
+
_st().activeTabMarker = null;
|
|
3525
3554
|
}
|
|
3526
3555
|
_st().lastTabCount = null; // Invalidate — tab count changed
|
|
3527
3556
|
_st().lastResolveTime = 0; // Force re-resolve on next operation
|