@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 +88 -2
- package/package.json +1 -1
- package/src/mcp-http.test.ts +443 -0
- package/src/mcp.ts +181 -12
- package/src/query.ts +42 -4
- package/src/release.test.ts +105 -0
- package/src/setup.ts +25 -0
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
|
@@ -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:**
|
|
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.
|
|
186
|
-
|
|
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.
|
|
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
|
-
.
|
|
208
|
-
.describe("
|
|
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
|
-
|
|
743
|
-
await
|
|
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,
|
|
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,
|
|
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
|
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
|
+
});
|
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
|