@tikoci/rosetta 0.3.0 → 0.4.0

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.
@@ -0,0 +1,443 @@
1
+ /**
2
+ * mcp-http.test.ts — Integration tests for the MCP Streamable HTTP transport.
3
+ *
4
+ * Starts the actual server on a random port (using `--http`) and exercises
5
+ * the real MCP protocol flow: initialize → list tools → call tool → SSE stream → DELETE.
6
+ *
7
+ * These tests catch transport-level regressions that query/schema tests cannot:
8
+ * - Session creation and routing (the bug that shipped broken in v0.3.0)
9
+ * - Multi-client concurrent sessions
10
+ * - SSE stream establishment
11
+ * - Session lifecycle (create → use → delete)
12
+ * - Proper error responses for missing/invalid session IDs
13
+ *
14
+ * Each test gets an isolated server on a fresh port.
15
+ */
16
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
17
+ import type { Subprocess } from "bun";
18
+
19
+ // ── Helpers ──
20
+
21
+ const BASE_PORT = 19700 + Math.floor(Math.random() * 800);
22
+ let portCounter = 0;
23
+
24
+ function nextPort(): number {
25
+ return BASE_PORT + portCounter++;
26
+ }
27
+
28
+ interface ServerHandle {
29
+ port: number;
30
+ url: string;
31
+ proc: Subprocess;
32
+ }
33
+
34
+ /** Start the MCP server on a random port, wait for it to be ready. */
35
+ async function startServer(): Promise<ServerHandle> {
36
+ const port = nextPort();
37
+ const proc = Bun.spawn(["bun", "run", "src/mcp.ts", "--http", "--port", String(port)], {
38
+ cwd: `${import.meta.dirname}/..`,
39
+ stdout: "pipe",
40
+ stderr: "pipe",
41
+ // Explicitly set DB_PATH so query.test.ts's process.env.DB_PATH=":memory:" override
42
+ // is not inherited by the server subprocess.
43
+ env: { ...process.env, HOST: "127.0.0.1", DB_PATH: `${import.meta.dirname}/../ros-help.db` },
44
+ });
45
+
46
+ // Wait for server to be ready (reads stderr for the startup message)
47
+ // 30s: 3 servers start in parallel during full test suite, contention extends startup time
48
+ const deadline = Date.now() + 30_000;
49
+ let ready = false;
50
+ const decoder = new TextDecoder();
51
+ const reader = proc.stderr.getReader();
52
+
53
+ while (Date.now() < deadline) {
54
+ const { value, done } = await reader.read();
55
+ if (done) break;
56
+ const text = decoder.decode(value);
57
+ if (text.includes(`/mcp`)) {
58
+ ready = true;
59
+ break;
60
+ }
61
+ }
62
+ // Release the reader lock so the stream can be consumed later or cleaned up
63
+ reader.releaseLock();
64
+
65
+ if (!ready) {
66
+ proc.kill();
67
+ throw new Error(`Server failed to start on port ${port} within 10s`);
68
+ }
69
+
70
+ return { port, url: `http://127.0.0.1:${port}/mcp`, proc };
71
+ }
72
+
73
+ async function killServer(handle: ServerHandle): Promise<void> {
74
+ try {
75
+ handle.proc.kill();
76
+ await handle.proc.exited;
77
+ } catch {
78
+ // already dead
79
+ }
80
+ }
81
+
82
+ /** Send an MCP initialize request, return { sessionId, response } */
83
+ async function mcpInitialize(url: string): Promise<{ sessionId: string; body: Record<string, unknown> }> {
84
+ const resp = await fetch(url, {
85
+ method: "POST",
86
+ headers: {
87
+ "Content-Type": "application/json",
88
+ Accept: "application/json, text/event-stream",
89
+ },
90
+ body: JSON.stringify({
91
+ jsonrpc: "2.0",
92
+ method: "initialize",
93
+ id: 1,
94
+ params: {
95
+ protocolVersion: "2025-03-26",
96
+ capabilities: {},
97
+ clientInfo: { name: "test-client", version: "1.0.0" },
98
+ },
99
+ }),
100
+ });
101
+
102
+ const sessionId = resp.headers.get("mcp-session-id");
103
+ if (!sessionId) throw new Error(`No mcp-session-id in response (status ${resp.status})`);
104
+
105
+ // Parse SSE response to extract the JSON-RPC message
106
+ const text = await resp.text();
107
+ const body = parseSseMessages(text);
108
+ return { sessionId, body: body[0] as Record<string, unknown> };
109
+ }
110
+
111
+ /** Parse SSE text into JSON-RPC message objects */
112
+ function parseSseMessages(text: string): unknown[] {
113
+ const messages: unknown[] = [];
114
+ for (const block of text.split("\n\n")) {
115
+ for (const line of block.split("\n")) {
116
+ if (line.startsWith("data: ")) {
117
+ const data = line.slice(6);
118
+ if (data.trim()) {
119
+ messages.push(JSON.parse(data));
120
+ }
121
+ }
122
+ }
123
+ }
124
+ return messages;
125
+ }
126
+
127
+ /** Send a JSON-RPC request with session ID, return parsed SSE messages */
128
+ async function mcpRequest(
129
+ url: string,
130
+ sessionId: string,
131
+ method: string,
132
+ id: number,
133
+ params: Record<string, unknown> = {},
134
+ ): Promise<unknown[]> {
135
+ const resp = await fetch(url, {
136
+ method: "POST",
137
+ headers: {
138
+ "Content-Type": "application/json",
139
+ Accept: "application/json, text/event-stream",
140
+ "Mcp-Session-Id": sessionId,
141
+ "Mcp-Protocol-Version": "2025-03-26",
142
+ },
143
+ body: JSON.stringify({ jsonrpc: "2.0", method, id, params }),
144
+ });
145
+
146
+ if (!resp.ok) {
147
+ const body = await resp.text();
148
+ throw new Error(`MCP request failed (${resp.status}): ${body}`);
149
+ }
150
+
151
+ const text = await resp.text();
152
+ return parseSseMessages(text);
153
+ }
154
+
155
+ /** Send a JSON-RPC notification (no id → 202 response) */
156
+ async function mcpNotification(
157
+ url: string,
158
+ sessionId: string,
159
+ method: string,
160
+ params: Record<string, unknown> = {},
161
+ ): Promise<number> {
162
+ const resp = await fetch(url, {
163
+ method: "POST",
164
+ headers: {
165
+ "Content-Type": "application/json",
166
+ Accept: "application/json, text/event-stream",
167
+ "Mcp-Session-Id": sessionId,
168
+ "Mcp-Protocol-Version": "2025-03-26",
169
+ },
170
+ body: JSON.stringify({ jsonrpc: "2.0", method, params }),
171
+ });
172
+ return resp.status;
173
+ }
174
+
175
+ // ── Tests ──
176
+
177
+ // Single server instance shared across all groups — sessions are isolated per test,
178
+ // so sharing a server does not affect test independence. Starting one server instead
179
+ // of three avoids startup-time contention when all test files run in parallel.
180
+ let server: ServerHandle;
181
+
182
+ beforeAll(async () => {
183
+ server = await startServer();
184
+ }, 30_000);
185
+
186
+ afterAll(async () => {
187
+ await killServer(server);
188
+ }, 15_000);
189
+
190
+ describe("HTTP transport: session lifecycle", () => {
191
+
192
+ test("POST initialize creates session and returns server info", async () => {
193
+ const { sessionId, body } = await mcpInitialize(server.url);
194
+ expect(sessionId).toBeTruthy();
195
+ expect(typeof sessionId).toBe("string");
196
+
197
+ const result = body.result as Record<string, unknown>;
198
+ expect(result.protocolVersion).toBe("2025-03-26");
199
+
200
+ const serverInfo = result.serverInfo as Record<string, string>;
201
+ expect(serverInfo.name).toBe("rosetta");
202
+ });
203
+
204
+ test("tools/list returns all 11 tools after initialization", async () => {
205
+ const { sessionId } = await mcpInitialize(server.url);
206
+
207
+ // Send initialized notification first (required by protocol)
208
+ await mcpNotification(server.url, sessionId, "notifications/initialized");
209
+
210
+ const messages = await mcpRequest(server.url, sessionId, "tools/list", 2);
211
+ expect(messages.length).toBe(1);
212
+
213
+ const result = (messages[0] as Record<string, unknown>).result as Record<string, unknown>;
214
+ const tools = result.tools as Array<{ name: string }>;
215
+ expect(tools.length).toBe(11);
216
+
217
+ const toolNames = tools.map((t) => t.name).sort();
218
+ expect(toolNames).toContain("routeros_search");
219
+ expect(toolNames).toContain("routeros_get_page");
220
+ expect(toolNames).toContain("routeros_lookup_property");
221
+ expect(toolNames).toContain("routeros_search_properties");
222
+ expect(toolNames).toContain("routeros_command_tree");
223
+ expect(toolNames).toContain("routeros_search_callouts");
224
+ expect(toolNames).toContain("routeros_search_changelogs");
225
+ expect(toolNames).toContain("routeros_command_version_check");
226
+ expect(toolNames).toContain("routeros_device_lookup");
227
+ expect(toolNames).toContain("routeros_stats");
228
+ expect(toolNames).toContain("routeros_current_versions");
229
+ });
230
+
231
+ test("tools/call works for routeros_stats", async () => {
232
+ const { sessionId } = await mcpInitialize(server.url);
233
+ await mcpNotification(server.url, sessionId, "notifications/initialized");
234
+
235
+ const messages = await mcpRequest(server.url, sessionId, "tools/call", 3, {
236
+ name: "routeros_stats",
237
+ arguments: {},
238
+ });
239
+ expect(messages.length).toBe(1);
240
+
241
+ const result = (messages[0] as Record<string, unknown>).result as Record<string, unknown>;
242
+ const content = result.content as Array<{ type: string; text: string }>;
243
+ expect(content[0].type).toBe("text");
244
+
245
+ const stats = JSON.parse(content[0].text);
246
+ expect(stats.pages).toBeGreaterThan(0);
247
+ expect(stats.commands).toBeGreaterThan(0);
248
+ });
249
+
250
+ test("GET with session ID opens SSE stream", async () => {
251
+ const { sessionId } = await mcpInitialize(server.url);
252
+ await mcpNotification(server.url, sessionId, "notifications/initialized");
253
+
254
+ // SSE streams are long-lived — abort after headers arrive.
255
+ // Use a short timeout to prove the stream opens successfully.
256
+ const controller = new AbortController();
257
+ const timeout = setTimeout(() => controller.abort(), 500);
258
+
259
+ try {
260
+ const resp = await fetch(server.url, {
261
+ method: "GET",
262
+ headers: {
263
+ Accept: "text/event-stream",
264
+ "Mcp-Session-Id": sessionId,
265
+ "Mcp-Protocol-Version": "2025-03-26",
266
+ },
267
+ signal: controller.signal,
268
+ });
269
+
270
+ // If we get here before abort, check status
271
+ expect(resp.status).toBe(200);
272
+ expect(resp.headers.get("content-type")).toBe("text/event-stream");
273
+ expect(resp.headers.get("mcp-session-id")).toBe(sessionId);
274
+ controller.abort();
275
+ } catch (e: unknown) {
276
+ // AbortError is expected — the stream was open and we killed it
277
+ if (e instanceof Error && (e.name === "AbortError" || (e as NodeJS.ErrnoException).code === "ABORT_ERR")) {
278
+ // Success — the SSE connection was established (headers returned 200)
279
+ // before we aborted. We can't check status after abort.
280
+ } else {
281
+ throw e;
282
+ }
283
+ } finally {
284
+ clearTimeout(timeout);
285
+ }
286
+ });
287
+
288
+ test("DELETE terminates session", async () => {
289
+ const { sessionId } = await mcpInitialize(server.url);
290
+ await mcpNotification(server.url, sessionId, "notifications/initialized");
291
+
292
+ const resp = await fetch(server.url, {
293
+ method: "DELETE",
294
+ headers: {
295
+ "Mcp-Session-Id": sessionId,
296
+ "Mcp-Protocol-Version": "2025-03-26",
297
+ },
298
+ });
299
+
300
+ // DELETE should succeed (200 or 202)
301
+ expect(resp.status).toBeLessThan(300);
302
+
303
+ // Subsequent requests with that session ID should fail
304
+ const resp2 = await fetch(server.url, {
305
+ method: "POST",
306
+ headers: {
307
+ "Content-Type": "application/json",
308
+ Accept: "application/json, text/event-stream",
309
+ "Mcp-Session-Id": sessionId,
310
+ "Mcp-Protocol-Version": "2025-03-26",
311
+ },
312
+ body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 99 }),
313
+ });
314
+
315
+ expect(resp2.status).toBe(404);
316
+ });
317
+ });
318
+
319
+ describe("HTTP transport: error handling", () => {
320
+
321
+ test("GET without session ID returns 400", async () => {
322
+ const resp = await fetch(server.url, {
323
+ method: "GET",
324
+ headers: { Accept: "text/event-stream" },
325
+ });
326
+
327
+ expect(resp.status).toBe(400);
328
+ const body = await resp.json();
329
+ expect(body.error).toBeDefined();
330
+ });
331
+
332
+ test("POST without session ID and non-initialize method returns 400", async () => {
333
+ const resp = await fetch(server.url, {
334
+ method: "POST",
335
+ headers: {
336
+ "Content-Type": "application/json",
337
+ Accept: "application/json, text/event-stream",
338
+ },
339
+ body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 1 }),
340
+ });
341
+
342
+ expect(resp.status).toBe(400);
343
+ const body = await resp.json();
344
+ expect(body.error).toBeDefined();
345
+ });
346
+
347
+ test("POST with invalid session ID returns 404", async () => {
348
+ const resp = await fetch(server.url, {
349
+ method: "POST",
350
+ headers: {
351
+ "Content-Type": "application/json",
352
+ Accept: "application/json, text/event-stream",
353
+ "Mcp-Session-Id": "nonexistent-session-id",
354
+ "Mcp-Protocol-Version": "2025-03-26",
355
+ },
356
+ body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 1 }),
357
+ });
358
+
359
+ expect(resp.status).toBe(404);
360
+ });
361
+
362
+ test("non-/mcp path returns 404", async () => {
363
+ const baseUrl = server.url.replace("/mcp", "");
364
+ const resp = await fetch(`${baseUrl}/other`);
365
+ expect(resp.status).toBe(404);
366
+ });
367
+
368
+ test("POST with invalid JSON returns 400", async () => {
369
+ const { sessionId } = await mcpInitialize(server.url);
370
+
371
+ const resp = await fetch(server.url, {
372
+ method: "POST",
373
+ headers: {
374
+ "Content-Type": "application/json",
375
+ Accept: "application/json, text/event-stream",
376
+ "Mcp-Session-Id": sessionId,
377
+ "Mcp-Protocol-Version": "2025-03-26",
378
+ },
379
+ body: "not valid json{{{",
380
+ });
381
+
382
+ // Our routing layer catches it for POST without session, or SDK catches it
383
+ expect(resp.status).toBeGreaterThanOrEqual(400);
384
+ });
385
+ });
386
+
387
+ describe("HTTP transport: multi-session", () => {
388
+
389
+ test("two clients get independent sessions", async () => {
390
+ const client1 = await mcpInitialize(server.url);
391
+ const client2 = await mcpInitialize(server.url);
392
+
393
+ expect(client1.sessionId).not.toBe(client2.sessionId);
394
+
395
+ // Both sessions are functional
396
+ await mcpNotification(server.url, client1.sessionId, "notifications/initialized");
397
+ await mcpNotification(server.url, client2.sessionId, "notifications/initialized");
398
+
399
+ const msgs1 = await mcpRequest(server.url, client1.sessionId, "tools/list", 2);
400
+ const msgs2 = await mcpRequest(server.url, client2.sessionId, "tools/list", 2);
401
+
402
+ const tools1 = ((msgs1[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
403
+ const tools2 = ((msgs2[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
404
+
405
+ expect(tools1.length).toBe(11);
406
+ expect(tools2.length).toBe(11);
407
+ });
408
+
409
+ test("deleting one session does not affect another", async () => {
410
+ const client1 = await mcpInitialize(server.url);
411
+ const client2 = await mcpInitialize(server.url);
412
+
413
+ await mcpNotification(server.url, client1.sessionId, "notifications/initialized");
414
+ await mcpNotification(server.url, client2.sessionId, "notifications/initialized");
415
+
416
+ // Delete client1's session
417
+ await fetch(server.url, {
418
+ method: "DELETE",
419
+ headers: {
420
+ "Mcp-Session-Id": client1.sessionId,
421
+ "Mcp-Protocol-Version": "2025-03-26",
422
+ },
423
+ });
424
+
425
+ // Client2 still works
426
+ const msgs = await mcpRequest(server.url, client2.sessionId, "tools/list", 2);
427
+ const tools = ((msgs[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
428
+ expect(tools.length).toBe(11);
429
+
430
+ // Client1 is gone
431
+ const resp = await fetch(server.url, {
432
+ method: "POST",
433
+ headers: {
434
+ "Content-Type": "application/json",
435
+ Accept: "application/json, text/event-stream",
436
+ "Mcp-Session-Id": client1.sessionId,
437
+ "Mcp-Protocol-Version": "2025-03-26",
438
+ },
439
+ body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 5 }),
440
+ });
441
+ expect(resp.status).toBe(404);
442
+ });
443
+ });
package/src/mcp.ts CHANGED
@@ -38,6 +38,12 @@ function getArg(name: string): string | undefined {
38
38
  return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
39
39
  }
40
40
 
41
+ /** Format a clickable terminal hyperlink using OSC 8 escape sequences.
42
+ * Falls back to plain URL in terminals that don't support OSC 8. */
43
+ function link(url: string, display?: string): string {
44
+ return `\x1b]8;;${url}\x07${display ?? url}\x1b]8;;\x07`;
45
+ }
46
+
41
47
  if (args.includes("--version") || args.includes("-v")) {
42
48
  console.log(`rosetta ${RESOLVED_VERSION}`);
43
49
  process.exit(0);
@@ -64,8 +70,10 @@ if (args.includes("--help") || args.includes("-h")) {
64
70
  console.log(" DB_PATH Absolute path to ros-help.db (optional)");
65
71
  console.log(" PORT HTTP listen port (lower precedence than --port)");
66
72
  console.log(" HOST HTTP bind address (lower precedence than --host)");
67
- console.log(" TLS_CERT_PATH TLS certificate path (lower precedence than --tls-cert)");
68
- console.log(" TLS_KEY_PATH TLS private key path (lower precedence than --tls-key)");
73
+ console.log();
74
+ console.log(`Quick start: bunx @tikoci/rosetta --setup`);
75
+ console.log(`Project: ${link("https://github.com/tikoci/rosetta")}`);
76
+ console.log(`Docs: ${link("https://help.mikrotik.com/docs/spaces/ROS/overview", "help.mikrotik.com")}`);
69
77
  process.exit(0);
70
78
  }
71
79
 
@@ -136,6 +144,10 @@ const {
136
144
 
137
145
  initDb();
138
146
 
147
+ /** Factory: create a new McpServer with all tools registered.
148
+ * Called once for stdio, or per-session for HTTP transport. */
149
+ function createServer() {
150
+
139
151
  const server = new McpServer({
140
152
  name: "rosetta",
141
153
  version: RESOLVED_VERSION,
@@ -626,16 +638,26 @@ Examples:
626
638
  server.registerTool(
627
639
  "routeros_device_lookup",
628
640
  {
629
- description: `Look up MikroTik hardware specs or search for devices matching criteria.
641
+ description: `Look up MikroTik hardware specs, performance benchmarks, or search for devices matching criteria.
630
642
 
631
- 144 products from mikrotik.com/products/matrix (March 2026). Returns hardware specs
632
- including CPU, RAM, storage, ports, PoE, wireless, license level, and price.
643
+ 144 products from mikrotik.com (March 2026). Returns hardware specs, official test results,
644
+ block diagram URLs, and pricing.
633
645
 
634
646
  **How it works:**
635
- - If query matches a product name or code exactly → returns full specs for that device
636
- - Otherwise → FTS search + optional structured filters → returns matching devices
647
+ - If query matches a product name or code exactly → returns full specs + test results + block diagram
648
+ - Otherwise → FTS search + optional structured filters → returns matching devices (compact)
637
649
  - Filters can be used alone (no query) to find devices by capability
638
650
 
651
+ **Test results** (from mikrotik.com per-product pages):
652
+ - Ethernet: bridging/routing throughput at 64/512/1518 byte packets (kpps + Mbps)
653
+ - IPSec: tunnel throughput with various AES/SHA configurations
654
+ - Key metric: "Routing 25 ip filter rules @ 512 byte" is a common routing performance gauge
655
+ - Devices with L3HW offload show additional hardware-accelerated routing rows
656
+ - Included automatically for exact/single-device lookups — no extra call needed
657
+
658
+ **Block diagram**: internal switch/CPU/PHY architecture diagram URL (PNG).
659
+ Shows bus topology and per-port bandwidth limits — useful for understanding SoC bottlenecks.
660
+
639
661
  **License levels** determine feature availability:
640
662
  - L3: CPE/home (no routing protocols, limited queues)
641
663
  - L4: standard (OSPF, BGP, all firewall features)
@@ -762,6 +784,9 @@ Requires network access to upgrade.mikrotik.com.`,
762
784
  },
763
785
  );
764
786
 
787
+ return server;
788
+ } // end createServer
789
+
765
790
  // ---- Start ----
766
791
 
767
792
  if (useHttp) {
@@ -769,6 +794,9 @@ if (useHttp) {
769
794
  const { WebStandardStreamableHTTPServerTransport } = await import(
770
795
  "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
771
796
  );
797
+ const { isInitializeRequest } = await import(
798
+ "@modelcontextprotocol/sdk/types.js"
799
+ );
772
800
 
773
801
  const port = Number(getArg("--port") ?? process.env.PORT ?? 8080);
774
802
  const hostname = getArg("--host") ?? process.env.HOST ?? "localhost";
@@ -793,12 +821,8 @@ if (useHttp) {
793
821
  const useTls = !!(tlsCert && tlsKey);
794
822
  const scheme = useTls ? "https" : "http";
795
823
 
796
- const httpTransport = new WebStandardStreamableHTTPServerTransport({
797
- sessionIdGenerator: () => crypto.randomUUID(),
798
- enableJsonResponse: false,
799
- });
800
-
801
- await server.connect(httpTransport);
824
+ // Per-session transport routing (each MCP client session gets its own transport + server)
825
+ const transports = new Map<string, InstanceType<typeof WebStandardStreamableHTTPServerTransport>>();
802
826
 
803
827
  const isLAN = hostname === "0.0.0.0" || hostname === "::";
804
828
  if (isLAN) {
@@ -812,6 +836,14 @@ if (useHttp) {
812
836
  }
813
837
  }
814
838
 
839
+ /** JSON-RPC error response helper */
840
+ function jsonRpcError(status: number, code: number, message: string): Response {
841
+ return new Response(
842
+ JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }),
843
+ { status, headers: { "Content-Type": "application/json" } },
844
+ );
845
+ }
846
+
815
847
  Bun.serve({
816
848
  port,
817
849
  hostname,
@@ -821,12 +853,16 @@ if (useHttp) {
821
853
  async fetch(req: Request): Promise<Response> {
822
854
  const url = new URL(req.url);
823
855
 
856
+ if (url.pathname !== "/mcp") {
857
+ return new Response("Not Found", { status: 404 });
858
+ }
859
+
824
860
  // DNS rebinding protection: reject browser-origin requests
825
861
  const origin = req.headers.get("origin");
826
862
  if (origin) {
827
863
  try {
828
864
  const originHost = new URL(origin).host;
829
- const serverHost = `${hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname}:${port}`;
865
+ const serverHost = `${isLAN ? "localhost" : hostname}:${port}`;
830
866
  if (originHost !== serverHost && originHost !== `localhost:${port}` && originHost !== `127.0.0.1:${port}`) {
831
867
  return new Response("Forbidden: Origin not allowed", { status: 403 });
832
868
  }
@@ -835,11 +871,49 @@ if (useHttp) {
835
871
  }
836
872
  }
837
873
 
838
- if (url.pathname === "/mcp") {
839
- return httpTransport.handleRequest(req);
874
+ const sessionId = req.headers.get("mcp-session-id");
875
+
876
+ // Route to existing session
877
+ if (sessionId) {
878
+ const transport = transports.get(sessionId);
879
+ if (transport) {
880
+ return transport.handleRequest(req);
881
+ }
882
+ return jsonRpcError(404, -32001, "Session not found");
883
+ }
884
+
885
+ // No session ID — only POST with initialize creates a new session
886
+ if (req.method === "POST") {
887
+ let body: unknown;
888
+ try {
889
+ body = await req.json();
890
+ } catch {
891
+ return jsonRpcError(400, -32700, "Parse error: Invalid JSON");
892
+ }
893
+
894
+ const isInit = Array.isArray(body)
895
+ ? body.some((msg: unknown) => isInitializeRequest(msg))
896
+ : isInitializeRequest(body);
897
+
898
+ if (isInit) {
899
+ const transport = new WebStandardStreamableHTTPServerTransport({
900
+ sessionIdGenerator: () => crypto.randomUUID(),
901
+ onsessioninitialized: (sid: string) => {
902
+ transports.set(sid, transport);
903
+ },
904
+ });
905
+ transport.onclose = () => {
906
+ const sid = transport.sessionId;
907
+ if (sid) transports.delete(sid);
908
+ };
909
+
910
+ const mcpServer = createServer();
911
+ await mcpServer.connect(transport);
912
+ return transport.handleRequest(req, { parsedBody: body });
913
+ }
840
914
  }
841
915
 
842
- return new Response("Not Found", { status: 404 });
916
+ return jsonRpcError(400, -32000, "Bad Request: No valid session ID provided");
843
917
  },
844
918
  });
845
919
 
@@ -850,6 +924,7 @@ if (useHttp) {
850
924
  const { StdioServerTransport } = await import(
851
925
  "@modelcontextprotocol/sdk/server/stdio.js"
852
926
  );
927
+ const server = createServer();
853
928
  const transport = new StdioServerTransport();
854
929
  await server.connect(transport);
855
930
  }