safari-mcp 2.21.13 → 2.21.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +36 -26
- package/ownership-state.js +5 -2
- package/package.json +1 -1
- package/safari.js +33 -0
package/index.js
CHANGED
|
@@ -169,6 +169,16 @@ const WEBKIT_MEMORY_LIMIT_MB = parseInt(process.env.MCP_WEBKIT_LIMIT_MB || "3000
|
|
|
169
169
|
// _removeOwnedURL, _trackTab, _untrackTab, BLANK_TAB_SENTINEL) are imported from
|
|
170
170
|
// ownership-state.js at the top of this file.
|
|
171
171
|
|
|
172
|
+
// Resolve a tab we recorded earlier to its CURRENT index, proving identity by the marker
|
|
173
|
+
// stamped on it. Returns null when the tab carries no marker we can find — the caller then
|
|
174
|
+
// leaves it alone. Never matches by URL: the tab navigates after we open it, and the user
|
|
175
|
+
// can have a tab of their own on the URL we recorded, so a URL match closed THEIR tab
|
|
176
|
+
// (#112). A stray MCP tab left open costs nothing; a closed user tab costs their work.
|
|
177
|
+
async function _verifiedTabIndex(info) {
|
|
178
|
+
if (!info?.marker) return null;
|
|
179
|
+
try { return await safari.findTabByMarker(info.marker); } catch { return null; }
|
|
180
|
+
}
|
|
181
|
+
|
|
172
182
|
// Close all MCP-opened tabs on process exit
|
|
173
183
|
async function _cleanupTabs() {
|
|
174
184
|
if (_openedTabs.size === 0) return;
|
|
@@ -181,17 +191,14 @@ async function _cleanupTabs() {
|
|
|
181
191
|
return;
|
|
182
192
|
}
|
|
183
193
|
console.error(`[Safari MCP] Cleanup: closing ${_openedTabs.size} MCP-opened tabs`);
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
194
|
+
// Resolve each tab by its identity marker right before closing it — indices shift after
|
|
195
|
+
// every closure, and the marker is the only coordinate that survives navigation. Name the
|
|
196
|
+
// index explicitly: it was just proven ours, and closeTab's own ownership check covers
|
|
197
|
+
// only this session's active tab (#68).
|
|
198
|
+
for (const info of [..._openedTabs.values()]) {
|
|
187
199
|
try {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
|
|
191
|
-
const match = parsed.find(t => t.url === url);
|
|
192
|
-
// Name the tab explicitly: it was just resolved out of our own opened-tab table, and
|
|
193
|
-
// closeTab refuses anything it cannot prove it owns (#68).
|
|
194
|
-
if (match) await safari.closeTab(match.index);
|
|
200
|
+
const idx = await _verifiedTabIndex(info);
|
|
201
|
+
if (idx) await safari.closeTab(idx);
|
|
195
202
|
} catch {}
|
|
196
203
|
}
|
|
197
204
|
_openedTabs.clear();
|
|
@@ -256,13 +263,8 @@ async function _closeOldestMCPTab() {
|
|
|
256
263
|
}
|
|
257
264
|
if (oldestIdx !== null) {
|
|
258
265
|
try {
|
|
259
|
-
const
|
|
260
|
-
if (
|
|
261
|
-
const tabs = await safari.listTabs();
|
|
262
|
-
const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
|
|
263
|
-
const match = parsed.find(t => t.url === info.url);
|
|
264
|
-
if (match) await safari.closeTab(match.index);
|
|
265
|
-
}
|
|
266
|
+
const idx = await _verifiedTabIndex(_openedTabs.get(oldestIdx));
|
|
267
|
+
if (idx) await safari.closeTab(idx);
|
|
266
268
|
} catch {}
|
|
267
269
|
_untrackTab(oldestIdx);
|
|
268
270
|
}
|
|
@@ -1765,7 +1767,7 @@ async function _runExtensionBatchAction(action, args = {}) {
|
|
|
1765
1767
|
const value = _sanitizeTabResult(normalize(raw));
|
|
1766
1768
|
if (value && typeof value === "object" && value.tabIndex) {
|
|
1767
1769
|
safari.setActiveTabIndex(value.tabIndex);
|
|
1768
|
-
_trackTab(value.tabIndex, requestedUrl, mySession);
|
|
1770
|
+
_trackTab(value.tabIndex, requestedUrl, mySession, safari.getActiveTabMarker());
|
|
1769
1771
|
}
|
|
1770
1772
|
const trackUrl = requestedUrl || "about:blank";
|
|
1771
1773
|
if (trackUrl) {
|
|
@@ -2863,11 +2865,19 @@ server.tool(
|
|
|
2863
2865
|
if (oldestIdx !== null) {
|
|
2864
2866
|
console.error(`[Safari MCP] Tab limit (${MAX_TABS}) reached for this session — closing its oldest tab #${oldestIdx}`);
|
|
2865
2867
|
try {
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2868
|
+
// The recorded index has shifted if any tab closed since; on the AppleScript path
|
|
2869
|
+
// re-prove it by marker first, and skip the eviction when it cannot be proven
|
|
2870
|
+
// (#112). The extension resolves its own owned tab ids, so it keeps the recorded one.
|
|
2871
|
+
const evictIdx = process.env.SAFARI_PROFILE
|
|
2872
|
+
? oldestIdx
|
|
2873
|
+
: await _verifiedTabIndex(_openedTabs.get(oldestIdx));
|
|
2874
|
+
if (evictIdx) {
|
|
2875
|
+
await extensionOrFallback(
|
|
2876
|
+
"close_tab",
|
|
2877
|
+
{ index: evictIdx },
|
|
2878
|
+
() => safari.closeTab(evictIdx)
|
|
2879
|
+
);
|
|
2880
|
+
}
|
|
2871
2881
|
} catch {}
|
|
2872
2882
|
_untrackTab(oldestIdx);
|
|
2873
2883
|
evictedTab = oldestIdx;
|
|
@@ -2889,7 +2899,7 @@ server.tool(
|
|
|
2889
2899
|
// remains internal and is never copied into the MCP response.
|
|
2890
2900
|
if (safeResult?.tabIndex) {
|
|
2891
2901
|
safari.setActiveTabIndex(safeResult.tabIndex);
|
|
2892
|
-
_trackTab(safeResult.tabIndex, requestedUrl, mySession);
|
|
2902
|
+
_trackTab(safeResult.tabIndex, requestedUrl, mySession, safari.getActiveTabMarker());
|
|
2893
2903
|
}
|
|
2894
2904
|
if (requestedUrl) {
|
|
2895
2905
|
const trackUrl = requestedUrl;
|
|
@@ -3084,7 +3094,7 @@ server.tool(
|
|
|
3084
3094
|
await extensionOrFallback("switch_tab", { index: resolved.index }, () => safari.switchTab(resolved.index));
|
|
3085
3095
|
safari.setActiveTabIndex(resolved.index);
|
|
3086
3096
|
safari.setActiveTabURL(resolved.url);
|
|
3087
|
-
_trackTab(resolved.index, resolved.url, `${SESSION_ID}:${currentSessionId()}
|
|
3097
|
+
_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
|
|
3088
3098
|
return { content: [{ type: "text", text: `Found new tab: ${resolved.title} (${resolved.url})` }] };
|
|
3089
3099
|
}
|
|
3090
3100
|
continue;
|
|
@@ -3093,7 +3103,7 @@ server.tool(
|
|
|
3093
3103
|
await extensionOrFallback("switch_tab", { index: tab.index }, () => safari.switchTab(tab.index));
|
|
3094
3104
|
safari.setActiveTabIndex(tab.index);
|
|
3095
3105
|
safari.setActiveTabURL(tab.url);
|
|
3096
|
-
_trackTab(tab.index, tab.url, `${SESSION_ID}:${currentSessionId()}
|
|
3106
|
+
_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
|
|
3097
3107
|
return { content: [{ type: "text", text: `Found new tab: ${tab.title} (${tab.url})` }] };
|
|
3098
3108
|
}
|
|
3099
3109
|
}
|
package/ownership-state.js
CHANGED
|
@@ -209,12 +209,15 @@ export function _removeOwnedURL(url) {
|
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
-
export function _trackTab(tabIndex, url, sessionId = "") {
|
|
212
|
+
export function _trackTab(tabIndex, url, sessionId = "", marker = "") {
|
|
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
|
-
|
|
217
|
+
// marker is the tab's identity stamp (window.name / __mcpTabMarker). The index and the
|
|
218
|
+
// URL both go stale — indices shift, and the tab navigates — so every path that later
|
|
219
|
+
// CLOSES this tab resolves it through the marker instead (#112).
|
|
220
|
+
_openedTabs.set(tabIndex, { url: url || "", openedAt: Date.now(), sessionId, marker: marker || "" });
|
|
218
221
|
_addOwnedURL(url);
|
|
219
222
|
}
|
|
220
223
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.21.
|
|
3
|
+
"version": "2.21.14",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
5
|
"description": "Safari browser automation for AI agents \u2014 native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|
package/safari.js
CHANGED
|
@@ -728,6 +728,39 @@ export function setActiveTabIndex(idx) { _st().activeTabIndex = idx; }
|
|
|
728
728
|
export function getActiveTabURL() { return _st().activeTabURL; }
|
|
729
729
|
export function setActiveTabURL(url) { _st().activeTabURL = url; _st().lastResolveTime = Date.now(); }
|
|
730
730
|
|
|
731
|
+
export function getActiveTabMarker() { return _st().activeTabMarker; }
|
|
732
|
+
|
|
733
|
+
// Find the tab carrying an EXACT identity marker. Returns its index, or null when the
|
|
734
|
+
// marker is on no tab — or when the scan itself could not be completed.
|
|
735
|
+
//
|
|
736
|
+
// resolveActiveTab() resolves *this session's current* tab and falls back to a URL match;
|
|
737
|
+
// this one takes the marker as an argument and has no fallback, because its callers are
|
|
738
|
+
// the destructive ones (shutdown cleanup, tab eviction). They hold a tab recorded minutes
|
|
739
|
+
// earlier: its index has shifted and it may have navigated, so a URL match there can land
|
|
740
|
+
// on a tab the USER opened on the same URL and close that instead (#112, the same
|
|
741
|
+
// principle as #68). No proof of identity means no close.
|
|
742
|
+
export async function findTabByMarker(marker) {
|
|
743
|
+
if (!marker) return null;
|
|
744
|
+
const safeMarker = String(marker).replace(/'/g, "\\'");
|
|
745
|
+
const check = `(function(){try{return (window.name==='${safeMarker}'||window.__mcpTabMarker==='${safeMarker}')?'1':'0'}catch(e){return '0'}})()`;
|
|
746
|
+
const scanScript = `tell application "Safari"
|
|
747
|
+
set w to ${getTargetWindowRef()}
|
|
748
|
+
set n to count of tabs of w
|
|
749
|
+
repeat with i from n to 1 by -1
|
|
750
|
+
try
|
|
751
|
+
if (do JavaScript "${check}" in tab i of w) is "1" then return i
|
|
752
|
+
end try
|
|
753
|
+
end repeat
|
|
754
|
+
return 0
|
|
755
|
+
end tell`;
|
|
756
|
+
// Fast daemon first; a hiccup retries once through the reliable subprocess.
|
|
757
|
+
let res = await osascriptFast(scanScript).catch(() => null);
|
|
758
|
+
if (res === null) res = await osascript(scanScript).catch(() => null);
|
|
759
|
+
if (res === null) return null;
|
|
760
|
+
const found = Number(String(res).trim());
|
|
761
|
+
return found > 0 ? found : null;
|
|
762
|
+
}
|
|
763
|
+
|
|
731
764
|
// Resolve our tracked URL to current tab index — single combined osascript call
|
|
732
765
|
async function resolveActiveTab() {
|
|
733
766
|
if (!_st().activeTabURL && !_st().activeTabMarker) return _st().activeTabIndex;
|