safari-mcp 2.5.2 โ†’ 2.6.0

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 CHANGED
@@ -1,19 +1,31 @@
1
1
  <div align="center">
2
2
 
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="assets/banner.svg">
5
+ <source media="(prefers-color-scheme: light)" srcset="assets/banner.svg">
6
+ <img src="assets/banner.svg" alt="Safari MCP โ€” Native browser automation for AI agents" width="100%">
7
+ </picture>
8
+
9
+ <br/>
10
+
3
11
  # ๐Ÿฆ Safari MCP
4
12
 
5
13
  **The only MCP server for Safari โ€” native browser automation for AI agents.**
6
14
 
7
15
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
16
  [![GitHub release](https://img.shields.io/github/v/release/achiya-automation/safari-mcp)](https://github.com/achiya-automation/safari-mcp/releases)
17
+ [![npm version](https://img.shields.io/npm/v/safari-mcp)](https://www.npmjs.com/package/safari-mcp)
18
+ [![npm downloads](https://img.shields.io/npm/dm/safari-mcp)](https://www.npmjs.com/package/safari-mcp)
19
+ [![CI](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml)
9
20
  [![Node.js](https://img.shields.io/badge/Node.js-18%2B-green)](https://nodejs.org/)
10
21
  [![macOS](https://img.shields.io/badge/macOS-only-blue)](https://www.apple.com/macos/)
11
22
  [![MCP](https://img.shields.io/badge/MCP-compatible-purple)](https://modelcontextprotocol.io)
12
- [![CI](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml)
13
- [![npm downloads](https://img.shields.io/npm/dm/safari-mcp)](https://www.npmjs.com/package/safari-mcp)
14
23
  [![GitHub stars](https://img.shields.io/github/stars/achiya-automation/safari-mcp?style=social)](https://github.com/achiya-automation/safari-mcp/stargazers)
15
24
  [![achiya-automation/safari-mcp MCP server](https://glama.ai/mcp/servers/achiya-automation/safari-mcp/badges/score.svg)](https://glama.ai/mcp/servers/achiya-automation/safari-mcp)
16
25
 
26
+ <a href="vscode:mcp/install?%7B%22safari-mcp%22%3A%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D%7D"><img src="https://img.shields.io/badge/VS_Code-Install_MCP_Server-0078d4?logo=visual-studio-code&logoColor=white" alt="Install in VS Code"></a>
27
+ <a href="https://insiders.vscode.dev/redirect?url=vscode-insiders:mcp/install?%7B%22safari-mcp%22%3A%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D%7D"><img src="https://img.shields.io/badge/VS_Code_Insiders-Install_MCP_Server-24bfa5?logo=visual-studio-code&logoColor=white" alt="Install in VS Code Insiders"></a>
28
+
17
29
  **80 tools** ยท **Zero dependencies** ยท **~5ms per command** ยท **60% less CPU than Chrome**
18
30
 
19
31
  [Quick Start](#quick-start) ยท [All 80 Tools](#tools-80) ยท [Examples](examples/) ยท [Why Safari MCP?](#safari-mcp-vs-alternatives) ยท [Architecture](#architecture) ยท [Changelog](CHANGELOG.md)
@@ -198,6 +198,17 @@ async function handleCommand(type, payload) {
198
198
  throw new Error("Tab belongs to a different profile โ€” refusing to operate on personal tabs");
199
199
  }
200
200
 
201
+ // ========== TAB OWNERSHIP GUARD ==========
202
+ // Block write operations on tabs not opened by this session.
203
+ // new_tab is always allowed (it creates owned tabs). Read-only ops are allowed on any tab.
204
+ if (type !== "new_tab" && !_readOnlyCommands.has(type) && !_isTabOwnedBySession(sessionId, tabId)) {
205
+ const anyOwned = _sessionOwnedTabs.has(sessionId) && _sessionOwnedTabs.get(sessionId).size > 0;
206
+ if (anyOwned) {
207
+ throw new Error(`โš ๏ธ Tab safety: refusing "${type}" on tab ${tabId} (${targetTab.url || 'unknown'}) โ€” not opened by this MCP session. Use safari_new_tab first.`);
208
+ }
209
+ // If no tabs owned yet, allow operation (backward compatibility for sessions that don't use new_tab)
210
+ }
211
+
201
212
  switch (type) {
202
213
  // --- Navigation ---
203
214
  case "navigate": {
@@ -1018,6 +1029,8 @@ async function handleCommand(type, payload) {
1018
1029
  // Use the requested URL (not updated.url) when page hasn't loaded yet (still about:blank)
1019
1030
  const trackUrl = (updated.url && updated.url !== "about:blank") ? updated.url : (payload.url || updated.url);
1020
1031
  _setSessionTab(sessionId, updated.id, trackUrl);
1032
+ // Register tab as owned by this session
1033
+ _addOwnedTab(sessionId, updated.id);
1021
1034
  return { title: updated.title, url: updated.url, tabIndex: updated.index + 1 };
1022
1035
  }
1023
1036
 
@@ -1026,8 +1039,12 @@ async function handleCommand(type, payload) {
1026
1039
  const query = _profileWindowId ? { windowId: _profileWindowId } : { currentWindow: true };
1027
1040
  const tabs = await browser.tabs.query(query);
1028
1041
  const target = tabs[payload.index - 1];
1029
- if (target) await browser.tabs.remove(target.id);
1042
+ if (target) {
1043
+ _removeOwnedTab(sessionId, target.id);
1044
+ await browser.tabs.remove(target.id);
1045
+ }
1030
1046
  } else {
1047
+ _removeOwnedTab(sessionId, tabId);
1031
1048
  await browser.tabs.remove(tabId);
1032
1049
  }
1033
1050
  return "Tab closed";
@@ -1616,6 +1633,42 @@ const _DEFAULT_SESSION = "__default__"; // Fallback for commands without session
1616
1633
  const SESSION_MAX_AGE_MS = 5 * 60 * 1000; // 5 min โ€” prune stale sessions
1617
1634
  const MAX_SESSIONS = 50; // Hard cap on session cache size
1618
1635
 
1636
+ // ========== TAB OWNERSHIP: track tabs opened by each MCP session ==========
1637
+ // Prevents operating on user's tabs โ€” only tabs created via new_tab are "owned".
1638
+ const _sessionOwnedTabs = new Map(); // sessionId โ†’ Set<tabId>
1639
+
1640
+ function _addOwnedTab(sessionId, tabId) {
1641
+ const sid = sessionId || _DEFAULT_SESSION;
1642
+ if (!_sessionOwnedTabs.has(sid)) _sessionOwnedTabs.set(sid, new Set());
1643
+ _sessionOwnedTabs.get(sid).add(tabId);
1644
+ }
1645
+
1646
+ function _removeOwnedTab(sessionId, tabId) {
1647
+ const sid = sessionId || _DEFAULT_SESSION;
1648
+ const set = _sessionOwnedTabs.get(sid);
1649
+ if (set) set.delete(tabId);
1650
+ }
1651
+
1652
+ function _isTabOwnedBySession(sessionId, tabId) {
1653
+ const sid = sessionId || _DEFAULT_SESSION;
1654
+ const set = _sessionOwnedTabs.get(sid);
1655
+ return set ? set.has(tabId) : false;
1656
+ }
1657
+
1658
+ // Read-only commands that don't modify the page โ€” allowed on any tab
1659
+ const _readOnlyCommands = new Set([
1660
+ "list_tabs", "read_page", "get_source", "snapshot", "accessibility_snapshot",
1661
+ "get_element", "query_all", "screenshot", "screenshot_element",
1662
+ "console_messages", "network_requests", "list_console_messages",
1663
+ "list_network_requests", "get_console_message", "get_network_request",
1664
+ "start_console", "start_network_capture", "network", "network_details",
1665
+ "console_filter", "performance_metrics", "css_coverage", "get_computed_style",
1666
+ "extract_images", "extract_links", "extract_meta", "extract_tables",
1667
+ "get_cookies", "local_storage", "session_storage",
1668
+ "get_indexed_db", "list_indexed_dbs", "detect_forms",
1669
+ "save_pdf", "analyze_page",
1670
+ ]);
1671
+
1619
1672
  function _getSessionCache(sessionId) {
1620
1673
  const sid = sessionId || _DEFAULT_SESSION;
1621
1674
  if (!_sessionTabCache.has(sid)) {
package/index.js CHANGED
@@ -59,11 +59,45 @@ const WEBKIT_MEMORY_LIMIT_MB = parseInt(process.env.MCP_WEBKIT_LIMIT_MB || "3000
59
59
  // Track tabs opened by THIS session (index โ†’ {url, openedAt})
60
60
  const _openedTabs = new Map();
61
61
 
62
+ // ========== TAB OWNERSHIP: prevent operating on user's tabs ==========
63
+ // Tracks URLs of tabs opened by this MCP session.
64
+ // Any tool that modifies a tab (navigate, click, fill, etc.) is blocked
65
+ // unless the current tab was opened via safari_new_tab.
66
+ const _ownedTabURLs = new Set();
67
+
68
+ function _isURLOwned(url) {
69
+ if (!url) return false;
70
+ if (_ownedTabURLs.has(url)) return true;
71
+ // Match ignoring query params / fragments (URL may change slightly after load)
72
+ const urlBase = url.split('?')[0].split('#')[0];
73
+ for (const owned of _ownedTabURLs) {
74
+ const ownedBase = owned.split('?')[0].split('#')[0];
75
+ if (urlBase === ownedBase) return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ function _addOwnedURL(url) {
81
+ if (url && url !== 'about:blank' && url !== 'favorites://') _ownedTabURLs.add(url);
82
+ }
83
+
84
+ function _removeOwnedURL(url) {
85
+ if (url) _ownedTabURLs.delete(url);
86
+ }
87
+
88
+ function _updateOwnedURL(oldUrl, newUrl) {
89
+ _removeOwnedURL(oldUrl);
90
+ _addOwnedURL(newUrl);
91
+ }
92
+
62
93
  function _trackTab(tabIndex, url) {
63
94
  _openedTabs.set(tabIndex, { url: url || "", openedAt: Date.now() });
95
+ _addOwnedURL(url);
64
96
  }
65
97
 
66
98
  function _untrackTab(tabIndex) {
99
+ const info = _openedTabs.get(tabIndex);
100
+ if (info?.url) _removeOwnedURL(info.url);
67
101
  _openedTabs.delete(tabIndex);
68
102
  }
69
103
 
@@ -539,9 +573,43 @@ const _nullMeansFailure = new Set([
539
573
  // CSP fallback is handled separately via isCspError check.
540
574
  ]);
541
575
 
576
+ // Operations that don't need tab ownership (read-only or tab management)
577
+ const _noOwnershipCheck = new Set([
578
+ // Tab management
579
+ "new_tab", "list_tabs", "close_tab", "switch_tab",
580
+ // Read-only โ€” don't modify the page
581
+ "read_page", "get_source", "snapshot", "accessibility_snapshot",
582
+ "get_element", "query_all", "screenshot", "screenshot_element",
583
+ "get_console", "list_console_messages", "start_console",
584
+ "get_network", "list_network_requests", "start_network_capture",
585
+ "network", "network_details", "console_filter",
586
+ "performance_metrics", "css_coverage", "get_computed_style",
587
+ "extract_images", "extract_links", "extract_meta", "extract_tables",
588
+ "get_cookies", "local_storage", "session_storage",
589
+ "get_indexed_db", "list_indexed_dbs", "detect_forms",
590
+ "save_pdf", "analyze_page",
591
+ ]);
592
+
542
593
  // Try extension first, fall back to AppleScript.
543
594
  // When SAFARI_PROFILE is set, skip extension entirely โ€” AppleScript doesn't steal focus.
544
595
  async function extensionOrFallback(extensionType, extensionPayload, fallbackFn) {
596
+ // ========== TAB OWNERSHIP GUARD ==========
597
+ // Block operations on tabs not opened by this MCP session.
598
+ // Once any tab has been opened via new_tab, ALL subsequent operations
599
+ // must target an owned tab. This prevents navigating/clicking in user's tabs.
600
+ if (!_noOwnershipCheck.has(extensionType)) {
601
+ const currentUrl = safari.getActiveTabURL();
602
+ if (_ownedTabURLs.size === 0) {
603
+ // No tabs opened yet โ€” block everything except read-only ops
604
+ const msg = `โš ๏ธ Tab safety: no tabs opened yet. Call safari_new_tab first before "${extensionType}".`;
605
+ console.error(`[Safari MCP] ${msg}`);
606
+ throw new Error(msg);
607
+ } else if (currentUrl && !_isURLOwned(currentUrl)) {
608
+ const msg = `โš ๏ธ Tab safety: refusing "${extensionType}" โ€” current tab (${currentUrl}) was not opened by this MCP session. Use safari_new_tab or safari_switch_tab to target your own tab.`;
609
+ console.error(`[Safari MCP] ${msg}`);
610
+ throw new Error(msg);
611
+ }
612
+ }
545
613
  if (_extensionConnected && !_preferAppleScript) {
546
614
  try {
547
615
  const t0 = Date.now();
@@ -587,10 +655,13 @@ server.tool(
587
655
  "Navigate to a URL in Safari. Waits for page to fully load.",
588
656
  { url: z.string().describe("URL to navigate to") },
589
657
  async ({ url }) => {
658
+ const oldUrl = safari.getActiveTabURL();
590
659
  const result = await extensionOrFallback(
591
660
  "navigate", { url },
592
661
  () => safari.navigate(url)
593
662
  );
663
+ // Update ownership: old URL โ†’ new URL (tab is still ours, just different URL)
664
+ if (oldUrl) _updateOwnedURL(oldUrl, url);
594
665
  return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
595
666
  }
596
667
  );
@@ -681,6 +752,7 @@ server.tool(
681
752
  timeout: z.coerce.number().optional().describe("Load timeout in ms (default: 30000)"),
682
753
  },
683
754
  async ({ url, maxLength, timeout }) => {
755
+ const oldUrl = safari.getActiveTabURL();
684
756
  const result = await extensionOrFallback(
685
757
  "navigate_and_read", { url, maxLength, timeout },
686
758
  async () => {
@@ -688,6 +760,8 @@ server.tool(
688
760
  return safari.readPage({ maxLength });
689
761
  }
690
762
  );
763
+ // Update ownership: old URL โ†’ new URL
764
+ if (oldUrl) _updateOwnedURL(oldUrl, url);
691
765
  return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }] };
692
766
  }
693
767
  );
@@ -1053,6 +1127,8 @@ server.tool(
1053
1127
  }
1054
1128
  if (result?.url) {
1055
1129
  safari.setActiveTabURL(result.url);
1130
+ // Also register actual URL (may differ from requested due to redirects)
1131
+ _addOwnedURL(result.url);
1056
1132
  }
1057
1133
  return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
1058
1134
  }
@@ -1075,6 +1151,20 @@ server.tool(
1075
1151
  "Switch to a specific tab by index (use safari_list_tabs to see indices). All subsequent commands (click, fill, evaluate, screenshot, scroll) will target this tab. If commands seem to run on the wrong tab, call switch_tab again to re-anchor.",
1076
1152
  { index: z.coerce.number().describe("Tab index (starting from 1)") },
1077
1153
  async ({ index }) => {
1154
+ // Tab ownership check: verify target tab is one we opened
1155
+ if (_ownedTabURLs.size > 0) {
1156
+ // Get target tab's URL via list_tabs before switching
1157
+ try {
1158
+ const tabs = await safari.listTabs();
1159
+ const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
1160
+ const target = parsed.find(t => t.index === index);
1161
+ if (target && target.url && !_isURLOwned(target.url)) {
1162
+ const msg = `โš ๏ธ Tab safety: refusing switch_tab to index ${index} (${target.url}) โ€” not opened by this MCP session. Use safari_new_tab to open your own tab.`;
1163
+ console.error(`[Safari MCP] ${msg}`);
1164
+ return { content: [{ type: "text", text: msg }], isError: true };
1165
+ }
1166
+ } catch {}
1167
+ }
1078
1168
  const result = await extensionOrFallback(
1079
1169
  "switch_tab", { index },
1080
1170
  () => safari.switchTab(index)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.5.2",
3
+ "version": "2.6.0",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents โ€” native macOS, zero Chrome overhead. 80 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
package/safari.js CHANGED
@@ -97,7 +97,7 @@ process.on("uncaughtException", (err) => { console.error("Uncaught:", err); clea
97
97
  let _activeTabIndex = null; // null = use front document (default)
98
98
  let _activeTabURL = null; // URL-based tracking (stable even when tabs shift)
99
99
  let _lastResolveTime = 0; // Cache: skip resolve if verified recently
100
- const RESOLVE_CACHE_MS = 1500; // Re-verify tab every 1.5 seconds (was 5s โ€” too stale)
100
+ const RESOLVE_CACHE_MS = 0; // Always re-verify โ€” cached indices go stale when tabs open/close
101
101
 
102
102
  // ========== DIAGNOSTIC LOG ==========
103
103
  // File-based log for profile/focus issues โ€” survives MCP restart, visible to user