safari-mcp 2.5.3 → 2.6.1

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,46 @@ 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 / trailing slashes (URL may change slightly after load)
72
+ const normalize = (u) => u.split('?')[0].split('#')[0].replace(/\/+$/, '');
73
+ const urlBase = normalize(url);
74
+ for (const owned of _ownedTabURLs) {
75
+ const ownedBase = normalize(owned);
76
+ if (urlBase === ownedBase) return true;
77
+ }
78
+ return false;
79
+ }
80
+
81
+ function _addOwnedURL(url) {
82
+ if (url && url !== 'about:blank' && url !== 'favorites://') _ownedTabURLs.add(url);
83
+ }
84
+
85
+ function _removeOwnedURL(url) {
86
+ if (url) _ownedTabURLs.delete(url);
87
+ }
88
+
89
+ function _updateOwnedURL(oldUrl, newUrl) {
90
+ _removeOwnedURL(oldUrl);
91
+ _addOwnedURL(newUrl);
92
+ }
93
+
62
94
  function _trackTab(tabIndex, url) {
63
95
  _openedTabs.set(tabIndex, { url: url || "", openedAt: Date.now() });
96
+ _addOwnedURL(url);
64
97
  }
65
98
 
66
99
  function _untrackTab(tabIndex) {
100
+ const info = _openedTabs.get(tabIndex);
101
+ if (info?.url) _removeOwnedURL(info.url);
67
102
  _openedTabs.delete(tabIndex);
68
103
  }
69
104
 
@@ -539,9 +574,43 @@ const _nullMeansFailure = new Set([
539
574
  // CSP fallback is handled separately via isCspError check.
540
575
  ]);
541
576
 
577
+ // Operations that don't need tab ownership (read-only or tab management)
578
+ const _noOwnershipCheck = new Set([
579
+ // Tab management
580
+ "new_tab", "list_tabs", "close_tab", "switch_tab",
581
+ // Read-only — don't modify the page
582
+ "read_page", "get_source", "snapshot", "accessibility_snapshot",
583
+ "get_element", "query_all", "screenshot", "screenshot_element",
584
+ "get_console", "list_console_messages", "start_console",
585
+ "get_network", "list_network_requests", "start_network_capture",
586
+ "network", "network_details", "console_filter",
587
+ "performance_metrics", "css_coverage", "get_computed_style",
588
+ "extract_images", "extract_links", "extract_meta", "extract_tables",
589
+ "get_cookies", "local_storage", "session_storage",
590
+ "get_indexed_db", "list_indexed_dbs", "detect_forms",
591
+ "save_pdf", "analyze_page",
592
+ ]);
593
+
542
594
  // Try extension first, fall back to AppleScript.
543
595
  // When SAFARI_PROFILE is set, skip extension entirely — AppleScript doesn't steal focus.
