safari-mcp
Version:
Safari browser automation for AI agents — native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.
175 lines (158 loc) • 7.88 kB
JavaScript
// Opt-in transport selection. Default (no env) = stdio, byte-for-byte the historical behaviour so
// npm users and single-session use are unaffected. SAFARI_MCP_HTTP=1 switches to a shared HTTP
// instance so many Claude Code sessions reuse ONE safari-mcp process. See docs/http-transport-design.md.
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { sessionCtx } from "./session-context.js";
import { _dropSession } from "./safari.js";
const DEFAULT_HTTP_PORT = 9225; // distinct from the 9224 Safari-extension port
// Pure decision: env -> transport plan. No side effects, so it's trivially testable and the
// stdio/HTTP branch is provable in isolation before any socket is opened.
export function planTransport(env = {}) {
const flag = env.SAFARI_MCP_HTTP;
if (flag && flag !== "0") {
return {
kind: "http",
host: "127.0.0.1", // loopback-only bind — necessary, not sufficient: see isLoopbackRequest()
port: parseInt(env.SAFARI_MCP_HTTP_PORT || String(DEFAULT_HTTP_PORT), 10),
};
}
return { kind: "stdio" };
}
// Connects an McpServer over the chosen transport and returns a handle with close().
// `createServer` is a FACTORY (() => McpServer) — McpServer is single-connection, so the HTTP
// path builds a fresh server per session. That's protocol plumbing only: safari's tab/ownership
// state lives at module scope and is shared across sessions (correct — one physical Safari window),
// so per-session servers still drive the same Safari. stdio calls the factory exactly once, so its
// path is behaviourally identical to the historical inline `new McpServer(); connect(stdio)`.
//
// `onSessionClosed(sessionId)` runs once when an HTTP session ends: on its DELETE, or after
// SAFARI_MCP_SESSION_IDLE_MIN idle minutes when that is a positive number. Unset or 0 never ends
// a session for being quiet, so a client that is only waiting keeps its tabs and receipts.
/** @param {{ onSessionClosed?: (sessionId: string) => unknown }} [hooks] */
export async function startTransport(createMcpServer, env = process.env, hooks = {}) {
const plan = planTransport(env);
if (plan.kind === "stdio") {
const server = createMcpServer();
await server.connect(new StdioServerTransport());
return { kind: "stdio", async close() {} };
}
// One StreamableHTTP transport per MCP session (keyed by Mcp-Session-Id), all sharing the single
// McpServer — this is the SDK's supported multi-client pattern. safari's tab/ownership state stays
// module-global (correct: one physical Safari window), so sessions share Safari but each gets its
// own protocol channel for request/response routing.
const transports = new Map();
// Idle reaper (ideas from PR #202 by @fahadjibransheikh), off unless the env asks for it.
const lastSeen = new Map();
const idleMs = Number(env.SAFARI_MCP_SESSION_IDLE_MIN) * 60_000;
const reaper = idleMs > 0
? setInterval(() => {
const cutoff = Date.now() - idleMs;
for (const [id, seen] of lastSeen) if (seen < cutoff) transports.get(id)?.close().catch(() => {});
}, Math.min(60_000, idleMs / 2))
: null;
reaper?.unref();
const httpServer = createServer(async (req, res) => {
try {
const { port } = /** @type {import("node:net").AddressInfo} */ (httpServer.address());
if (!isLoopbackRequest(req.headers, port)) {
res.statusCode = 403;
res.end(
JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Forbidden: non-loopback Host or Origin" }, id: null })
);
return;
}
let body;
if (req.method === "POST") {
const chunks = [];
for await (const c of req) chunks.push(c);
body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : undefined;
}
const sid = req.headers["mcp-session-id"];
let transport = sid ? transports.get(sid) : undefined;
if (!transport && isInitialize(body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => {
transports.set(id, transport);
lastSeen.set(id, Date.now());
},
});
transport.onclose = () => {
const id = transport.sessionId;
if (!id || !transports.has(id)) return; // already ended
transports.delete(id);
lastSeen.delete(id);
_dropSession(id);
Promise.resolve().then(() => hooks.onSessionClosed?.(id)).catch(() => {});
};
const server = createMcpServer(); // fresh server per session (McpServer is single-connection)
await server.connect(transport);
}
if (!transport) {
if (sid) {
// The client presented a session id we don't have — it expired, was evicted, or the
// daemon restarted under it. Per the MCP StreamableHTTP spec this MUST be 404 (NOT 400):
// a 404 tells the client the session is gone so it transparently re-initializes a fresh
// one. The old 400 wedged the client permanently on "No valid session" — every shared
// Claude Code session died the moment its session dropped, with no self-healing.
res.statusCode = 404;
res.end(
JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message: "Session not found" }, id: null })
);
return;
}
// No session id and not an initialize: a genuinely malformed first request.
res.statusCode = 400;
res.end(
JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "No valid session" }, id: null })
);
return;
}
if (sid && lastSeen.has(sid)) lastSeen.set(sid, Date.now());
await sessionCtx.run({ sessionId: sid ?? "_init" }, () => transport.handleRequest(req, res, body));
} catch {
if (!res.headersSent) {
res.statusCode = 500;
res.end();
}
}
});
await new Promise((resolve, reject) => {
httpServer.once("error", reject);
httpServer.listen(plan.port, plan.host, resolve);
});
return {
kind: "http",
port: plan.port,
async close() {
if (reaper) clearInterval(reaper);
for (const t of transports.values()) await t.close().catch(() => {});
await new Promise((r) => httpServer.close(r));
},
};
}
// DNS-rebinding guard — the MCP spec requires a local HTTP server to validate Origin. Binding to
// 127.0.0.1 is not enough on its own: a web page whose hostname re-resolves to 127.0.0.1 reaches
// this port as its *own* origin, and every tool here runs in the user's logged-in Safari. MCP
// clients are not browsers: they send a loopback Host and no Origin. So the Host must name loopback
// on the port we are bound to, and an Origin, when a browser sends one, must name the same.
const LOOPBACK_HOST_PORT = /^(?:127\.0\.0\.1|localhost|\[::1\]):(\d+)$/i;
export function isLoopbackRequest(headers, port) {
const onOurPort = (hostPort) => Number(LOOPBACK_HOST_PORT.exec(hostPort ?? "")?.[1]) === port;
if (!onOurPort(headers.host)) return false;
if (headers.origin === undefined) return true;
try {
const { protocol, host } = new URL(headers.origin);
return (protocol === "http:" || protocol === "https:") && onOurPort(host);
} catch {
return false; // "null" and other opaque origins
}
}
// An MCP initialize request may arrive as a single object or (rarely) batched in an array.
function isInitialize(body) {
const msgs = Array.isArray(body) ? body : [body];
return msgs.some((m) => m && m.method === "initialize");
}