UNPKG

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
// 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"); }