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 +14 -2
- package/extension/background.js +54 -1
- package/index.js +103 -4
- package/package.json +1 -1
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
|
[](https://opensource.org/licenses/MIT)
|
|
8
16
|
[](https://github.com/achiya-automation/safari-mcp/releases)
|
|
17
|
+
[](https://www.npmjs.com/package/safari-mcp)
|
|
18
|
+
[](https://www.npmjs.com/package/safari-mcp)
|
|
19
|
+
[](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml)
|
|
9
20
|
[](https://nodejs.org/)
|
|
10
21
|
[](https://www.apple.com/macos/)
|
|
11
22
|
[](https://modelcontextprotocol.io)
|
|
12
|
-
[](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml)
|
|
13
|
-
[](https://www.npmjs.com/package/safari-mcp)
|
|
14
23
|
[](https://github.com/achiya-automation/safari-mcp/stargazers)
|
|
15
24
|
[](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)
|
package/extension/background.js
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|