safari-mcp 2.7.1 → 2.7.3
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 +17 -5
- package/extension/background.js +2 -2
- package/extension/content.js +1 -1
- package/extension/manifest.json +1 -1
- package/extension/popup/popup.html +1 -1
- package/index.js +31 -8
- package/mcp-helpers.js +1 -1
- package/package.json +3 -3
- package/safari.js +19 -2
- package/extension/popup/popup.js +0 -57
package/README.md
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<
|
|
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>
|
|
3
|
+
<img src="social-preview.png" alt="Safari MCP Server — 80 native browser automation tools for AI agents on macOS" width="100%">
|
|
8
4
|
|
|
9
5
|
<br/>
|
|
10
6
|
|
|
@@ -25,6 +21,7 @@
|
|
|
25
21
|
|
|
26
22
|
<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
23
|
<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>
|
|
24
|
+
<a href="https://paypal.me/achiyaC123"><img src="https://img.shields.io/badge/Sponsor-PayPal-blue?logo=paypal" alt="Sponsor"></a>
|
|
28
25
|
|
|
29
26
|
**80 tools** · **Zero dependencies** · **~5ms per command** · **60% less CPU than Chrome**
|
|
30
27
|
|
|
@@ -533,6 +530,21 @@ The codebase is two files:
|
|
|
533
530
|
|
|
534
531
|
---
|
|
535
532
|
|
|
533
|
+
## Sponsors
|
|
534
|
+
|
|
535
|
+
Safari MCP is free and open source. If it saves you time or CPU cycles, consider supporting its development:
|
|
536
|
+
|
|
537
|
+
<a href="https://paypal.me/achiyaC123"><img src="https://img.shields.io/badge/Sponsor-PayPal-blue?logo=paypal" alt="Sponsor via PayPal"></a>
|
|
538
|
+
|
|
539
|
+
Your support funds:
|
|
540
|
+
- 🧪 Testing across macOS versions and Safari releases
|
|
541
|
+
- 🛠️ New tools and features
|
|
542
|
+
- 📖 Documentation and examples
|
|
543
|
+
|
|
544
|
+
[Become the first sponsor!](https://paypal.me/achiyaC123)
|
|
545
|
+
|
|
546
|
+
---
|
|
547
|
+
|
|
536
548
|
## Star History
|
|
537
549
|
|
|
538
550
|
If Safari MCP saved you from Chrome overhead, consider giving it a ⭐
|
package/extension/background.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
const HTTP_URL = "http://127.0.0.1:9224";
|
|
6
6
|
let isConnected = false;
|
|
7
7
|
let pollAbort = null;
|
|
8
|
-
let _targetProfile = null; // Profile name from server (e.g. "
|
|
8
|
+
let _targetProfile = null; // Profile name from server (e.g. "Automations")
|
|
9
9
|
let _profileWindowId = null; // Discovered windowId for the profile
|
|
10
10
|
let _enabled = true; // Toggle from popup — when false, stops polling and rejects commands
|
|
11
11
|
let _reconnectTimer = null; // Single reconnect timer — prevents exponential growth
|
|
@@ -1735,7 +1735,7 @@ browser.tabs.onRemoved.addListener((tabId) => {
|
|
|
1735
1735
|
|
|
1736
1736
|
// Verify this extension instance is running in the expected profile.
|
|
1737
1737
|
// Safari extensions see only their own profile's windows/tabs.
|
|
1738
|
-
// If the server expects
|
|
1738
|
+
// If the server expects a specific profile but this worker's windows don't match, we're in the wrong profile.
|
|
1739
1739
|
async function _verifyProfileMatch(expectedProfile) {
|
|
1740
1740
|
try {
|
|
1741
1741
|
const allWindows = await browser.windows.getAll({ populate: true });
|
package/extension/content.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Content script — runs at document_start in MAIN world (before page scripts)
|
|
2
2
|
// Purpose: Monkey-patch attachShadow to capture CLOSED shadow roots.
|
|
3
|
-
// Without this, Reddit
|
|
3
|
+
// Without this, Reddit and other sites using closed shadow DOM
|
|
4
4
|
// are invisible to snapshot/click/fill.
|
|
5
5
|
// Runs in MAIN world via manifest "world": "MAIN" — no script injection needed,
|
|
6
6
|
// so CSP cannot block it.
|
package/extension/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Safari MCP Bridge",
|
|
4
4
|
"description": "Connects AI agents to Safari — fast JS execution, screenshots, clicks with your real cookies/logins",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.7.2",
|
|
6
6
|
"icons": {
|
|
7
7
|
"48": "images/icon-48.png",
|
|
8
8
|
"96": "images/icon-96.png",
|
package/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// 1. Safari Web Extension (fast, ~5-20ms, keeps logins) — when extension is connected
|
|
4
4
|
// 2. AppleScript + Swift daemon (~5ms, keeps logins) — always available
|
|
5
5
|
//
|
|
6
|
-
// Extension transport:
|
|
6
|
+
// Extension transport: HTTP polling (Safari — WebSocket blocked by Apple)
|
|
7
7
|
|
|
8
8
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
9
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -84,6 +84,13 @@ function _isURLOwned(url) {
|
|
|
84
84
|
if (new URL(owned).origin === urlOrigin && urlBase.startsWith(normalize(owned))) return true;
|
|
85
85
|
} catch {}
|
|
86
86
|
}
|
|
87
|
+
// Broader same-origin check: if we own ANY URL on this origin, treat all same-origin URLs as owned.
|
|
88
|
+
// This handles redirects like /article/new → /article/edit/ID where path prefix doesn't match.
|
|
89
|
+
for (const owned of _ownedTabURLs) {
|
|
90
|
+
try {
|
|
91
|
+
if (new URL(owned).origin === urlOrigin) return true;
|
|
92
|
+
} catch {}
|
|
93
|
+
}
|
|
87
94
|
} catch {}
|
|
88
95
|
return false;
|
|
89
96
|
}
|
|
@@ -386,6 +393,14 @@ try {
|
|
|
386
393
|
const { execFile: execFileCb } = await import("node:child_process");
|
|
387
394
|
const { promisify: pfy } = await import("node:util");
|
|
388
395
|
const execFileAsync = pfy(execFileCb);
|
|
396
|
+
// Don't launch Safari if it's not running
|
|
397
|
+
try {
|
|
398
|
+
await execFileAsync("pgrep", ["-x", "Safari"], { timeout: 2000 });
|
|
399
|
+
} catch {
|
|
400
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
401
|
+
res.end(JSON.stringify({ match: false, error: "Safari is not running" }));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
389
404
|
const script = `tell application "Safari"
|
|
390
405
|
repeat with w in every window
|
|
391
406
|
repeat with t in every tab of w
|
|
@@ -635,15 +650,19 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
|
|
|
635
650
|
// must target an owned tab. This prevents navigating/clicking in user's tabs.
|
|
636
651
|
if (!_noOwnershipCheck.has(extensionType)) {
|
|
637
652
|
const currentUrl = safari.getActiveTabURL();
|
|
638
|
-
if (_ownedTabURLs.size === 0) {
|
|
653
|
+
if (_ownedTabURLs.size === 0 && _openedTabs.size === 0) {
|
|
639
654
|
// No tabs opened yet — block everything except read-only ops
|
|
640
655
|
const msg = `⚠️ Tab safety: no tabs opened yet. Call safari_new_tab first before "${extensionType}".`;
|
|
641
656
|
console.error(`[Safari MCP] ${msg}`);
|
|
642
657
|
throw new Error(msg);
|
|
643
658
|
} else if (currentUrl && !_isURLOwned(currentUrl)) {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
659
|
+
// about:blank tabs are owned if we have any tracked tabs (new_tab creates them at about:blank)
|
|
660
|
+
const isBlankOwned = (currentUrl === 'about:blank' || currentUrl === 'missing value') && _openedTabs.size > 0;
|
|
661
|
+
if (!isBlankOwned) {
|
|
662
|
+
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.`;
|
|
663
|
+
console.error(`[Safari MCP] ${msg}`);
|
|
664
|
+
throw new Error(msg);
|
|
665
|
+
}
|
|
647
666
|
}
|
|
648
667
|
}
|
|
649
668
|
if (_extensionConnected && !_preferAppleScript) {
|
|
@@ -1203,9 +1222,13 @@ server.tool(
|
|
|
1203
1222
|
const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
|
|
1204
1223
|
const target = parsed.find(t => t.index === index);
|
|
1205
1224
|
if (target && target.url && !_isURLOwned(target.url)) {
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1225
|
+
// about:blank / missing value tabs are owned if tracked in _openedTabs
|
|
1226
|
+
const isBlankOwned = (target.url === 'about:blank' || target.url === 'missing value') && _openedTabs.has(index);
|
|
1227
|
+
if (!isBlankOwned) {
|
|
1228
|
+
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.`;
|
|
1229
|
+
console.error(`[Safari MCP] ${msg}`);
|
|
1230
|
+
return { content: [{ type: "text", text: msg }], isError: true };
|
|
1231
|
+
}
|
|
1209
1232
|
}
|
|
1210
1233
|
} catch {}
|
|
1211
1234
|
}
|
package/mcp-helpers.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// MCP Click/Interaction Helpers — injected into every page
|
|
2
2
|
// This file is loaded by safari.js at startup, escaped, and injected via AppleScript `do JavaScript`.
|
|
3
3
|
// It is also used by the extension's execInTab for the AppleScript fallback path.
|
|
4
|
-
// IMPORTANT: Keep
|
|
4
|
+
// IMPORTANT: Keep compatible with all browsers — no ES6+ modules, use var where possible.
|
|
5
5
|
|
|
6
6
|
if (window.__mcpVersion !== 5) {
|
|
7
7
|
window.__mcpVersion = 5;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.3",
|
|
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",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"license": "MIT",
|
|
40
40
|
"homepage": "https://achiya-automation.com",
|
|
41
41
|
"funding": {
|
|
42
|
-
"type": "
|
|
43
|
-
"url": "https://
|
|
42
|
+
"type": "individual",
|
|
43
|
+
"url": "https://paypal.me/achiyaC123"
|
|
44
44
|
},
|
|
45
45
|
"repository": {
|
|
46
46
|
"type": "git",
|
package/safari.js
CHANGED
|
@@ -89,6 +89,21 @@ function cleanupHelper() {
|
|
|
89
89
|
process.on("exit", cleanupHelper);
|
|
90
90
|
process.on("uncaughtException", (err) => { console.error("Uncaught:", err); cleanupHelper(); process.exit(1); });
|
|
91
91
|
|
|
92
|
+
// ========== SAFARI RUNNING CHECK ==========
|
|
93
|
+
// Prevent AppleScript from auto-launching Safari when it's closed
|
|
94
|
+
async function isSafariRunning() {
|
|
95
|
+
try {
|
|
96
|
+
const { stdout } = await execFileAsync("pgrep", ["-x", "Safari"], { timeout: 2000 });
|
|
97
|
+
return stdout.trim().length > 0;
|
|
98
|
+
} catch {
|
|
99
|
+
return false; // pgrep exits 1 when no match
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function safariNotRunningError() {
|
|
104
|
+
return new Error("Safari is not running. Open Safari manually before using Safari MCP tools.");
|
|
105
|
+
}
|
|
106
|
+
|
|
92
107
|
// ========== CLIPBOARD LOCK ==========
|
|
93
108
|
// Prevents concurrent clipboard operations from clobbering the user's clipboard.
|
|
94
109
|
// While locked, any new clipboard operation waits until the current one completes.
|
|
@@ -326,6 +341,7 @@ async function resolveActiveTab() {
|
|
|
326
341
|
|
|
327
342
|
// Run AppleScript — uses execFile (safe, isolated, for complex scripts)
|
|
328
343
|
async function osascript(script, { timeout = 10000 } = {}) {
|
|
344
|
+
if (!(await isSafariRunning())) throw safariNotRunningError();
|
|
329
345
|
try {
|
|
330
346
|
const { stdout } = await execFileAsync("osascript", ["-e", script], {
|
|
331
347
|
timeout,
|
|
@@ -349,6 +365,7 @@ async function osascript(script, { timeout = 10000 } = {}) {
|
|
|
349
365
|
|
|
350
366
|
// osascriptFast: uses persistent Swift daemon (~5ms) — 18x faster than subprocess (~90ms)
|
|
351
367
|
async function osascriptFast(script, { timeout = 10000 } = {}) {
|
|
368
|
+
if (!(await isSafariRunning())) throw safariNotRunningError();
|
|
352
369
|
if (!_helperProc) startHelper();
|
|
353
370
|
if (_helperProc) {
|
|
354
371
|
try {
|
|
@@ -659,7 +676,7 @@ export async function navigate(url) {
|
|
|
659
676
|
const state = await runJS('document.readyState', { timeout: 5000 });
|
|
660
677
|
if (state === 'complete' || state === 'interactive') {
|
|
661
678
|
result = await runJS(
|
|
662
|
-
`JSON.stringify({title:document.title,url:location.href,blocked:document.title.includes('cannot open')||document.title.includes('
|
|
679
|
+
`JSON.stringify({title:document.title,url:location.href,blocked:document.title.includes('cannot open')||document.title.includes('\u05D0\u05D9\u05DF \u05D0\u05E4\u05E9\u05E8\u05D5\u05EA')})`,
|
|
663
680
|
{ timeout: 5000 }
|
|
664
681
|
);
|
|
665
682
|
if (state === 'complete') break;
|
|
@@ -2069,7 +2086,7 @@ export async function uploadFile({ selector, filePath }) {
|
|
|
2069
2086
|
click button "Cancel" of sheet 1 of w
|
|
2070
2087
|
on error
|
|
2071
2088
|
try
|
|
2072
|
-
click button "
|
|
2089
|
+
click button "\u05D1\u05D9\u05D8\u05D5\u05DC" of sheet 1 of w -- "Cancel" in Hebrew locale
|
|
2073
2090
|
on error
|
|
2074
2091
|
key code 53
|
|
2075
2092
|
end try
|
package/extension/popup/popup.js
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
// v6 — asks background script for real-time status, falls back to storage
|
|
2
|
-
async function checkStatus() {
|
|
3
|
-
const enabled = await getVal("mcpEnabled", true);
|
|
4
|
-
document.getElementById("enableToggle").checked = enabled;
|
|
5
|
-
|
|
6
|
-
if (!enabled) {
|
|
7
|
-
setStatus("paused", "Paused", "Toggle to resume");
|
|
8
|
-
return;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
// Ask background script for real-time status
|
|
12
|
-
try {
|
|
13
|
-
const resp = await browser.runtime.sendMessage({ action: "getStatus" });
|
|
14
|
-
if (resp) {
|
|
15
|
-
if (resp.connected) { setStatus("connected", "Connected", "Port 9224"); return; }
|
|
16
|
-
if (!resp.enabled) { setStatus("paused", "Paused", "Toggle to resume"); return; }
|
|
17
|
-
}
|
|
18
|
-
} catch {}
|
|
19
|
-
|
|
20
|
-
// Fallback: read from storage
|
|
21
|
-
const status = await getVal("mcpStatus", "checking");
|
|
22
|
-
switch (status) {
|
|
23
|
-
case "connected":
|
|
24
|
-
setStatus("connected", "Connected", "Port 9224");
|
|
25
|
-
break;
|
|
26
|
-
case "paused":
|
|
27
|
-
setStatus("paused", "Paused", "Toggle to resume");
|
|
28
|
-
break;
|
|
29
|
-
case "checking":
|
|
30
|
-
setStatus("checking", "Connecting...", "Trying port 9224...");
|
|
31
|
-
break;
|
|
32
|
-
default:
|
|
33
|
-
setStatus("disconnected", "Not connected", "Start the MCP server to connect");
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function setStatus(dotClass, text, info) {
|
|
38
|
-
document.getElementById("statusDot").className = "dot " + dotClass;
|
|
39
|
-
document.getElementById("statusText").textContent = text;
|
|
40
|
-
document.getElementById("info").textContent = info;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
document.getElementById("enableToggle").addEventListener("change", async (e) => {
|
|
44
|
-
const enabled = e.target.checked;
|
|
45
|
-
await browser.storage.local.set({ mcpEnabled: enabled });
|
|
46
|
-
try { browser.runtime.sendMessage({ action: "setEnabled", enabled }); } catch {}
|
|
47
|
-
setTimeout(checkStatus, 500);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
async function getVal(key, defaultVal) {
|
|
51
|
-
try {
|
|
52
|
-
const data = await browser.storage.local.get(key);
|
|
53
|
-
return data[key] !== undefined ? data[key] : defaultVal;
|
|
54
|
-
} catch { return defaultVal; }
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
checkStatus();
|