@tikoci/rosetta 0.3.0 → 0.3.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "RouterOS documentation as SQLite FTS5 — RAG search + command glossary via MCP",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
@@ -136,6 +136,10 @@ const {
136
136
 
137
137
  initDb();
138
138
 
139
+ /** Factory: create a new McpServer with all tools registered.
140
+ * Called once for stdio, or per-session for HTTP transport. */
141
+ function createServer() {
142
+
139
143
  const server = new McpServer({
140
144
  name: "rosetta",
141
145
  version: RESOLVED_VERSION,
@@ -762,6 +766,9 @@ Requires network access to upgrade.mikrotik.com.`,
762
766
  },
763
767
  );
764
768
 
769
+ return server;
770
+ } // end createServer
771
+
765
772
  // ---- Start ----
766
773
 
767
774
  if (useHttp) {
@@ -769,6 +776,9 @@ if (useHttp) {
769
776
  const { WebStandardStreamableHTTPServerTransport } = await import(
770
777
  "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
771
778
  );
779
+ const { isInitializeRequest } = await import(
780
+ "@modelcontextprotocol/sdk/types.js"
781
+ );
772
782
 
773
783
  const port = Number(getArg("--port") ?? process.env.PORT ?? 8080);
774
784
  const hostname = getArg("--host") ?? process.env.HOST ?? "localhost";
@@ -793,12 +803,8 @@ if (useHttp) {
793
803
  const useTls = !!(tlsCert && tlsKey);
794
804
  const scheme = useTls ? "https" : "http";
795
805
 
796
- const httpTransport = new WebStandardStreamableHTTPServerTransport({
797
- sessionIdGenerator: () => crypto.randomUUID(),
798
- enableJsonResponse: false,
799
- });
800
-
801
- await server.connect(httpTransport);
806
+ // Per-session transport routing (each MCP client session gets its own transport + server)
807
+ const transports = new Map<string, InstanceType<typeof WebStandardStreamableHTTPServerTransport>>();
802
808
 
803
809
  const isLAN = hostname === "0.0.0.0" || hostname === "::";
804
810
  if (isLAN) {
@@ -812,6 +818,14 @@ if (useHttp) {
812
818
  }
813
819
  }
814
820
 
821
+ /** JSON-RPC error response helper */
822
+ function jsonRpcError(status: number, code: number, message: string): Response {
823
+ return new Response(
824
+ JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }),
825
+ { status, headers: { "Content-Type": "application/json" } },
826
+ );
827
+ }
828
+
815
829
  Bun.serve({
816
830
  port,
817
831
  hostname,
@@ -821,12 +835,16 @@ if (useHttp) {
821
835
  async fetch(req: Request): Promise<Response> {
822
836
  const url = new URL(req.url);
823
837
 
838
+ if (url.pathname !== "/mcp") {
839
+ return new Response("Not Found", { status: 404 });
840
+ }
841
+
824
842
  // DNS rebinding protection: reject browser-origin requests
825
843
  const origin = req.headers.get("origin");
826
844
  if (origin) {
827
845
  try {
828
846
  const originHost = new URL(origin).host;
829
- const serverHost = `${hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname}:${port}`;
847
+ const serverHost = `${isLAN ? "localhost" : hostname}:${port}`;
830
848
  if (originHost !== serverHost && originHost !== `localhost:${port}` && originHost !== `127.0.0.1:${port}`) {
831
849
  return new Response("Forbidden: Origin not allowed", { status: 403 });
832
850
  }
@@ -835,11 +853,49 @@ if (useHttp) {
835
853
  }
836
854
  }
837
855
 
838
- if (url.pathname === "/mcp") {
839
- return httpTransport.handleRequest(req);
856
+ const sessionId = req.headers.get("mcp-session-id");
857
+
858
+ // Route to existing session
859
+ if (sessionId) {
860
+ const transport = transports.get(sessionId);
861
+ if (transport) {
862
+ return transport.handleRequest(req);
863
+ }
864
+ return jsonRpcError(404, -32001, "Session not found");
865
+ }
866
+
867
+ // No session ID — only POST with initialize creates a new session
868
+ if (req.method === "POST") {
869
+ let body: unknown;
870
+ try {
871
+ body = await req.json();
872
+ } catch {
873
+ return jsonRpcError(400, -32700, "Parse error: Invalid JSON");
874
+ }
875
+
876
+ const isInit = Array.isArray(body)
877
+ ? body.some((msg: unknown) => isInitializeRequest(msg))
878
+ : isInitializeRequest(body);
879
+
880
+ if (isInit) {
881
+ const transport = new WebStandardStreamableHTTPServerTransport({
882
+ sessionIdGenerator: () => crypto.randomUUID(),
883
+ onsessioninitialized: (sid: string) => {
884
+ transports.set(sid, transport);
885
+ },
886
+ });
887
+ transport.onclose = () => {
888
+ const sid = transport.sessionId;
889
+ if (sid) transports.delete(sid);
890
+ };
891
+
892
+ const mcpServer = createServer();
893
+ await mcpServer.connect(transport);
894
+ return transport.handleRequest(req, { parsedBody: body });
895
+ }
840
896
  }
841
897
 
842
- return new Response("Not Found", { status: 404 });
898
+ return jsonRpcError(400, -32000, "Bad Request: No valid session ID provided");
843
899
  },
844
900
  });
845
901
 
@@ -850,6 +906,7 @@ if (useHttp) {
850
906
  const { StdioServerTransport } = await import(
851
907
  "@modelcontextprotocol/sdk/server/stdio.js"
852
908
  );
909
+ const server = createServer();
853
910
  const transport = new StdioServerTransport();
854
911
  await server.connect(transport);
855
912
  }
@@ -280,3 +280,108 @@ describe("CLI flags", () => {
280
280
  expect(src).toContain("--setup");
281
281
  });
282
282
  });
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // HTTP transport structural checks — catch per-session breakage at build time
286
+ // ---------------------------------------------------------------------------
287
+
288
+ describe("HTTP transport structure", () => {
289
+ const src = readText("src/mcp.ts");
290
+
291
+ test("uses per-session transport routing (not single shared transport)", () => {
292
+ // The single-transport pattern was: `await server.connect(httpTransport)` at module level
293
+ // followed by `httpTransport.handleRequest(req)`. The per-session pattern has a
294
+ // transports Map and creates transport/server per session.
295
+ expect(src).toContain("new Map");
296
+ expect(src).toContain("transports.set");
297
+ expect(src).toContain("transports.get");
298
+ });
299
+
300
+ test("creates new McpServer per session, not one shared instance", () => {
301
+ // createServer() factory must exist and be called per-session
302
+ expect(src).toContain("function createServer()");
303
+ expect(src).toContain("createServer()");
304
+ });
305
+
306
+ test("checks isInitializeRequest before creating transport", () => {
307
+ expect(src).toContain("isInitializeRequest");
308
+ });
309
+
310
+ test("registers onsessioninitialized callback", () => {
311
+ expect(src).toContain("onsessioninitialized");
312
+ });
313
+
314
+ test("cleans up transport on close", () => {
315
+ expect(src).toContain("transport.onclose");
316
+ expect(src).toContain("transports.delete");
317
+ });
318
+
319
+ test("passes parsedBody to handleRequest after consuming body", () => {
320
+ // Once we req.json() for isInitializeRequest check, the body is consumed.
321
+ // Must pass parsedBody so the transport doesn't try to re-parse.
322
+ expect(src).toContain("parsedBody");
323
+ });
324
+
325
+ test("handles missing session ID on non-initialize requests", () => {
326
+ expect(src).toContain("No valid session ID provided");
327
+ });
328
+
329
+ test("handles invalid session ID with 404", () => {
330
+ expect(src).toContain("Session not found");
331
+ });
332
+ });
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Container / entrypoint checks
336
+ // ---------------------------------------------------------------------------
337
+
338
+ describe("container entrypoint", () => {
339
+ test("entrypoint script exists", () => {
340
+ expect(existsSync(path.join(ROOT, "scripts/container-entrypoint.sh"))).toBe(true);
341
+ });
342
+
343
+ test("defaults to --http mode", () => {
344
+ const src = readText("scripts/container-entrypoint.sh");
345
+ expect(src).toContain("--http");
346
+ });
347
+
348
+ test("defaults to 0.0.0.0 host binding", () => {
349
+ const src = readText("scripts/container-entrypoint.sh");
350
+ expect(src).toContain("0.0.0.0");
351
+ });
352
+
353
+ test("supports TLS via env vars", () => {
354
+ const src = readText("scripts/container-entrypoint.sh");
355
+ expect(src).toContain("TLS_CERT_PATH");
356
+ expect(src).toContain("TLS_KEY_PATH");
357
+ });
358
+ });
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // Dockerfile structure
362
+ // ---------------------------------------------------------------------------
363
+
364
+ describe("Dockerfile.release", () => {
365
+ test("copies entrypoint script", () => {
366
+ const src = readText("Dockerfile.release");
367
+ expect(src).toContain("container-entrypoint.sh");
368
+ expect(src).toContain("ENTRYPOINT");
369
+ });
370
+
371
+ test("copies database into image", () => {
372
+ const src = readText("Dockerfile.release");
373
+ expect(src).toContain("ros-help.db");
374
+ });
375
+
376
+ test("exposes port 8080", () => {
377
+ const src = readText("Dockerfile.release");
378
+ expect(src).toContain("EXPOSE 8080");
379
+ });
380
+
381
+ test("injects build constants", () => {
382
+ const src = readText("Dockerfile.release");
383
+ expect(src).toContain("IS_COMPILED");
384
+ expect(src).toContain("VERSION");
385
+ expect(src).toContain("REPO_URL");
386
+ });
387
+ });