@tikoci/rosetta 0.2.1 → 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/README.md CHANGED
@@ -10,7 +10,7 @@ Most retrieval-augmented generation (RAG) systems use vector embeddings to searc
10
10
 
11
11
  For structured technical documentation like RouterOS, full-text search with [BM25 ranking](https://www.sqlite.org/fts5.html#the_bm25_function) beats vector similarity. Technical terms like "dhcp-snooping" or "/ip/firewall/filter" are exact tokens — [porter stemming](https://www.sqlite.org/fts5.html#porter_tokenizer) and proximity matching handle the rest. No embedding pipeline, no vector database, no API keys. Just a single SQLite file that searches in milliseconds.
12
12
 
13
- The data flows: **HTML docs → SQLite extraction → FTS5 indexes → MCP tools → your AI assistant.** The database is built once from MikroTik's official Confluence documentation export, then the MCP server exposes 11 search tools over stdio transport.
13
+ The data flows: **HTML docs → SQLite extraction → FTS5 indexes → MCP tools → your AI assistant.** The database is built once from MikroTik's official Confluence documentation export, then the MCP server exposes 11 search tools over stdio or HTTP transport.
14
14
 
15
15
  ## What's Inside
16
16
 
@@ -24,6 +24,28 @@ The data flows: **HTML docs → SQLite extraction → FTS5 indexes → MCP tools
24
24
 
25
25
  ## Quick Start
26
26
 
27
+ ## MCP Discovery Status
28
+
29
+ - GitHub MCP Registry listing: planned
30
+ - Official MCP Registry publication: metadata is now prepared in `server.json`
31
+
32
+ Local install remains the primary path today (`bunx @tikoci/rosetta`).
33
+
34
+ When ready to publish to the official registry:
35
+
36
+ ```sh
37
+ brew install mcp-publisher
38
+ mcp-publisher validate server.json
39
+ mcp-publisher login github
40
+ mcp-publisher publish server.json
41
+ ```
42
+
43
+ After publication, the server should be discoverable via:
44
+
45
+ ```sh
46
+ curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.tikoci/rosetta"
47
+ ```
48
+
27
49
  ### Option A: Install with Bun (recommended)
28
50
 
29
51
  Zero install, zero config, no binary signing issues. Requires [Bun](https://bun.sh/) — no Gatekeeper or SmartScreen warnings since there's no compiled binary to sign.
@@ -216,6 +238,67 @@ The AI assistant typically starts with `routeros_search`, then drills into speci
216
238
  | **Windows SmartScreen warning** | Use `bunx` install (no SmartScreen issues), or click **More info → Run anyway** |
217
239
  | **How to update** | `bunx` always uses the latest published version. For binaries, re-download from [Releases](https://github.com/tikoci/rosetta/releases/latest). |
218
240
 
241
+ ## HTTP Transport
242
+
243
+ Most MCP clients use stdio (the default). Some — like the OpenAI platform and remote/LAN setups — require an HTTP endpoint instead. Rosetta supports the [MCP Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) via the `--http` flag:
244
+
245
+ ```sh
246
+ rosetta --http # http://localhost:8080/mcp
247
+ rosetta --http --port 9090 # custom port
248
+ rosetta --http --host 0.0.0.0 # accessible from LAN
249
+ ```
250
+
251
+ Then point your MCP client at the URL:
252
+
253
+ ```json
254
+ { "url": "http://localhost:8080/mcp" }
255
+ ```
256
+
257
+ **Key facts:**
258
+
259
+ - **Read-only** — the server queries a local SQLite database. It does not store data, accept uploads, or modify anything.
260
+ - **No authentication** — designed for local/trusted-network use. For public exposure, put it behind a reverse proxy (nginx, caddy) with TLS and auth.
261
+ - **TLS built-in** — for direct HTTPS without a proxy: `--tls-cert cert.pem --tls-key key.pem` (or `TLS_CERT_PATH` + `TLS_KEY_PATH` env vars)
262
+ - **Defaults to localhost** — binding to all interfaces (`--host 0.0.0.0`) requires an explicit flag and logs a warning.
263
+ - **Origin validation** — rejects cross-origin requests to prevent DNS rebinding attacks.
264
+ - **Stdio remains default** — `--http` is opt-in. Existing stdio configs are unaffected.
265
+
266
+ The `PORT`, `HOST`, `TLS_CERT_PATH`, and `TLS_KEY_PATH` environment variables are supported (lower precedence than CLI flags).
267
+
268
+ ## Container Images
269
+
270
+ Release CI publishes multi-arch OCI images to:
271
+
272
+ - Docker Hub: `ammo74/rosetta`
273
+ - GHCR: `ghcr.io/tikoci/rosetta`
274
+
275
+ Tags per release:
276
+
277
+ - `${version}` (example: `v0.2.1`)
278
+ - `latest`
279
+ - `sha-<12-char-commit>`
280
+
281
+ Container defaults:
282
+
283
+ - Starts in HTTP mode (`--http`) on `0.0.0.0`
284
+ - Uses `PORT` if set, otherwise 8080
285
+ - Uses HTTPS only when both `TLS_CERT_PATH` and `TLS_KEY_PATH` are set
286
+
287
+ Examples:
288
+
289
+ ```sh
290
+ docker run --rm -p 8080:8080 ghcr.io/tikoci/rosetta:latest
291
+ ```
292
+
293
+ ```sh
294
+ docker run --rm -p 8443:8443 \
295
+ -e PORT=8443 \
296
+ -e TLS_CERT_PATH=/certs/cert.pem \
297
+ -e TLS_KEY_PATH=/certs/key.pem \
298
+ -v "$PWD/certs:/certs:ro" \
299
+ ghcr.io/tikoci/rosetta:latest
300
+ ```
301
+
219
302
  ## Building from Source
220
303
 
221
304
  For contributors or when you have access to the MikroTik HTML documentation export.
@@ -276,11 +359,13 @@ make release VERSION=v0.1.0
276
359
 
277
360
  This cross-compiles to macOS (arm64 + x64), Windows (x64), and Linux (x64), creates ZIP archives, compresses the database, tags the commit, and creates a GitHub Release with all artifacts.
278
361
 
362
+ The release workflow also publishes OCI images to Docker Hub (`ammo74/rosetta`) and GHCR (`ghcr.io/tikoci/rosetta`) using crane (no Docker daemon required in CI).
363
+
279
364
  ## Project Structure
280
365
 
281
366
  ```text
282
367
  src/
283
- ├── mcp.ts # MCP server (11 tools, stdio) + CLI dispatch
368
+ ├── mcp.ts # MCP server (11 tools, stdio + HTTP) + CLI dispatch
284
369
  ├── setup.ts # --setup: DB download + MCP client config
285
370
  ├── query.ts # NL → FTS5 query planner, BM25 ranking
286
371
  ├── db.ts # SQLite schema, WAL mode, FTS5 triggers
@@ -294,6 +379,7 @@ src/
294
379
 
295
380
  scripts/
296
381
  └── build-release.ts # Cross-compile + package releases
382
+ └── container-entrypoint.sh # OCI image runtime entrypoint (HTTP default)
297
383
  ```
298
384
 
299
385
  ## Data Sources
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.2.1",
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
@@ -9,10 +9,19 @@
9
9
  * --setup [--force] Download database + print MCP client config
10
10
  * --version Print version
11
11
  * --help Print usage
12
+ * --http Start with Streamable HTTP transport (instead of stdio)
13
+ * --port <N> HTTP listen port (default: 8080, env: PORT)
14
+ * --host <ADDR> HTTP bind address (default: localhost, env: HOST)
15
+ * --tls-cert <PATH> TLS certificate PEM file (enables HTTPS, env: TLS_CERT_PATH)
16
+ * --tls-key <PATH> TLS private key PEM file (requires --tls-cert, env: TLS_KEY_PATH)
12
17
  * (default) Start MCP server (stdio transport)
13
18
  *
14
19
  * Environment variables:
15
20
  * DB_PATH — absolute path to ros-help.db (default: next to binary or project root)
21
+ * PORT — HTTP listen port (lower precedence than --port)
22
+ * HOST — HTTP bind address (lower precedence than --host)
23
+ * TLS_CERT_PATH — TLS certificate path (lower precedence than --tls-cert)
24
+ * TLS_KEY_PATH — TLS private key path (lower precedence than --tls-key)
16
25
  */
17
26
 
18
27
  import { resolveVersion } from "./paths.ts";
@@ -23,6 +32,12 @@ const RESOLVED_VERSION = resolveVersion(import.meta.dirname);
23
32
 
24
33
  const args = process.argv.slice(2);
25
34
 
35
+ /** Extract the value following a named flag (e.g. --port 8080 → "8080") */
36
+ function getArg(name: string): string | undefined {
37
+ const idx = args.indexOf(name);
38
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
39
+ }
40
+
26
41
  if (args.includes("--version") || args.includes("-v")) {
27
42
  console.log(`rosetta ${RESOLVED_VERSION}`);
28
43
  process.exit(0);
@@ -33,13 +48,24 @@ if (args.includes("--help") || args.includes("-h")) {
33
48
  console.log();
34
49
  console.log("Usage:");
35
50
  console.log(" rosetta Start MCP server (stdio transport)");
51
+ console.log(" rosetta --http Start with Streamable HTTP transport");
36
52
  console.log(" rosetta --setup Download database + print MCP client config");
37
53
  console.log(" rosetta --setup --force Re-download database");
38
54
  console.log(" rosetta --version Print version");
39
55
  console.log(" rosetta --help Print this help");
40
56
  console.log();
57
+ console.log("HTTP options (require --http):");
58
+ console.log(" --port <N> Listen port (default: 8080, env: PORT)");
59
+ console.log(" --host <ADDR> Bind address (default: localhost, env: HOST)");
60
+ console.log(" --tls-cert <PATH> TLS certificate PEM file (env: TLS_CERT_PATH)");
61
+ console.log(" --tls-key <PATH> TLS private key PEM file (env: TLS_KEY_PATH)");
62
+ console.log();
41
63
  console.log("Environment:");
42
64
  console.log(" DB_PATH Absolute path to ros-help.db (optional)");
65
+ console.log(" PORT HTTP listen port (lower precedence than --port)");
66
+ 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)");
43
69
  process.exit(0);
44
70
  }
45
71
 
@@ -54,8 +80,9 @@ if (args.includes("--setup")) {
54
80
 
55
81
  // ── MCP Server ──
56
82
 
83
+ const useHttp = args.includes("--http");
84
+
57
85
  const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
58
- const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
59
86
  const { z } = await import("zod/v3");
60
87
 
61
88
  // Dynamic imports — db.ts eagerly opens the DB file on import,
@@ -109,6 +136,10 @@ const {
109
136
 
110
137
  initDb();
111
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
+
112
143
  const server = new McpServer({
113
144
  name: "rosetta",
114
145
  version: RESOLVED_VERSION,
@@ -175,19 +206,17 @@ Use after routeros_search identifies a relevant page. Pass the numeric page ID
175
206
  Returns: plain text, code blocks, and callout blocks (notes, warnings, info, tips).
176
207
  Callouts contain crucial caveats and edge-case details — always review them.
177
208
 
178
- **Large page handling:** Always set max_length (e.g., 30000) on the first call.
179
- Some pages are 100K+ chars. When max_length is set and the page exceeds it,
209
+ **Large page handling:** max_length defaults to 16000. When page content exceeds it,
180
210
  pages with sections return a **table of contents** instead of truncated text.
181
211
  The TOC lists each section's heading, hierarchy level, character count, and
182
212
  deep-link URL. Re-call with the section parameter to retrieve specific sections.
183
213
 
184
214
  **Section parameter:** Pass a section heading or anchor_id (from the TOC)
185
- to get that section's content. Parent sections automatically include all
186
- sub-section content, so requesting a top-level heading gives you everything
187
- under it.
215
+ to get that section's content. If a section is still too large, its sub-section
216
+ TOC is returned instead request a more specific sub-section.
188
217
 
189
218
  Recommended workflow for large pages:
190
- 1. Call with max_length=30000 → get TOC if page is large
219
+ 1. First call → get TOC if page is large (automatic with default max_length)
191
220
  2. Review section headings to find the relevant section
192
221
  3. Call again with section="Section Name" to get that section's content
193
222
 
@@ -204,12 +233,12 @@ Workflow — what to do with this content:
204
233
  .number()
205
234
  .int()
206
235
  .min(1000)
207
- .optional()
208
- .describe("Recommended: set to 30000. Max combined text+code length. If exceeded and page has sections, returns a TOC instead of truncated text. Omit only if you need the entire page."),
236
+ .default(16000)
237
+ .describe("Max combined text+code length (default: 16000). If exceeded and page has sections, returns a TOC instead of truncated text. Set higher (e.g. 50000) to get more content in one call."),
209
238
  section: z
210
239
  .string()
211
240
  .optional()
212
- .describe("Section heading or anchor_id from TOC. Returns only that section's content."),
241
+ .describe("Section heading or anchor_id from TOC. Returns only that section's content (also subject to max_length)."),
213
242
  },
214
243
  },
215
244
  async ({ page, max_length, section }) => {
@@ -737,9 +766,149 @@ Requires network access to upgrade.mikrotik.com.`,
737
766
  },
738
767
  );
739
768
 
769
+ return server;
770
+ } // end createServer
771
+
740
772
  // ---- Start ----
741
773
 
742
- const transport = new StdioServerTransport();
743
- await server.connect(transport);
774
+ if (useHttp) {
775
+ const { existsSync } = await import("node:fs");
776
+ const { WebStandardStreamableHTTPServerTransport } = await import(
777
+ "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
778
+ );
779
+ const { isInitializeRequest } = await import(
780
+ "@modelcontextprotocol/sdk/types.js"
781
+ );
782
+
783
+ const port = Number(getArg("--port") ?? process.env.PORT ?? 8080);
784
+ const hostname = getArg("--host") ?? process.env.HOST ?? "localhost";
785
+ const tlsCert = getArg("--tls-cert") ?? process.env.TLS_CERT_PATH;
786
+ const tlsKey = getArg("--tls-key") ?? process.env.TLS_KEY_PATH;
787
+
788
+ if ((tlsCert && !tlsKey) || (!tlsCert && tlsKey)) {
789
+ process.stderr.write(
790
+ "Error: TLS cert and key must both be provided (via flags or TLS_CERT_PATH/TLS_KEY_PATH)\n"
791
+ );
792
+ process.exit(1);
793
+ }
794
+ if (tlsCert && !existsSync(tlsCert)) {
795
+ process.stderr.write(`Error: TLS certificate not found: ${tlsCert}\n`);
796
+ process.exit(1);
797
+ }
798
+ if (tlsKey && !existsSync(tlsKey)) {
799
+ process.stderr.write(`Error: TLS private key not found: ${tlsKey}\n`);
800
+ process.exit(1);
801
+ }
802
+
803
+ const useTls = !!(tlsCert && tlsKey);
804
+ const scheme = useTls ? "https" : "http";
805
+
806
+ // Per-session transport routing (each MCP client session gets its own transport + server)
807
+ const transports = new Map<string, InstanceType<typeof WebStandardStreamableHTTPServerTransport>>();
808
+
809
+ const isLAN = hostname === "0.0.0.0" || hostname === "::";
810
+ if (isLAN) {
811
+ process.stderr.write(
812
+ "Warning: Binding to all interfaces — the MCP server will be accessible from the network.\n"
813
+ );
814
+ if (!useTls) {
815
+ process.stderr.write(
816
+ " Consider using --tls-cert/--tls-key or a reverse proxy for production use.\n"
817
+ );
818
+ }
819
+ }
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
+
829
+ Bun.serve({
830
+ port,
831
+ hostname,
832
+ ...(useTls && tlsCert && tlsKey
833
+ ? { tls: { cert: Bun.file(tlsCert), key: Bun.file(tlsKey) } }
834
+ : {}),
835
+ async fetch(req: Request): Promise<Response> {
836
+ const url = new URL(req.url);
837
+
838
+ if (url.pathname !== "/mcp") {
839
+ return new Response("Not Found", { status: 404 });
840
+ }
841
+
842
+ // DNS rebinding protection: reject browser-origin requests
843
+ const origin = req.headers.get("origin");
844
+ if (origin) {
845
+ try {
846
+ const originHost = new URL(origin).host;
847
+ const serverHost = `${isLAN ? "localhost" : hostname}:${port}`;
848
+ if (originHost !== serverHost && originHost !== `localhost:${port}` && originHost !== `127.0.0.1:${port}`) {
849
+ return new Response("Forbidden: Origin not allowed", { status: 403 });
850
+ }
851
+ } catch {
852
+ return new Response("Forbidden: Invalid Origin", { status: 403 });
853
+ }
854
+ }
855
+
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
+ }
896
+ }
897
+
898
+ return jsonRpcError(400, -32000, "Bad Request: No valid session ID provided");
899
+ },
900
+ });
901
+
902
+ const displayHost = isLAN ? "localhost" : hostname;
903
+ process.stderr.write(`rosetta ${RESOLVED_VERSION} — Streamable HTTP\n`);
904
+ process.stderr.write(` ${scheme}://${displayHost}:${port}/mcp\n`);
905
+ } else {
906
+ const { StdioServerTransport } = await import(
907
+ "@modelcontextprotocol/sdk/server/stdio.js"
908
+ );
909
+ const server = createServer();
910
+ const transport = new StdioServerTransport();
911
+ await server.connect(transport);
912
+ }
744
913
 
745
914
  })();
package/src/query.ts CHANGED
@@ -184,6 +184,7 @@ export function getPage(idOrTitle: string | number, maxLength?: number, section?
184
184
  word_count: number;
185
185
  code_lines: number;
186
186
  callouts: Array<{ type: string; content: string }>;
187
+ callout_summary?: { count: number; types: Record<string, number> };
187
188
  truncated?: { text_total: number; code_total: number };
188
189
  sections?: SectionTocEntry[];
189
190
  section?: { heading: string; level: number; anchor_id: string };
@@ -221,11 +222,11 @@ export function getPage(idOrTitle: string | number, maxLength?: number, section?
221
222
 
222
223
  const descendants = db
223
224
  .prepare(
224
- `SELECT heading, level, text, code, word_count
225
+ `SELECT heading, level, anchor_id, text, code, word_count
225
226
  FROM sections WHERE page_id = ? AND sort_order > ? AND level > ? AND sort_order < ?
226
227
  ORDER BY sort_order`,
227
228
  )
228
- .all(page.id, sec.sort_order, sec.level, upperBound) as Array<{ heading: string; level: number; text: string; code: string; word_count: number }>;
229
+ .all(page.id, sec.sort_order, sec.level, upperBound) as Array<{ heading: string; level: number; anchor_id: string; text: string; code: string; word_count: number }>;
229
230
 
230
231
  let fullText = sec.text;
231
232
  let fullCode = sec.code;
@@ -237,6 +238,34 @@ export function getPage(idOrTitle: string | number, maxLength?: number, section?
237
238
  totalWords += child.word_count;
238
239
  }
239
240
 
241
+ // If section+descendants exceed maxLength and there are subsections, return sub-TOC
242
+ if (maxLength && (fullText.length + fullCode.length) > maxLength && descendants.length > 0) {
243
+ const subToc: SectionTocEntry[] = [
244
+ { heading: sec.heading, level: sec.level, anchor_id: sec.anchor_id, char_count: sec.text.length + sec.code.length, url: `${page.url}#${sec.anchor_id}` },
245
+ ...descendants.map((d) => ({ heading: d.heading, level: d.level, anchor_id: d.anchor_id, char_count: d.text.length + d.code.length, url: `${page.url}#${d.anchor_id}` })),
246
+ ];
247
+ const totalChars = fullText.length + fullCode.length;
248
+ return {
249
+ id: page.id, title: page.title, path: page.path, url: `${page.url}#${sec.anchor_id}`,
250
+ text: "", code: "",
251
+ word_count: totalWords, code_lines: 0,
252
+ callouts: [], callout_summary: calloutSummary(callouts),
253
+ sections: subToc,
254
+ section: { heading: sec.heading, level: sec.level, anchor_id: sec.anchor_id },
255
+ note: `Section "${sec.heading}" content (${totalChars} chars) exceeds max_length (${maxLength}). Showing ${subToc.length} sub-sections. Re-call with a more specific section heading or anchor_id.`,
256
+ };
257
+ }
258
+
259
+ // If section exceeds maxLength but has no sub-sections, truncate
260
+ if (maxLength && (fullText.length + fullCode.length) > maxLength) {
261
+ const textTotal = fullText.length;
262
+ const codeTotal = fullCode.length;
263
+ const codeBudget = Math.min(codeTotal, Math.floor(maxLength * 0.2));
264
+ const textBudget = maxLength - codeBudget;
265
+ fullText = `${fullText.slice(0, textBudget)}\n\n[... truncated — ${textTotal} chars total, showing first ${textBudget}]`;
266
+ fullCode = codeTotal > codeBudget ? `${fullCode.slice(0, codeBudget)}\n# [... truncated — ${codeTotal} chars total]` : fullCode;
267
+ }
268
+
240
269
  return {
241
270
  id: page.id,
242
271
  title: page.title,
@@ -258,7 +287,8 @@ export function getPage(idOrTitle: string | number, maxLength?: number, section?
258
287
  id: page.id, title: page.title, path: page.path, url: page.url,
259
288
  text: "", code: "",
260
289
  word_count: page.word_count, code_lines: page.code_lines,
261
- callouts, sections: toc,
290
+ callouts: [], callout_summary: calloutSummary(callouts),
291
+ sections: toc,
262
292
  note: `Section "${section}" not found. ${toc.length} sections available — use a heading or anchor_id from the list.`,
263
293
  };
264
294
  }
@@ -284,7 +314,8 @@ export function getPage(idOrTitle: string | number, maxLength?: number, section?
284
314
  id: page.id, title: page.title, path: page.path, url: page.url,
285
315
  text: "", code: "",
286
316
  word_count: page.word_count, code_lines: page.code_lines,
287
- callouts, sections: toc,
317
+ callouts: [], callout_summary: calloutSummary(callouts),
318
+ sections: toc,
288
319
  truncated: { text_total: text.length, code_total: code.length },
289
320
  note: `Page content (${totalChars} chars) exceeds max_length (${maxLength}). Showing table of contents with ${toc.length} sections. Re-call with section parameter to retrieve specific sections.`,
290
321
  };
@@ -303,6 +334,13 @@ export function getPage(idOrTitle: string | number, maxLength?: number, section?
303
334
  return { id: page.id, title: page.title, path: page.path, url: page.url, text, code, word_count: page.word_count, code_lines: page.code_lines, callouts, ...(truncated ? { truncated } : {}) };
304
335
  }
305
336
 
337
+ /** Build compact callout summary (count + type breakdown) for TOC-mode responses. */
338
+ function calloutSummary(callouts: Array<{ type: string; content: string }>): { count: number; types: Record<string, number> } {
339
+ const types: Record<string, number> = {};
340
+ for (const c of callouts) types[c.type] = (types[c.type] || 0) + 1;
341
+ return { count: callouts.length, types };
342
+ }
343
+
306
344
  /** Build section TOC for a page. */
307
345
  function getPageToc(pageId: number, pageUrl: string): SectionTocEntry[] {
308
346
  const rows = db
@@ -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
+ });
package/src/setup.ts CHANGED
@@ -167,6 +167,8 @@ function printCompiledConfig(serverCmd: string) {
167
167
  console.log("▸ OpenAI Codex");
168
168
  console.log(` codex mcp add rosetta -- ${serverCmd}`);
169
169
  console.log();
170
+
171
+ printHttpConfig(`${serverCmd} --http`);
170
172
  }
171
173
 
172
174
  function printPackageConfig() {
@@ -228,6 +230,8 @@ function printPackageConfig() {
228
230
  console.log("▸ OpenAI Codex");
229
231
  console.log(` codex mcp add rosetta -- bunx @tikoci/rosetta`);
230
232
  console.log();
233
+
234
+ printHttpConfig("bunx @tikoci/rosetta --http");
231
235
  }
232
236
 
233
237
  function printDevConfig(baseDir: string) {
@@ -276,6 +280,27 @@ function printDevConfig(baseDir: string) {
276
280
  console.log("▸ OpenAI Codex");
277
281
  console.log(` codex mcp add rosetta -- bun run src/mcp.ts`);
278
282
  console.log();
283
+
284
+ printHttpConfig(`bun run src/mcp.ts --http`);
285
+ }
286
+
287
+ function printHttpConfig(startCmd: string) {
288
+ console.log("─".repeat(60));
289
+ console.log("Streamable HTTP transport (for HTTP-only MCP clients):");
290
+ console.log("─".repeat(60));
291
+ console.log();
292
+ console.log("▸ Start in HTTP mode");
293
+ console.log(` ${startCmd}`);
294
+ console.log(` ${startCmd} --port 9090`);
295
+ console.log(` ${startCmd} --host 0.0.0.0 # LAN access`);
296
+ console.log(` ${startCmd} --tls-cert cert.pem --tls-key key.pem # HTTPS`);
297
+ console.log();
298
+ console.log("▸ URL-based MCP clients (OpenAI, etc.)");
299
+ console.log(` { "url": "http://localhost:8080/mcp" }`);
300
+ console.log();
301
+ console.log(" For LAN access, replace localhost with the server's IP address.");
302
+ console.log(" Use a reverse proxy (nginx, caddy) for production HTTPS.");
303
+ console.log();
279
304
  }
280
305
 
281
306
  // Run directly