@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 +1 -1
- package/src/mcp-http.test.ts +443 -0
- package/src/mcp.ts +67 -10
- package/src/release.test.ts +105 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
797
|
-
|
|
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 = `${
|
|
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
|
-
|
|
839
|
-
|
|
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
|
|
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
|
}
|
package/src/release.test.ts
CHANGED
|
@@ -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
|
+
});
|