safari-mcp 2.22.0 → 2.22.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/package.json +1 -1
- package/safari.js +64 -17
- package/transport.js +28 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.22.
|
|
3
|
+
"version": "2.22.2",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
5
|
"description": "Safari browser automation for AI agents \u2014 native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|
package/safari.js
CHANGED
|
@@ -1060,6 +1060,26 @@ function _helperPreflight(timeout = 3000) {
|
|
|
1060
1060
|
}));
|
|
1061
1061
|
}
|
|
1062
1062
|
|
|
1063
|
+
// ========== SCREEN LOCK GATE ==========
|
|
1064
|
+
// A locked screen (the lid shut on an awake Mac, or the lock screen up) routes every OS-level
|
|
1065
|
+
// event to loginwindow: CGEvent clicks and keys land on the lock screen — keystrokes even in its
|
|
1066
|
+
// password field — while the native tools used to report "clicked"/"typed". In-page tools keep
|
|
1067
|
+
// working while locked, so every native path fails fast here and points there instead.
|
|
1068
|
+
export const SCREEN_LOCKED_MSG =
|
|
1069
|
+
"SCREEN_LOCKED: the Mac's screen is locked, so OS-level (native) input would land on the lock screen " +
|
|
1070
|
+
"instead of Safari. In-page tools still work while locked: use safari_click / safari_fill / " +
|
|
1071
|
+
"safari_type_text / safari_press_key, and safari_upload_file without forceNative.";
|
|
1072
|
+
|
|
1073
|
+
export async function isScreenLocked() {
|
|
1074
|
+
return execFileAsync("/bin/sh", ["-c",
|
|
1075
|
+
"ioreg -n Root -d1 -r -a 2>/dev/null | grep -c CGSSessionScreenIsLocked || true"])
|
|
1076
|
+
.then((r) => String(r.stdout).trim() !== "0").catch(() => false);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
export async function assertScreenUnlocked(probe = isScreenLocked) {
|
|
1080
|
+
if (await probe()) throw new Error(SCREEN_LOCKED_MSG);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1063
1083
|
// ========== NATIVE CLICK VIA CGEVENT ==========
|
|
1064
1084
|
// Sends a CGEvent click command to the Swift helper daemon.
|
|
1065
1085
|
// This produces isTrusted: true events — bypasses WAF protection (G2, etc.)
|
|
@@ -1071,7 +1091,8 @@ function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5
|
|
|
1071
1091
|
return _withTargetTabFronted(() => _helperNativeClickRaw(x, y, doubleClick, windowId, timeout));
|
|
1072
1092
|
}
|
|
1073
1093
|
|
|
1074
|
-
function _helperNativeClickRaw(x, y, doubleClick = false, windowId = 0, timeout = 5000) {
|
|
1094
|
+
async function _helperNativeClickRaw(x, y, doubleClick = false, windowId = 0, timeout = 5000) {
|
|
1095
|
+
await assertScreenUnlocked();
|
|
1075
1096
|
return _withHelperLock(() => new Promise((resolve, reject) => {
|
|
1076
1097
|
if (!_helperProc) startHelper();
|
|
1077
1098
|
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
@@ -1118,7 +1139,8 @@ function _helperNativeClickRaw(x, y, doubleClick = false, windowId = 0, timeout
|
|
|
1118
1139
|
|
|
1119
1140
|
// Sends a CGEvent hover command to the Swift helper daemon.
|
|
1120
1141
|
// Moves the cursor to (x, y), dwells to let tooltips render, optionally restores cursor.
|
|
1121
|
-
function _helperNativeHover(x, y, windowId = 0, dwellMs = 500, restoreMouse = true, timeout = 10000) {
|
|
1142
|
+
async function _helperNativeHover(x, y, windowId = 0, dwellMs = 500, restoreMouse = true, timeout = 10000) {
|
|
1143
|
+
await assertScreenUnlocked();
|
|
1122
1144
|
return _withHelperLock(() => new Promise((resolve, reject) => {
|
|
1123
1145
|
if (!_helperProc) startHelper();
|
|
1124
1146
|
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
@@ -1168,7 +1190,8 @@ function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000
|
|
|
1168
1190
|
return _withTargetTabFronted(() => _helperNativeKeyboardRaw(keyCode, flags, windowId, timeout));
|
|
1169
1191
|
}
|
|
1170
1192
|
|
|
1171
|
-
function _helperNativeKeyboardRaw(keyCode, flags = [], windowId = 0, timeout = 5000) {
|
|
1193
|
+
async function _helperNativeKeyboardRaw(keyCode, flags = [], windowId = 0, timeout = 5000) {
|
|
1194
|
+
await assertScreenUnlocked();
|
|
1172
1195
|
return _withHelperLock(() => new Promise((resolve, reject) => {
|
|
1173
1196
|
if (!_helperProc) startHelper();
|
|
1174
1197
|
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
@@ -3620,10 +3643,7 @@ async function _screenshotFronted({ fullPage }) {
|
|
|
3620
3643
|
try {
|
|
3621
3644
|
// A locked screen captures as solid black. Say so instead of returning a
|
|
3622
3645
|
// black rectangle the caller has to guess at.
|
|
3623
|
-
|
|
3624
|
-
"ioreg -n Root -d1 -r -a 2>/dev/null | grep -c CGSSessionScreenIsLocked || true"])
|
|
3625
|
-
.then((r) => String(r.stdout).trim() !== "0").catch(() => false);
|
|
3626
|
-
if (locked) {
|
|
3646
|
+
if (await isScreenLocked()) {
|
|
3627
3647
|
throw new Error("SCREEN_LOCKED");
|
|
3628
3648
|
}
|
|
3629
3649
|
const boundsRaw = await osascript(
|
|
@@ -4568,12 +4588,21 @@ export async function uploadFile({ selector, filePath, forceNative = false, veri
|
|
|
4568
4588
|
let ghostPickup = false;
|
|
4569
4589
|
if (verifyPreview && !forceNative && /verified [1-9]\d* file/i.test(result)) {
|
|
4570
4590
|
const before = Number((result.match(/\[previews=(-?\d+)\]/) || [])[1] ?? -1);
|
|
4571
|
-
|
|
4572
|
-
const after = Number(
|
|
4591
|
+
const countPreviews = async () => Number(
|
|
4573
4592
|
await runJS(
|
|
4574
4593
|
`(function(){try{var n=document.querySelectorAll('img[src^="blob:"], img[src^="data:image"], video[src^="blob:"]').length;var all=document.querySelectorAll('div,span,a,figure');for(var i=0;i<all.length;i++){var b=all[i].style&&all[i].style.backgroundImage||'';if(b.indexOf('blob:')>-1||b.indexOf('data:image')>-1)n++;}return n;}catch(_){return -1;}})()`
|
|
4575
4594
|
).catch(() => -1)
|
|
4576
4595
|
);
|
|
4596
|
+
// Locked screen: every window is occluded, Safari throttles the page, and the site can take
|
|
4597
|
+
// 30–90 s to render the preview (GBP, 26.9.2026) — while the native-dialog escalation cannot
|
|
4598
|
+
// run at all. So poll for up to 90 s instead of judging after 1.4 s.
|
|
4599
|
+
const locked = await isScreenLocked();
|
|
4600
|
+
const deadline = Date.now() + (locked && before >= 0 ? 90000 : 0);
|
|
4601
|
+
let after;
|
|
4602
|
+
do {
|
|
4603
|
+
await new Promise((r) => setTimeout(r, locked ? 2000 : 1400));
|
|
4604
|
+
after = await countPreviews();
|
|
4605
|
+
} while (Date.now() < deadline && !(before >= 0 && after > before));
|
|
4577
4606
|
if (before >= 0 && after >= 0 && after <= before) {
|
|
4578
4607
|
ghostPickup = true;
|
|
4579
4608
|
console.error(`[Safari MCP] upload_file: ghost pickup on ${sel} (previews ${before}→${after}) — escalating to native dialog`);
|
|
@@ -4585,7 +4614,7 @@ export async function uploadFile({ selector, filePath, forceNative = false, veri
|
|
|
4585
4614
|
finalResult = await _nativeFileUpload(sel, resolvedPath, safeName);
|
|
4586
4615
|
if (ghostPickup) finalResult = `${finalResult} [escalated: synthetic pickup produced no preview]`;
|
|
4587
4616
|
} catch (nativeErr) {
|
|
4588
|
-
finalResult = `${result} | Native file-dialog ${forceNative ? '(forced)' : '(ghost-pickup escalation)'} failed: ${nativeErr.message}`;
|
|
4617
|
+
finalResult = `${ghostPickup ? "NO PREVIEW — the page never showed the file. " : ""}${result} | Native file-dialog ${forceNative ? '(forced)' : '(ghost-pickup escalation)'} failed: ${nativeErr.message}`;
|
|
4589
4618
|
}
|
|
4590
4619
|
} else if (/verified 0 file|Upload attempted|el\.files is empty/i.test(result)) {
|
|
4591
4620
|
try {
|
|
@@ -4607,6 +4636,8 @@ export async function uploadFile({ selector, filePath, forceNative = false, veri
|
|
|
4607
4636
|
// drives the dialog via System Events (Cmd+Shift+G → path → Return → Return). `sel` is
|
|
4608
4637
|
// already escJsSingleQuote-escaped by the caller.
|
|
4609
4638
|
async function _nativeFileUpload(sel, absPath, safeName) {
|
|
4639
|
+
// Gate first: step 1 restyles the input into an invisible full-size overlay.
|
|
4640
|
+
await assertScreenUnlocked();
|
|
4610
4641
|
// 1. Make the input clickable (hidden inputs have no hit-box) and read its viewport centre.
|
|
4611
4642
|
const coordsJson = await runJS(
|
|
4612
4643
|
`(function(){var el=document.querySelector('${sel}');if(!el){var all=document.querySelectorAll('*');for(var i=0;i<all.length;i++){var sr=all[i].shadowRoot;if(sr){el=sr.querySelector('${sel}');if(el)break;}}}if(!el)return JSON.stringify({error:'Element not found: ${sel}'});el.setAttribute('data-mcp-oldstyle',el.getAttribute('style')||'');el.style.cssText='position:fixed !important;top:42% !important;left:38% !important;width:320px !important;height:120px !important;opacity:0.02 !important;z-index:2147483647 !important;display:block !important;visibility:visible !important;pointer-events:auto !important';var r=el.getBoundingClientRect();return JSON.stringify({x:Math.round(r.left+r.width/2),y:Math.round(r.top+r.height/2)});})()`
|
|
@@ -4615,6 +4646,27 @@ async function _nativeFileUpload(sel, absPath, safeName) {
|
|
|
4615
4646
|
try { c = JSON.parse(coordsJson); } catch { throw new Error("coord parse failed: " + coordsJson); }
|
|
4616
4647
|
if (c.error) throw new Error(c.error);
|
|
4617
4648
|
|
|
4649
|
+
let dlgStatus;
|
|
4650
|
+
let filesLen;
|
|
4651
|
+
try {
|
|
4652
|
+
dlgStatus = await _clickInputAndDriveDialog(c, absPath);
|
|
4653
|
+
} finally {
|
|
4654
|
+
// 4. Restore the input's original style — also when the click or the dialog threw, or the
|
|
4655
|
+
// page keeps an invisible full-size overlay that swallows clicks.
|
|
4656
|
+
filesLen = await runJS(
|
|
4657
|
+
`(function(){var el=document.querySelector('${sel}');if(!el)return 'gone';var old=el.getAttribute('data-mcp-oldstyle');if(old!==null){if(old){el.setAttribute('style',old);}else{el.removeAttribute('style');}el.removeAttribute('data-mcp-oldstyle');}return String(el.files?el.files.length:0);})()`
|
|
4658
|
+
).catch(() => "?");
|
|
4659
|
+
}
|
|
4660
|
+
// A dialog that never opened selected nothing; reporting "Uploaded" here sent callers on as if it had.
|
|
4661
|
+
if (/sheet=false/.test(dlgStatus)) {
|
|
4662
|
+
throw new Error(`the OS file dialog never opened (${dlgStatus}, input.files=${filesLen}) — nothing was selected`);
|
|
4663
|
+
}
|
|
4664
|
+
return `Uploaded via native file dialog (isTrusted): ${safeName} — input.files=${filesLen}, ${dlgStatus}. Real OS selection; accepted by isTrusted-gated handlers (GitHub etc.). Verify with safari_snapshot.`;
|
|
4665
|
+
}
|
|
4666
|
+
|
|
4667
|
+
// Steps 2–3 of _nativeFileUpload: a native click on the restyled input opens the NSOpenPanel,
|
|
4668
|
+
// then System Events drives it. Returns the dialog status ("sheet=true|false" or "osaerr:…").
|
|
4669
|
+
async function _clickInputAndDriveDialog(c, absPath) {
|
|
4618
4670
|
// 2. Native (CGEvent, isTrusted:true) click on the input → opens the OS file dialog.
|
|
4619
4671
|
const geo = await _getSafariWindowGeometry();
|
|
4620
4672
|
if (!geo.windowId) throw new Error("no Safari window id (cannot native-click without focus steal)");
|
|
@@ -4635,7 +4687,7 @@ async function _nativeFileUpload(sel, absPath, safeName) {
|
|
|
4635
4687
|
// Activate + drive the dialog in ONE osascript so Safari stays frontmost throughout (a
|
|
4636
4688
|
// separate activate call would be undone by osascript()'s focus-guard restore). Generous
|
|
4637
4689
|
// timeout: the sheet-wait + Cmd+Shift+G + typing a long path can exceed the 10s default.
|
|
4638
|
-
const
|
|
4690
|
+
const status = await osascript(
|
|
4639
4691
|
`tell application "Safari" to activate
|
|
4640
4692
|
delay 0.3
|
|
4641
4693
|
tell application "System Events"
|
|
@@ -4659,13 +4711,8 @@ async function _nativeFileUpload(sel, absPath, safeName) {
|
|
|
4659
4711
|
end tell`,
|
|
4660
4712
|
{ timeout: 30000 }
|
|
4661
4713
|
).catch((e) => "osaerr:" + e.message);
|
|
4662
|
-
|
|
4663
|
-
// 4. Restore the input's original style, then report the resulting file count.
|
|
4664
4714
|
await new Promise((r) => setTimeout(r, 900));
|
|
4665
|
-
|
|
4666
|
-
`(function(){var el=document.querySelector('${sel}');if(!el)return 'gone';var old=el.getAttribute('data-mcp-oldstyle');if(old!==null){if(old){el.setAttribute('style',old);}else{el.removeAttribute('style');}el.removeAttribute('data-mcp-oldstyle');}return String(el.files?el.files.length:0);})()`
|
|
4667
|
-
).catch(() => "?");
|
|
4668
|
-
return `Uploaded via native file dialog (isTrusted): ${safeName} — input.files=${filesLen}, ${dlgStatus}. Real OS selection; accepted by isTrusted-gated handlers (GitHub etc.). Verify with safari_snapshot.`;
|
|
4715
|
+
return status;
|
|
4669
4716
|
}
|
|
4670
4717
|
|
|
4671
4718
|
// ========== PASTE IMAGE FROM FILE ==========
|
package/transport.js
CHANGED
|
@@ -18,7 +18,7 @@ export function planTransport(env = {}) {
|
|
|
18
18
|
if (flag && flag !== "0") {
|
|
19
19
|
return {
|
|
20
20
|
kind: "http",
|
|
21
|
-
host: "127.0.0.1", //
|
|
21
|
+
host: "127.0.0.1", // loopback-only bind — necessary, not sufficient: see isLoopbackRequest()
|
|
22
22
|
port: parseInt(env.SAFARI_MCP_HTTP_PORT || String(DEFAULT_HTTP_PORT), 10),
|
|
23
23
|
};
|
|
24
24
|
}
|
|
@@ -48,6 +48,15 @@ export async function startTransport(createMcpServer, env = process.env) {
|
|
|
48
48
|
|
|
49
49
|
const httpServer = createServer(async (req, res) => {
|
|
50
50
|
try {
|
|
51
|
+
const { port } = /** @type {import("node:net").AddressInfo} */ (httpServer.address());
|
|
52
|
+
if (!isLoopbackRequest(req.headers, port)) {
|
|
53
|
+
res.statusCode = 403;
|
|
54
|
+
res.end(
|
|
55
|
+
JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Forbidden: non-loopback Host or Origin" }, id: null })
|
|
56
|
+
);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
51
60
|
let body;
|
|
52
61
|
if (req.method === "POST") {
|
|
53
62
|
const chunks = [];
|
|
@@ -117,6 +126,24 @@ export async function startTransport(createMcpServer, env = process.env) {
|
|
|
117
126
|
};
|
|
118
127
|
}
|
|
119
128
|
|
|
129
|
+
// DNS-rebinding guard — the MCP spec requires a local HTTP server to validate Origin. Binding to
|
|
130
|
+
// 127.0.0.1 is not enough on its own: a web page whose hostname re-resolves to 127.0.0.1 reaches
|
|
131
|
+
// this port as its *own* origin, and every tool here runs in the user's logged-in Safari. MCP
|
|
132
|
+
// clients are not browsers: they send a loopback Host and no Origin. So the Host must name loopback
|
|
133
|
+
// on the port we are bound to, and an Origin, when a browser sends one, must name the same.
|
|
134
|
+
const LOOPBACK_HOST_PORT = /^(?:127\.0\.0\.1|localhost|\[::1\]):(\d+)$/i;
|
|
135
|
+
export function isLoopbackRequest(headers, port) {
|
|
136
|
+
const onOurPort = (hostPort) => Number(LOOPBACK_HOST_PORT.exec(hostPort ?? "")?.[1]) === port;
|
|
137
|
+
if (!onOurPort(headers.host)) return false;
|
|
138
|
+
if (headers.origin === undefined) return true;
|
|
139
|
+
try {
|
|
140
|
+
const { protocol, host } = new URL(headers.origin);
|
|
141
|
+
return (protocol === "http:" || protocol === "https:") && onOurPort(host);
|
|
142
|
+
} catch {
|
|
143
|
+
return false; // "null" and other opaque origins
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
120
147
|
// An MCP initialize request may arrive as a single object or (rarely) batched in an array.
|
|
121
148
|
function isInitialize(body) {
|
|
122
149
|
const msgs = Array.isArray(body) ? body : [body];
|