safari-mcp 2.15.7 → 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 +11 -12
- 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);
|
|
@@ -657,8 +653,12 @@ const _nullMeansFailure = new Set([
|
|
|
657
653
|
|
|
658
654
|
// Operations that don't need tab ownership (read-only or tab management)
|
|
659
655
|
const _noOwnershipCheck = new Set([
|
|
660
|
-
// Tab management
|
|
661
|
-
"
|
|
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",
|
|
662
662
|
// Extension self-management (doesn't touch tabs)
|
|
663
663
|
"reload_extension",
|
|
664
664
|
// Read-only — don't modify the page
|
|
@@ -1378,8 +1378,7 @@ server.tool(
|
|
|
1378
1378
|
if (oldestIdx !== null) {
|
|
1379
1379
|
console.error(`[Safari MCP] Tab limit (${MAX_TABS}) reached — closing oldest tab #${oldestIdx}`);
|
|
1380
1380
|
try {
|
|
1381
|
-
safari.
|
|
1382
|
-
await safari.closeTab();
|
|
1381
|
+
await safari.closeTab(oldestIdx);
|
|
1383
1382
|
} catch {}
|
|
1384
1383
|
_untrackTab(oldestIdx);
|
|
1385
1384
|
}
|
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
|