safari-mcp 2.16.0 → 2.16.2
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 -2
- package/extension/background.js +4 -0
- package/glama.json +1 -1
- package/index.js +10 -3
- package/injected-escape.js +11 -1
- package/package.json +2 -2
- package/safari.js +58 -0
- package/scripts/postinstall.cjs +9 -4
package/README.md
CHANGED
|
@@ -64,12 +64,27 @@ Native WebKit. ~60% less CPU. Background operation. 97 tools. One `npx` command.
|
|
|
64
64
|
|
|
65
65
|
---
|
|
66
66
|
|
|
67
|
+
## In users' own words
|
|
68
|
+
|
|
69
|
+
Not solicited testimonials — quotes lifted from the public issue tracker, each linked to the thread it came from.
|
|
70
|
+
|
|
71
|
+
> "I run multiple Pi sessions/subagents against my normal Safari profile in parallel. **Sharing its cookies and logins is intentional.**"
|
|
72
|
+
> — [@maxim](https://github.com/achiya-automation/safari-mcp/issues/76), on running concurrent agents against a real browser
|
|
73
|
+
|
|
74
|
+
> "The server has a deliberate tab-ownership model … **the code is careful about this, and for the default case that's the right safety posture.**"
|
|
75
|
+
> — [@turner-moore](https://github.com/achiya-automation/safari-mcp/issues/92), on why the guards refuse to touch your tabs
|
|
76
|
+
|
|
77
|
+
> "Direct local validation from the package: **Safari MCP doctor 6/6.**"
|
|
78
|
+
> — [@jrepp](https://github.com/achiya-automation/safari-mcp/pull/53), who found and fixed a queue-alignment bug in the focus helper
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
67
82
|
## Quick Start
|
|
68
83
|
|
|
69
84
|
### Prerequisites
|
|
70
85
|
|
|
71
86
|
- macOS (any version with Safari)
|
|
72
|
-
- Node.js
|
|
87
|
+
- Node.js 20+
|
|
73
88
|
- Safari → Settings → Advanced → **Show features for web developers** ✓
|
|
74
89
|
- Safari → Develop → **Allow JavaScript from Apple Events** ✓
|
|
75
90
|
|
|
@@ -869,7 +884,7 @@ If Safari MCP saves you from Chrome overhead, **a star helps others discover it:
|
|
|
869
884
|
|
|
870
885
|
[](https://github.com/achiya-automation/safari-mcp)
|
|
871
886
|
|
|
872
|
-
[Share on Twitter/X](https://twitter.com/intent/tweet?text=Safari%20MCP%20%E2%80%94%20Stop%20running%20Chrome%20just%20so%20your%20AI%20agent%20can%20browse.%
|
|
887
|
+
[Share on Twitter/X](https://twitter.com/intent/tweet?text=Safari%20MCP%20%E2%80%94%20Stop%20running%20Chrome%20just%20so%20your%20AI%20agent%20can%20browse.%2097%20tools%2C%20native%20Safari%2C%2060%25%20less%20CPU.&url=https%3A%2F%2Fgithub.com%2Fachiya-automation%2Fsafari-mcp) · [Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fgithub.com%2Fachiya-automation%2Fsafari-mcp) · [Write about it](https://dev.to/)
|
|
873
888
|
|
|
874
889
|
[](https://star-history.com/#achiya-automation/safari-mcp&Date)
|
|
875
890
|
|
package/extension/background.js
CHANGED
|
@@ -1941,6 +1941,10 @@ browser.tabs.onRemoved.addListener((tabId) => {
|
|
|
1941
1941
|
async function _verifyProfileMatch(expectedProfile) {
|
|
1942
1942
|
try {
|
|
1943
1943
|
const allWindows = await browser.windows.getAll({ populate: true });
|
|
1944
|
+
// No window in this profile → it cannot be the profile the server drives, and probing
|
|
1945
|
+
// would have to CREATE one: browser.tabs.create() with no open window opens a fresh window
|
|
1946
|
+
// that jumps to the front. That is the personal profile popping up every reconnect.
|
|
1947
|
+
if (!allWindows.length) return false;
|
|
1944
1948
|
// Check if any window's tab titles contain the profile name pattern "ProfileName —"
|
|
1945
1949
|
// Safari profile windows show: "ProfileName — Tab Title" in window name
|
|
1946
1950
|
// But the extension only sees its OWN profile's windows, so we check if tabs exist at all.
|
package/glama.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://glama.ai/mcp/schemas/server.json",
|
|
3
3
|
"name": "Safari MCP Server",
|
|
4
|
-
"description": "Native Safari browser automation for AI agents.
|
|
4
|
+
"description": "Native Safari browser automation for AI agents. 97 tools via AppleScript — zero overhead, keeps logins, runs silently in background.",
|
|
5
5
|
"repository": "https://github.com/achiya-automation/safari-mcp",
|
|
6
6
|
"homepage": "https://github.com/achiya-automation/safari-mcp",
|
|
7
7
|
"author": "achiya-automation",
|
package/index.js
CHANGED
|
@@ -403,7 +403,7 @@ try {
|
|
|
403
403
|
const execFileAsync = pfy(execFileCb);
|
|
404
404
|
// Don't launch Safari if it's not running
|
|
405
405
|
try {
|
|
406
|
-
await execFileAsync("pgrep", ["-x", "Safari"], { timeout:
|
|
406
|
+
await execFileAsync("pgrep", ["-x", "Safari"], { timeout: 1000 });
|
|
407
407
|
} catch {
|
|
408
408
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
409
409
|
res.end(JSON.stringify({ match: false, error: "Safari is not running" }));
|
|
@@ -423,7 +423,11 @@ try {
|
|
|
423
423
|
end repeat
|
|
424
424
|
return "notfound"
|
|
425
425
|
end tell`;
|
|
426
|
-
|
|
426
|
+
// Must finish well inside the extension's 5s fetch timeout: if the extension aborts
|
|
427
|
+
// first it never caches the verdict, so it re-probes every few seconds — and each probe
|
|
428
|
+
// opens a *new window* in any profile that has none (the personal profile popping up).
|
|
429
|
+
// 1s pgrep + 2.5s osascript = 3.5s worst case, and the catch below still answers 200.
|
|
430
|
+
const { stdout } = await execFileAsync("osascript", ["-e", script], { timeout: 2500 });
|
|
427
431
|
const out = stdout.trim();
|
|
428
432
|
if (out === "match") {
|
|
429
433
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -2397,7 +2401,10 @@ try {
|
|
|
2397
2401
|
let version = "?";
|
|
2398
2402
|
try { version = JSON.parse(readFileSync(pkgPath, "utf8")).version; } catch {}
|
|
2399
2403
|
console.error("");
|
|
2400
|
-
|
|
2404
|
+
// Count tools at runtime — a hardcoded number here drifted to 96 while the code had 97.
|
|
2405
|
+
const toolCount = Object.keys(server._registeredTools ?? {}).length;
|
|
2406
|
+
const toolsPhrase = toolCount ? `${toolCount} tools, ` : "";
|
|
2407
|
+
console.error(`[Safari MCP] 🦁 v${version} ready — ${toolsPhrase}native WebKit, zero Chrome.`);
|
|
2401
2408
|
console.error(`[Safari MCP] ⭐ Like it? Star: https://github.com/achiya-automation/safari-mcp`);
|
|
2402
2409
|
console.error("");
|
|
2403
2410
|
try { writeFileSync(bannerStateFile, String(Date.now()), { mode: 0o600 }); } catch {}
|
package/injected-escape.js
CHANGED
|
@@ -7,9 +7,19 @@
|
|
|
7
7
|
// ORDER MATTERS: backslash is escaped BEFORE the quote, or the backslash inserted in front
|
|
8
8
|
// of the quote gets doubled and the string breaks out. Locked by test/escaping.test.mjs
|
|
9
9
|
// and test/injection-safety.test.mjs.
|
|
10
|
+
// NOTE: \r and \n are escaped to \\r/\\n so that multiline values round-trip as escape
|
|
11
|
+
// sequences inside the single-quoted JS literal rather than being silently space-flattened
|
|
12
|
+
// by runJS. U+2028/U+2029 are ECMAScript LineTerminators and must also be spelled as
|
|
13
|
+
// escape sequences -- raw U+2028/U+2029 inside a regex literal is a SyntaxError.
|
|
10
14
|
|
|
11
15
|
export function escJsSingleQuote(s) {
|
|
12
|
-
return String(s)
|
|
16
|
+
return String(s)
|
|
17
|
+
.replace(/\\/g, "\\\\")
|
|
18
|
+
.replace(/'/g, "\\'")
|
|
19
|
+
.replace(/\r/g, "\\r")
|
|
20
|
+
.replace(/\n/g, "\\n")
|
|
21
|
+
.replace(/\u2028/g, "\\u2028")
|
|
22
|
+
.replace(/\u2029/g, "\\u2029");
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
// Also strips CR/LF: a raw newline would close the AppleScript string and allow injection.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.16.
|
|
3
|
+
"version": "2.16.2",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
|
-
"description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead.
|
|
5
|
+
"description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 97 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "index.js",
|
|
8
8
|
"bin": {
|
package/safari.js
CHANGED
|
@@ -3262,6 +3262,64 @@ async function _screenshotFronted({ fullPage }) {
|
|
|
3262
3262
|
}
|
|
3263
3263
|
}
|
|
3264
3264
|
|
|
3265
|
+
// Fallback: full-screen capture + crop to the window rect.
|
|
3266
|
+
//
|
|
3267
|
+
// macOS 26 (Tahoe) broke `screencapture -l<windowId>` for Safari: it exits 1
|
|
3268
|
+
// with "could not create image from window" for EVERY window, while a plain
|
|
3269
|
+
// full-screen `screencapture -x` from the same process succeeds. So this is
|
|
3270
|
+
// not a TCC problem — safari_doctor reports Screen Recording as granted and
|
|
3271
|
+
// is telling the truth. Diagnosed 17.08.2026, when the by-id path started
|
|
3272
|
+
// failing mid-session and the misleading "permission may have been lost"
|
|
3273
|
+
// error below sent the investigation after a permission that was never gone.
|
|
3274
|
+
//
|
|
3275
|
+
// Crop math: AppleScript bounds are in points, the capture is in pixels.
|
|
3276
|
+
// devicePixelRatio from the page gives the backing-scale factor.
|
|
3277
|
+
if (windowId) {
|
|
3278
|
+
try {
|
|
3279
|
+
// A locked screen captures as solid black. Say so instead of returning a
|
|
3280
|
+
// black rectangle the caller has to guess at.
|
|
3281
|
+
const locked = await execFileAsync("/bin/sh", ["-c",
|
|
3282
|
+
"ioreg -n Root -d1 -r -a 2>/dev/null | grep -c CGSSessionScreenIsLocked || true"])
|
|
3283
|
+
.then((r) => String(r.stdout).trim() !== "0").catch(() => false);
|
|
3284
|
+
if (locked) {
|
|
3285
|
+
throw new Error("SCREEN_LOCKED");
|
|
3286
|
+
}
|
|
3287
|
+
const boundsRaw = await osascript(
|
|
3288
|
+
`tell application "Safari" to return bounds of ${getTargetWindowRef()}`
|
|
3289
|
+
);
|
|
3290
|
+
const [x1, y1, x2, y2] = String(boundsRaw).split(",").map((v) => parseInt(v.trim(), 10));
|
|
3291
|
+
const dpr = Math.max(1, Math.round(Number(await runJS("window.devicePixelRatio")) || 1));
|
|
3292
|
+
const w = (x2 - x1) * dpr, h = (y2 - y1) * dpr;
|
|
3293
|
+
if ([x1, y1, x2, y2].every(Number.isFinite) && w > 0 && h > 0) {
|
|
3294
|
+
const fullFile = tmpFile.replace(/\.png$/, "-full.png");
|
|
3295
|
+
const cropFile = tmpFile.replace(/\.png$/, "-crop.jpg");
|
|
3296
|
+
try {
|
|
3297
|
+
// Direct first (VS Code / terminal grant), helper second (launchd daemon has no grant of its own)
|
|
3298
|
+
await execFileAsync("/usr/sbin/screencapture", ["-x", fullFile], { timeout: 15000 })
|
|
3299
|
+
.catch(() => osascriptFast(`do shell script "/usr/sbin/screencapture -x '${fullFile}'"`, { timeout: 15000 }));
|
|
3300
|
+
await execFileAsync("sips", [
|
|
3301
|
+
"-c", String(h), String(w), "--cropOffset", String(y1 * dpr), String(x1 * dpr),
|
|
3302
|
+
fullFile, "--out", cropFile,
|
|
3303
|
+
], { timeout: 10000 });
|
|
3304
|
+
await execFileAsync("sips", [
|
|
3305
|
+
"-s", "format", "jpeg", "-s", "formatOptions", "50", "--resampleWidth", "1200",
|
|
3306
|
+
cropFile, "--out", cropFile,
|
|
3307
|
+
], { timeout: 10000 });
|
|
3308
|
+
const cropped = await readFile(cropFile);
|
|
3309
|
+
if (cropped.length > 100) return cropped.toString("base64");
|
|
3310
|
+
} finally {
|
|
3311
|
+
await unlink(fullFile).catch(() => {});
|
|
3312
|
+
await unlink(cropFile).catch(() => {});
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
} catch (e) {
|
|
3316
|
+
if (e && e.message === "SCREEN_LOCKED") {
|
|
3317
|
+
throw new Error("screenshot unavailable — the screen is locked, so the capture would be solid black. Unlock the Mac and retry. (Text-based tools — safari_snapshot / safari_read_page / safari_evaluate — work regardless.)");
|
|
3318
|
+
}
|
|
3319
|
+
// crop path failed too — fall through to the JS canvas method
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3265
3323
|
// Fallback: JS-based screenshot via canvas (no permissions needed)
|
|
3266
3324
|
const dataUrl = await runJS(
|
|
3267
3325
|
`(async function(){` +
|
package/scripts/postinstall.cjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Skipped silently in CI and when stdout is not a TTY (npm install in scripts).
|
|
4
4
|
|
|
5
5
|
const path = require("path");
|
|
6
|
-
const {
|
|
6
|
+
const { execFileSync, spawnSync } = require("child_process");
|
|
7
7
|
const fs = require("fs");
|
|
8
8
|
|
|
9
9
|
const c = {
|
|
@@ -31,7 +31,10 @@ function readHelperId(helper) {
|
|
|
31
31
|
// NOTE: the Identifier= line is only emitted with --verbose. Plain `codesign -d`
|
|
32
32
|
// prints nothing matchable, so the old grep always failed → the early-return never
|
|
33
33
|
// fired and any verify read came back empty (a false "re-sign failed" signal).
|
|
34
|
-
|
|
34
|
+
const result = spawnSync("codesign", ["-d", "--verbose=2", "--", helper], { encoding: "utf8" });
|
|
35
|
+
const output = (result.stdout || "") + (result.stderr || "");
|
|
36
|
+
const match = output.split("\n").find((l) => l.startsWith("Identifier="));
|
|
37
|
+
return match ? match.trim() : "";
|
|
35
38
|
} catch { return ""; }
|
|
36
39
|
}
|
|
37
40
|
function ensureCodesign() {
|
|
@@ -43,9 +46,11 @@ function ensureCodesign() {
|
|
|
43
46
|
const current = readHelperId(helper);
|
|
44
47
|
if (current.includes(STABLE_ID)) return;
|
|
45
48
|
const entitlements = path.join(__dirname, "..", "safari-helper.entitlements");
|
|
46
|
-
const
|
|
49
|
+
const codesignArgs = ["-s", "-", "-f", "--identifier", STABLE_ID];
|
|
50
|
+
if (fs.existsSync(entitlements)) codesignArgs.push("--entitlements", entitlements);
|
|
51
|
+
codesignArgs.push(helper);
|
|
47
52
|
// NOTE: do NOT swallow codesign's stderr (the old `2>/dev/null` hid real failures).
|
|
48
|
-
|
|
53
|
+
execFileSync("codesign", codesignArgs, { stdio: ["ignore", "ignore", "pipe"] });
|
|
49
54
|
// Verify the re-sign actually took. A SILENT failure here is the #1 cause of native
|
|
50
55
|
// clicks "succeeding" while never reaching the page — the Accessibility grant is keyed
|
|
51
56
|
// to the identifier, so a wrong/unstable identifier breaks it invisibly. Make it loud.
|