safari-mcp 2.5.3 → 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 +14 -2
- package/extension/background.js +54 -1
- package/index.js +90 -0
- 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,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.
|
|
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",
|