544
596
  async function extensionOrFallback(extensionType, extensionPayload, fallbackFn) {
597
+ // ========== TAB OWNERSHIP GUARD ==========
598
+ // Block operations on tabs not opened by this MCP session.
599
+ // Once any tab has been opened via new_tab, ALL subsequent operations
600
+ // must target an owned tab. This prevents navigating/clicking in user's tabs.
601
+ if (!_noOwnershipCheck.has(extensionType)) {
602
+ const currentUrl = safari.getActiveTabURL();
603
+ if (_ownedTabURLs.size === 0) {
604
+ // No tabs opened yet — block everything except read-only ops
605
+ const msg = `⚠️ Tab safety: no tabs opened yet. Call safari_new_tab first before "${extensionType}".`;
606
+ console.error(`[Safari MCP] ${msg}`);
607
+ throw new Error(msg);
608
+ } else if (currentUrl && !_isURLOwned(currentUrl)) {
609
+ 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.`;
610
+ console.error(`[Safari MCP] ${msg}`);
611
+ throw new Error(msg);
612
+ }
613
+ }
545
614
  if (_extensionConnected && !_preferAppleScript) {
546
615
  try {
547
616
  const t0 = Date.now();
@@ -587,10 +656,13 @@ server.tool(
587
656
  "Navigate to a URL in Safari. Waits for page to fully load.",
588
657
  { url: z.string().describe("URL to navigate to") },
589
658
  async ({ url }) => {
659
+ const oldUrl = safari.getActiveTabURL();
590
660
  const result = await extensionOrFallback(
591
661
  "navigate", { url },
592
662
  () => safari.navigate(url)
593
663
  );
664
+ // Update ownership: old URL → new URL (tab is still ours, just different URL)
665
+ if (oldUrl) _updateOwnedURL(oldUrl, url);
594
666
  return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
595
667
  }
596
668
  );
@@ -681,6 +753,7 @@ server.tool(
681
753
  timeout: z.coerce.number().optional().describe("Load timeout in ms (default: 30000)"),
682
754
  },
683
755
  async ({ url, maxLength, timeout }) => {
756
+ const oldUrl = safari.getActiveTabURL();
684
757
  const result = await extensionOrFallback(
685
758
  "navigate_and_read", { url, maxLength, timeout },
686
759
  async () => {
@@ -688,6 +761,8 @@ server.tool(
688
761
  return safari.readPage({ maxLength });
689
762
  }
690
763
  );
764
+ // Update ownership: old URL → new URL
765
+ if (oldUrl) _updateOwnedURL(oldUrl, url);
691
766
  return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }] };
692
767
  }
693
768
  );
@@ -1042,19 +1117,29 @@ server.tool(
1042
1117
  }
1043
1118
  }
1044
1119
 
1045
- const result = await extensionOrFallback(
1120
+ const rawResult = await extensionOrFallback(
1046
1121
  "new_tab", { url },
1047
1122
  () => safari.newTab(url)
1048
1123
  );
1124
+ // AppleScript fallback returns a JSON string; extension returns an object — normalize
1125
+ let result = rawResult;
1126
+ if (typeof rawResult === 'string') {
1127
+ try { result = JSON.parse(rawResult); } catch {}
1128
+ }
1049
1129
  // Sync safari.js tracking when extension handled new_tab
1050
1130
  if (result?.tabIndex) {
1051
1131
  safari.setActiveTabIndex(result.tabIndex);
1052
1132
  _trackTab(result.tabIndex, url);
1053
1133
  }
1054
- if (result?.url) {
1055
- safari.setActiveTabURL(result.url);
1134
+ if (result?.url || url) {
1135
+ // Prefer requested URL over about:blank for tracking (page hasn't loaded yet)
1136
+ const trackUrl = (!result?.url || result.url === 'about:blank') && url ? url : result.url;
1137
+ safari.setActiveTabURL(trackUrl);
1138
+ // Also register actual URL (may differ from requested due to redirects)
1139
+ _addOwnedURL(trackUrl);
1140
+ if (url && url !== trackUrl) _addOwnedURL(url); // also own the requested URL (handles www redirects)
1056
1141
  }
1057
- return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
1142
+ return { content: [{ type: "text", text: typeof rawResult === 'string' ? rawResult : JSON.stringify(result) }] };
1058
1143
  }
1059
1144
  );
1060
1145
 
@@ -1075,6 +1160,20 @@ server.tool(
1075
1160
  "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
1161
  { index: z.coerce.number().describe("Tab index (starting from 1)") },
1077
1162
  async ({ index }) => {
1163
+ // Tab ownership check: verify target tab is one we opened
1164
+ if (_ownedTabURLs.size > 0) {
1165
+ // Get target tab's URL via list_tabs before switching
1166
+ try {
1167
+ const tabs = await safari.listTabs();
1168
+ const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
1169
+ const target = parsed.find(t => t.index === index);
1170
+ if (target && target.url && !_isURLOwned(target.url)) {
1171
+ 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.`;
1172
+ console.error(`[Safari MCP] ${msg}`);
1173
+ return { content: [{ type: "text", text: msg }], isError: true };
1174
+ }
1175
+ } catch {}
1176
+ }
1078
1177
  const result = await extensionOrFallback(
1079
1178
  "switch_tab", { index },
1080
1179
  () => safari.switchTab(index)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.5.3",
3
+ "version": "2.6.1",
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",