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 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
- // Close by URL (not index) — indices shift as tabs are closed
185
- const urlsToClose = [..._openedTabs.values()].map(v => v.url).filter(Boolean);
186
- for (const url of urlsToClose) {
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
- // Re-resolve index by URL before each close (indices shift after each closure)
189
- const tabs = await safari.listTabs();
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 info = _openedTabs.get(oldestIdx);
260
- if (info?.url) {
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
- await extensionOrFallback(
2867
- "close_tab",
2868
- { index: oldestIdx },
2869
- () => safari.closeTab(oldestIdx)
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()}`); // own the popup for THIS session, else the next interaction trips the tab-safety guard
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()}`); // own the new tab for THIS session, else the next interaction trips the tab-safety guard
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
  }
@@ -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
- _openedTabs.set(tabIndex, { url: url || "", openedAt: Date.now(), sessionId });
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.13",
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;