safari-mcp 2.15.1 → 2.15.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.
Files changed (2) hide show
  1. package/package.json +2 -1
  2. package/transport.js +122 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.15.1",
3
+ "version": "2.15.2",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 96 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
@@ -63,6 +63,7 @@
63
63
  ],
64
64
  "files": [
65
65
  "index.js",
66
+ "transport.js",
66
67
  "response.js",
67
68
  "safari.js",
68
69
  "ownership-match.js",
package/transport.js ADDED
@@ -0,0 +1,122 @@
1
+ // Opt-in transport selection. Default (no env) = stdio, byte-for-byte the historical behaviour so
2
+ // npm users and single-session use are unaffected. SAFARI_MCP_HTTP=1 switches to a shared HTTP
3
+ // instance so many Claude Code sessions reuse ONE safari-mcp process. See docs/http-transport-design.md.
4
+
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
+ import { createServer } from "node:http";
8
+ import { randomUUID } from "node:crypto";
9
+
10
+ const DEFAULT_HTTP_PORT = 9225; // distinct from the 9224 Safari-extension port
11
+
12
+ // Pure decision: env -> transport plan. No side effects, so it's trivially testable and the
13
+ // stdio/HTTP branch is provable in isolation before any socket is opened.
14
+ export function planTransport(env = {}) {
15
+ const flag = env.SAFARI_MCP_HTTP;
16
+ if (flag && flag !== "0") {
17
+ return {
18
+ kind: "http",
19
+ host: "127.0.0.1", // localhost-only bind — no auth needed, never exposed off-box
20
+ port: parseInt(env.SAFARI_MCP_HTTP_PORT || String(DEFAULT_HTTP_PORT), 10),
21
+ };
22
+ }
23
+ return { kind: "stdio" };
24
+ }
25
+
26
+ // Connects an McpServer over the chosen transport and returns a handle with close().
27
+ // `createServer` is a FACTORY (() => McpServer) — McpServer is single-connection, so the HTTP
28
+ // path builds a fresh server per session. That's protocol plumbing only: safari's tab/ownership
29
+ // state lives at module scope and is shared across sessions (correct — one physical Safari window),
30
+ // so per-session servers still drive the same Safari. stdio calls the factory exactly once, so its
31
+ // path is behaviourally identical to the historical inline `new McpServer(); connect(stdio)`.
32
+ export async function startTransport(createMcpServer, env = process.env) {
33
+ const plan = planTransport(env);
34
+
35
+ if (plan.kind === "stdio") {
36
+ const server = createMcpServer();
37
+ await server.connect(new StdioServerTransport());
38
+ return { kind: "stdio", async close() {} };
39
+ }
40
+
41
+ // One StreamableHTTP transport per MCP session (keyed by Mcp-Session-Id), all sharing the single
42
+ // McpServer — this is the SDK's supported multi-client pattern. safari's tab/ownership state stays
43
+ // module-global (correct: one physical Safari window), so sessions share Safari but each gets its
44
+ // own protocol channel for request/response routing.
45
+ const transports = new Map();
46
+
47
+ const httpServer = createServer(async (req, res) => {
48
+ try {
49
+ let body;
50
+ if (req.method === "POST") {
51
+ const chunks = [];
52
+ for await (const c of req) chunks.push(c);
53
+ body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : undefined;
54
+ }
55
+
56
+ const sid = req.headers["mcp-session-id"];
57
+ let transport = sid ? transports.get(sid) : undefined;
58
+
59
+ if (!transport && isInitialize(body)) {
60
+ transport = new StreamableHTTPServerTransport({
61
+ sessionIdGenerator: () => randomUUID(),
62
+ onsessioninitialized: (id) => {
63
+ transports.set(id, transport);
64
+ },
65
+ });
66
+ transport.onclose = () => {
67
+ if (transport.sessionId) transports.delete(transport.sessionId);
68
+ };
69
+ const server = createMcpServer(); // fresh server per session (McpServer is single-connection)
70
+ await server.connect(transport);
71
+ }
72
+
73
+ if (!transport) {
74
+ if (sid) {
75
+ // The client presented a session id we don't have — it expired, was evicted, or the
76
+ // daemon restarted under it. Per the MCP StreamableHTTP spec this MUST be 404 (NOT 400):
77
+ // a 404 tells the client the session is gone so it transparently re-initializes a fresh
78
+ // one. The old 400 wedged the client permanently on "No valid session" — every shared
79
+ // Claude Code session died the moment its session dropped, with no self-healing.
80
+ res.statusCode = 404;
81
+ res.end(
82
+ JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message: "Session not found" }, id: null })
83
+ );
84
+ return;
85
+ }
86
+ // No session id and not an initialize: a genuinely malformed first request.
87
+ res.statusCode = 400;
88
+ res.end(
89
+ JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "No valid session" }, id: null })
90
+ );
91
+ return;
92
+ }
93
+
94
+ await transport.handleRequest(req, res, body);
95
+ } catch {
96
+ if (!res.headersSent) {
97
+ res.statusCode = 500;
98
+ res.end();
99
+ }
100
+ }
101
+ });
102
+
103
+ await new Promise((resolve, reject) => {
104
+ httpServer.once("error", reject);
105
+ httpServer.listen(plan.port, plan.host, resolve);
106
+ });
107
+
108
+ return {
109
+ kind: "http",
110
+ port: plan.port,
111
+ async close() {
112
+ for (const t of transports.values()) await t.close().catch(() => {});
113
+ await new Promise((r) => httpServer.close(r));
114
+ },
115
+ };
116
+ }
117
+
118
+ // An MCP initialize request may arrive as a single object or (rarely) batched in an array.
119
+ function isInitialize(body) {
120
+ const msgs = Array.isArray(body) ? body : [body];
121
+ return msgs.some((m) => m && m.method === "initialize");
122
+ }