@tikoci/rosetta 0.4.0 → 0.4.2
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 +13 -1
- package/package.json +1 -1
- package/src/db.ts +39 -8
- package/src/extract-test-results.ts +86 -13
- package/src/mcp-http.test.ts +168 -23
- package/src/mcp.ts +241 -1
- package/src/paths.ts +8 -0
- package/src/query.test.ts +426 -13
- package/src/query.ts +420 -23
- package/src/setup.ts +7 -2
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ Instead of vector embeddings, rosetta uses **SQLite [FTS5](https://www.sqlite.or
|
|
|
17
17
|
- **144 hardware products** — CPU, RAM, storage, ports, PoE, wireless, license level, pricing
|
|
18
18
|
- **2,874 performance benchmarks** — ethernet and IPSec throughput test results for 125 devices (64/512/1518-byte packets, multiple routing/bridging modes), plus block diagrams for 110
|
|
19
19
|
- **46 RouterOS versions tracked** (7.9 through 7.23beta2) for command history
|
|
20
|
+
- **2 MCP CSV resources** for bulk reporting workflows: full benchmark dataset and full device catalog
|
|
20
21
|
- Direct links to help.mikrotik.com for every page and section
|
|
21
22
|
|
|
22
23
|
---
|
|
@@ -212,6 +213,15 @@ powershell -c "irm bun.sh/install.ps1 | iex"
|
|
|
212
213
|
|
|
213
214
|
> **Auto-update:** `bunx` checks the npm registry each session and uses the latest published version automatically. The database in `~/.rosetta/ros-help.db` persists across updates.
|
|
214
215
|
|
|
216
|
+
### MCP Resources for Reporting
|
|
217
|
+
|
|
218
|
+
If your MCP client supports resources, rosetta also exposes two read-only CSV datasets for bulk analysis and reporting:
|
|
219
|
+
|
|
220
|
+
- `rosetta://datasets/device-test-results.csv`
|
|
221
|
+
- `rosetta://datasets/devices.csv`
|
|
222
|
+
|
|
223
|
+
In VS Code Copilot, attach them via **Add Context > MCP Resources** or **MCP: Browse Resources**. Use tools for normal search and drill-down; use resources when you explicitly want the whole dataset as CSV.
|
|
224
|
+
|
|
215
225
|
---
|
|
216
226
|
|
|
217
227
|
## Install from Binary
|
|
@@ -245,10 +255,11 @@ Ask your AI assistant questions like:
|
|
|
245
255
|
- *"Show me warnings about hardware offloading"*
|
|
246
256
|
- *"Which MikroTik routers have L3HW offload, and more than 8 ports of 48V PoE? Include cost."*
|
|
247
257
|
- *"Compare the RB5009 and CCR2004 IPSec throughput at 1518-byte packets."*
|
|
258
|
+
- *"My BGP routes stopped working after upgrading from 7.15 to 7.22 — what changed in the routing commands?"*
|
|
248
259
|
|
|
249
260
|
## MCP Tools
|
|
250
261
|
|
|
251
|
-
The server provides
|
|
262
|
+
The server provides 13 tools, designed to work together:
|
|
252
263
|
|
|
253
264
|
| Tool | What it does |
|
|
254
265
|
|------|-------------|
|
|
@@ -260,6 +271,7 @@ The server provides 11 tools, designed to work together:
|
|
|
260
271
|
| `routeros_search_callouts` | Search warnings, notes, and tips across all pages |
|
|
261
272
|
| `routeros_search_changelogs` | Search parsed changelog entries — filter by version range, category, breaking changes |
|
|
262
273
|
| `routeros_command_version_check` | Check which RouterOS versions include a command |
|
|
274
|
+
| `routeros_command_diff` | Diff two RouterOS versions — which command paths were added or removed between them |
|
|
263
275
|
| `routeros_device_lookup` | Hardware specs for 144 MikroTik products — filter by architecture, RAM, storage, PoE, wireless, LTE. Includes ethernet/IPSec benchmarks and block diagrams for most devices |
|
|
264
276
|
| `routeros_stats` | Database health: page/property/command counts, coverage stats |
|
|
265
277
|
| `routeros_current_versions` | Fetch current RouterOS versions from MikroTik (live) |
|
package/package.json
CHANGED
package/src/db.ts
CHANGED
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import sqlite from "bun:sqlite";
|
|
23
|
-
import { resolveDbPath } from "./paths.ts";
|
|
23
|
+
import { resolveDbPath, SCHEMA_VERSION } from "./paths.ts";
|
|
24
|
+
|
|
25
|
+
export { SCHEMA_VERSION };
|
|
24
26
|
|
|
25
27
|
export const DB_PATH = resolveDbPath(import.meta.dirname);
|
|
26
28
|
|
|
@@ -29,7 +31,11 @@ export const db = new sqlite(DB_PATH);
|
|
|
29
31
|
export function initDb() {
|
|
30
32
|
db.run("PRAGMA journal_mode=WAL;");
|
|
31
33
|
db.run("PRAGMA foreign_keys=ON;");
|
|
32
|
-
|
|
34
|
+
// Stamp schema version unconditionally — initDb() is only called by extractors
|
|
35
|
+
// (which produce a current-schema DB) and by the MCP server after the version
|
|
36
|
+
// check in mcp.ts. If you ever need to open a DB read-only without touching
|
|
37
|
+
// user_version, call `db.run("PRAGMA foreign_keys=ON;")` directly and skip initDb().
|
|
38
|
+
db.run(`PRAGMA user_version = ${SCHEMA_VERSION};`);
|
|
33
39
|
db.run(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
34
40
|
version TEXT PRIMARY KEY,
|
|
35
41
|
applied_at TEXT NOT NULL
|
|
@@ -342,13 +348,19 @@ export function initDb() {
|
|
|
342
348
|
END;`);
|
|
343
349
|
}
|
|
344
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Verify the open DB was built with the expected schema version.
|
|
353
|
+
* Only meaningful after initDb() — initDb() itself stamps the version,
|
|
354
|
+
* so this is mainly a regression guard and for the test suite.
|
|
355
|
+
*/
|
|
356
|
+
export function checkSchemaVersion(): { ok: boolean; actual: number; expected: number } {
|
|
357
|
+
const row = db.prepare("PRAGMA user_version").get() as { user_version: number };
|
|
358
|
+
return { ok: row.user_version === SCHEMA_VERSION, actual: row.user_version, expected: SCHEMA_VERSION };
|
|
359
|
+
}
|
|
360
|
+
|
|
345
361
|
export function getDbStats() {
|
|
346
362
|
const count = (sql: string) =>
|
|
347
363
|
Number((db.prepare(sql).get() as { c: number }).c ?? 0);
|
|
348
|
-
const scalar = (sql: string) => {
|
|
349
|
-
const row = db.prepare(sql).get() as { v: string | null } | null;
|
|
350
|
-
return row?.v ?? null;
|
|
351
|
-
};
|
|
352
364
|
return {
|
|
353
365
|
db_path: DB_PATH,
|
|
354
366
|
pages: count("SELECT COUNT(*) AS c FROM pages"),
|
|
@@ -363,8 +375,27 @@ export function getDbStats() {
|
|
|
363
375
|
changelogs: count("SELECT COUNT(*) AS c FROM changelogs"),
|
|
364
376
|
changelog_versions: count("SELECT COUNT(DISTINCT version) AS c FROM changelogs"),
|
|
365
377
|
ros_versions: count("SELECT COUNT(*) AS c FROM ros_versions"),
|
|
366
|
-
|
|
367
|
-
|
|
378
|
+
...(() => {
|
|
379
|
+
// Semantic version sort — SQL MIN/MAX is lexicographic ("7.10" < "7.9")
|
|
380
|
+
const versions = (db.prepare("SELECT version FROM ros_versions").all() as Array<{ version: string }>).map((r) => r.version);
|
|
381
|
+
if (versions.length === 0) return { ros_version_min: null, ros_version_max: null };
|
|
382
|
+
const norm = (v: string) => {
|
|
383
|
+
const clean = v.replace(/beta\d*/, "").replace(/rc\d*/, "");
|
|
384
|
+
const parts = clean.split(".").map(Number);
|
|
385
|
+
const suffix = v.includes("beta") ? 0 : v.includes("rc") ? 1 : 2;
|
|
386
|
+
return { parts, suffix };
|
|
387
|
+
};
|
|
388
|
+
const cmp = (a: string, b: string) => {
|
|
389
|
+
const na = norm(a), nb = norm(b);
|
|
390
|
+
for (let i = 0; i < Math.max(na.parts.length, nb.parts.length); i++) {
|
|
391
|
+
const d = (na.parts[i] ?? 0) - (nb.parts[i] ?? 0);
|
|
392
|
+
if (d !== 0) return d;
|
|
393
|
+
}
|
|
394
|
+
return na.suffix - nb.suffix;
|
|
395
|
+
};
|
|
396
|
+
versions.sort(cmp);
|
|
397
|
+
return { ros_version_min: versions[0], ros_version_max: versions[versions.length - 1] };
|
|
398
|
+
})(),
|
|
368
399
|
doc_export: "2026-03-25 (Confluence HTML)",
|
|
369
400
|
};
|
|
370
401
|
}
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Usage: bun run src/extract-test-results.ts [--concurrency N] [--delay MS]
|
|
14
14
|
*
|
|
15
|
-
* Product page URL slug discovery: fetches
|
|
16
|
-
*
|
|
15
|
+
* Product page URL slug discovery: fetches sitemap.xml once to build a validated
|
|
16
|
+
* slug set, then applies (1) a hardcoded override table for the 15 products with
|
|
17
|
+
* opaque/unpredictable slugs, (2) heuristic candidates validated against the sitemap,
|
|
18
|
+
* (3) raw heuristics as a fallback for new products not yet in the sitemap.
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import { parseHTML } from "linkedom";
|
|
@@ -31,6 +33,28 @@ function getFlag(name: string, fallback: number): number {
|
|
|
31
33
|
const CONCURRENCY = getFlag("concurrency", 4);
|
|
32
34
|
const DELAY_MS = getFlag("delay", 500);
|
|
33
35
|
const PRODUCT_BASE = "https://mikrotik.com/product/";
|
|
36
|
+
const SITEMAP_URL = "https://mikrotik.com/sitemap.xml";
|
|
37
|
+
|
|
38
|
+
// ── Slug overrides ──
|
|
39
|
+
// Products whose URL slugs are undiscoverable by heuristics alone.
|
|
40
|
+
// Derived from sitemap.xml research (2026-04-04 — all 15 missing devices resolved).
|
|
41
|
+
const SLUG_OVERRIDES: Record<string, string> = {
|
|
42
|
+
"ATL LTE18 kit": "atl18",
|
|
43
|
+
"CRS418-8P-8G-2S+5axQ2axQ-RM": "crs418_8p_8g_2s_wifi",
|
|
44
|
+
"CRS518-16XS-2XQ-RM": "crs518_16xs_2xq",
|
|
45
|
+
"Chateau LTE18 ax": "chateaulte18_ax",
|
|
46
|
+
"FiberBox Plus": "fiberboxplus",
|
|
47
|
+
"KNOT Embedded LTE4 Global": "knot_emb_lte4_global",
|
|
48
|
+
"LHG LTE18 kit": "lhg_lte18",
|
|
49
|
+
"LHG XL 5 ax": "lhg_5_ax_xl",
|
|
50
|
+
"LHGG LTE7 kit": "lhgg_lte7",
|
|
51
|
+
"RB5009UPr+S+OUT": "rb5009_out",
|
|
52
|
+
"ROSE Data server (RDS)": "rds2216",
|
|
53
|
+
"SXTsq 5 ax": "sxtsq_5ax",
|
|
54
|
+
"cAP lite": "RBcAPL-2nD-307",
|
|
55
|
+
"hEX refresh": "hex_2024",
|
|
56
|
+
"wAP ax LTE7 kit": "wap_ax_lte7",
|
|
57
|
+
};
|
|
34
58
|
|
|
35
59
|
// ── Types ──
|
|
36
60
|
|
|
@@ -51,14 +75,6 @@ interface ProductPageData {
|
|
|
51
75
|
|
|
52
76
|
// ── HTML Parsing ──
|
|
53
77
|
|
|
54
|
-
/** Decode HTML entities like none to text. */
|
|
55
|
-
function decodeEntities(html: string): string {
|
|
56
|
-
const { document } = parseHTML("<div></div>");
|
|
57
|
-
const el = document.createElement("div");
|
|
58
|
-
el.innerHTML = html;
|
|
59
|
-
return el.textContent || "";
|
|
60
|
-
}
|
|
61
|
-
|
|
62
78
|
/** Parse a performance-table element into test result rows. */
|
|
63
79
|
function parsePerformanceTable(table: Element): { testType: string; rows: TestResultRow[] } {
|
|
64
80
|
const rows: TestResultRow[] = [];
|
|
@@ -162,10 +178,10 @@ function generateSlugs(name: string, code: string | null): string[] {
|
|
|
162
178
|
const cleanCode = norm(code);
|
|
163
179
|
|
|
164
180
|
// 3. Product code as-is (original casing, + → plus, strip other specials)
|
|
165
|
-
add(cleanCode.replace(/\+/g, "plus").replace(/[^a-zA-Z0-9plus
|
|
181
|
+
add(cleanCode.replace(/\+/g, "plus").replace(/[^a-zA-Z0-9plus-]+/g, "").replace(/^-|-$/g, ""));
|
|
166
182
|
|
|
167
183
|
// 4. Product code as-is (original casing)
|
|
168
|
-
add(cleanCode.replace(/[^a-zA-Z0-9
|
|
184
|
+
add(cleanCode.replace(/[^a-zA-Z0-9-]+/g, "").replace(/^-|-$/g, ""));
|
|
169
185
|
|
|
170
186
|
// 5. Lowercased code: + → plus
|
|
171
187
|
add(cleanCode.toLowerCase().replace(/\+/g, "plus").replace(/[^a-z0-9plus]+/g, "_").replace(/^_|_$/g, ""));
|
|
@@ -177,6 +193,59 @@ function generateSlugs(name: string, code: string | null): string[] {
|
|
|
177
193
|
return slugs;
|
|
178
194
|
}
|
|
179
195
|
|
|
196
|
+
/** Fetch sitemap.xml and return the set of all valid product slugs.
|
|
197
|
+
* Returns an empty set on error (callers fall back to heuristics). */
|
|
198
|
+
async function fetchSitemap(): Promise<Set<string>> {
|
|
199
|
+
try {
|
|
200
|
+
const resp = await fetch(SITEMAP_URL);
|
|
201
|
+
if (!resp.ok) {
|
|
202
|
+
console.warn(` [sitemap] HTTP ${resp.status} — falling back to heuristics`);
|
|
203
|
+
return new Set();
|
|
204
|
+
}
|
|
205
|
+
const xml = await resp.text();
|
|
206
|
+
const slugs = new Set<string>();
|
|
207
|
+
const re = /\/product\/([^<"]+)/g;
|
|
208
|
+
while (true) {
|
|
209
|
+
const m = re.exec(xml);
|
|
210
|
+
if (m === null) break;
|
|
211
|
+
slugs.add(m[1]);
|
|
212
|
+
}
|
|
213
|
+
console.log(` [sitemap] ${slugs.size} product slugs loaded`);
|
|
214
|
+
return slugs;
|
|
215
|
+
} catch {
|
|
216
|
+
console.warn(" [sitemap] fetch failed — falling back to heuristics");
|
|
217
|
+
return new Set();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Build ordered slug candidates for a product.
|
|
222
|
+
* Priority: (1) override table, (2) generated slugs that appear in sitemap,
|
|
223
|
+
* (3) remaining generated slugs as fallback. */
|
|
224
|
+
function buildSlugCandidates(name: string, code: string | null, sitemapSlugs: Set<string>): string[] {
|
|
225
|
+
const slugs: string[] = [];
|
|
226
|
+
const seen = new Set<string>();
|
|
227
|
+
const add = (s: string) => {
|
|
228
|
+
if (s && !seen.has(s)) { seen.add(s); slugs.push(s); }
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// 1. Override table (highest priority — known-opaque slugs)
|
|
232
|
+
const override = SLUG_OVERRIDES[name];
|
|
233
|
+
if (override) add(override);
|
|
234
|
+
|
|
235
|
+
// 2. Heuristic candidates validated against sitemap
|
|
236
|
+
const generated = generateSlugs(name, code);
|
|
237
|
+
if (sitemapSlugs.size > 0) {
|
|
238
|
+
for (const s of generated) {
|
|
239
|
+
if (sitemapSlugs.has(s)) add(s);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 3. All remaining heuristic candidates (fallback for new products not yet in sitemap)
|
|
244
|
+
for (const s of generated) add(s);
|
|
245
|
+
|
|
246
|
+
return slugs;
|
|
247
|
+
}
|
|
248
|
+
|
|
180
249
|
/** Fetch and parse a single product page, trying multiple slug candidates. */
|
|
181
250
|
async function fetchProductPage(slugs: string[]): Promise<ProductPageData | null> {
|
|
182
251
|
for (const slug of slugs) {
|
|
@@ -257,10 +326,14 @@ if (devices.length === 0) {
|
|
|
257
326
|
|
|
258
327
|
console.log(`Found ${devices.length} devices in database`);
|
|
259
328
|
|
|
329
|
+
// Fetch sitemap once to validate slugs and prioritize correct candidates
|
|
330
|
+
console.log("Loading product sitemap...");
|
|
331
|
+
const sitemapSlugs = await fetchSitemap();
|
|
332
|
+
|
|
260
333
|
// Build device → candidate slugs mapping
|
|
261
334
|
const deviceSlugs: Array<{ id: number; name: string; slugs: string[] }> = [];
|
|
262
335
|
for (const dev of devices) {
|
|
263
|
-
const slugs =
|
|
336
|
+
const slugs = buildSlugCandidates(dev.product_name, dev.product_code, sitemapSlugs);
|
|
264
337
|
deviceSlugs.push({ id: dev.id, name: dev.product_name, slugs });
|
|
265
338
|
}
|
|
266
339
|
|
package/src/mcp-http.test.ts
CHANGED
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Each test gets an isolated server on a fresh port.
|
|
15
15
|
*/
|
|
16
|
+
import sqlite from "bun:sqlite";
|
|
16
17
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
18
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
17
21
|
import type { Subprocess } from "bun";
|
|
18
22
|
|
|
19
23
|
// ── Helpers ──
|
|
@@ -31,42 +35,146 @@ interface ServerHandle {
|
|
|
31
35
|
proc: Subprocess;
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
interface ProcessLogs {
|
|
39
|
+
stdout: string[];
|
|
40
|
+
stderr: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Continuously consume a subprocess stream and append decoded chunks. */
|
|
44
|
+
async function collectStream(stream: ReadableStream<Uint8Array>, sink: string[]): Promise<void> {
|
|
45
|
+
const reader = stream.getReader();
|
|
46
|
+
const decoder = new TextDecoder();
|
|
47
|
+
try {
|
|
48
|
+
while (true) {
|
|
49
|
+
const { value, done } = await reader.read();
|
|
50
|
+
if (done) break;
|
|
51
|
+
sink.push(decoder.decode(value));
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
reader.releaseLock();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Keep only the last N characters from aggregated process logs for error messages. */
|
|
59
|
+
function tailLogs(logs: ProcessLogs, maxChars = 4000): string {
|
|
60
|
+
const merged = `--- stdout ---\n${logs.stdout.join("")}\n--- stderr ---\n${logs.stderr.join("")}`;
|
|
61
|
+
return merged.length > maxChars ? merged.slice(-maxChars) : merged;
|
|
62
|
+
}
|
|
63
|
+
|
|
34
64
|
/** Start the MCP server on a random port, wait for it to be ready. */
|
|
35
|
-
|
|
65
|
+
function createFixtureDb(dbPath: string): void {
|
|
66
|
+
const fixture = new sqlite(dbPath);
|
|
67
|
+
fixture.run(`CREATE TABLE pages (
|
|
68
|
+
id INTEGER PRIMARY KEY,
|
|
69
|
+
slug TEXT NOT NULL,
|
|
70
|
+
title TEXT NOT NULL,
|
|
71
|
+
path TEXT NOT NULL,
|
|
72
|
+
depth INTEGER NOT NULL,
|
|
73
|
+
parent_id INTEGER REFERENCES pages(id),
|
|
74
|
+
url TEXT NOT NULL,
|
|
75
|
+
text TEXT NOT NULL,
|
|
76
|
+
code TEXT NOT NULL,
|
|
77
|
+
code_lang TEXT,
|
|
78
|
+
author TEXT,
|
|
79
|
+
last_updated TEXT,
|
|
80
|
+
word_count INTEGER NOT NULL,
|
|
81
|
+
code_lines INTEGER NOT NULL,
|
|
82
|
+
html_file TEXT NOT NULL
|
|
83
|
+
);`);
|
|
84
|
+
|
|
85
|
+
fixture.run(`CREATE TABLE commands (
|
|
86
|
+
id INTEGER PRIMARY KEY,
|
|
87
|
+
path TEXT NOT NULL UNIQUE,
|
|
88
|
+
name TEXT NOT NULL,
|
|
89
|
+
type TEXT NOT NULL,
|
|
90
|
+
parent_path TEXT,
|
|
91
|
+
page_id INTEGER REFERENCES pages(id),
|
|
92
|
+
description TEXT,
|
|
93
|
+
ros_version TEXT
|
|
94
|
+
);`);
|
|
95
|
+
|
|
96
|
+
fixture.run(`INSERT INTO pages (
|
|
97
|
+
id, slug, title, path, depth, parent_id, url, text, code, code_lang,
|
|
98
|
+
author, last_updated, word_count, code_lines, html_file
|
|
99
|
+
) VALUES (
|
|
100
|
+
1, 'fixture', 'Fixture Page', 'RouterOS > Fixture', 1, NULL,
|
|
101
|
+
'https://help.mikrotik.com/docs/spaces/ROS/pages/1/Fixture',
|
|
102
|
+
'fixture text', '', NULL, 'test', NULL, 2, 0, 'fixture.html'
|
|
103
|
+
);`);
|
|
104
|
+
|
|
105
|
+
fixture.run(`INSERT INTO commands (
|
|
106
|
+
id, path, name, type, parent_path, page_id, description, ros_version
|
|
107
|
+
) VALUES (
|
|
108
|
+
1, '/system', 'system', 'dir', NULL, 1, 'fixture command', '7.22'
|
|
109
|
+
);`);
|
|
110
|
+
|
|
111
|
+
fixture.close();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function startServer(dbPath: string): Promise<ServerHandle> {
|
|
36
115
|
const port = nextPort();
|
|
37
116
|
const proc = Bun.spawn(["bun", "run", "src/mcp.ts", "--http", "--port", String(port)], {
|
|
38
117
|
cwd: `${import.meta.dirname}/..`,
|
|
39
118
|
stdout: "pipe",
|
|
40
119
|
stderr: "pipe",
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
120
|
+
// Use an isolated fixture DB and avoid network-dependent auto-downloads.
|
|
121
|
+
env: { ...process.env, HOST: "127.0.0.1", DB_PATH: dbPath },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const logs: ProcessLogs = { stdout: [], stderr: [] };
|
|
125
|
+
const stdoutCollector = collectStream(proc.stdout, logs.stdout);
|
|
126
|
+
const stderrCollector = collectStream(proc.stderr, logs.stderr);
|
|
127
|
+
|
|
128
|
+
let exitCode: number | null = null;
|
|
129
|
+
void proc.exited.then((code) => {
|
|
130
|
+
exitCode = code;
|
|
44
131
|
});
|
|
45
132
|
|
|
46
|
-
// Wait for server
|
|
47
|
-
//
|
|
48
|
-
const deadline = Date.now() +
|
|
133
|
+
// Wait for server readiness by polling the actual HTTP endpoint.
|
|
134
|
+
// Use a generous timeout because first boot may auto-download the DB.
|
|
135
|
+
const deadline = Date.now() + 120_000;
|
|
49
136
|
let ready = false;
|
|
50
|
-
const decoder = new TextDecoder();
|
|
51
|
-
const reader = proc.stderr.getReader();
|
|
52
137
|
|
|
53
138
|
while (Date.now() < deadline) {
|
|
54
|
-
|
|
55
|
-
if (done) break;
|
|
56
|
-
const text = decoder.decode(value);
|
|
57
|
-
if (text.includes(`/mcp`)) {
|
|
58
|
-
ready = true;
|
|
139
|
+
if (exitCode !== null) {
|
|
59
140
|
break;
|
|
60
141
|
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const resp = await fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
145
|
+
method: "GET",
|
|
146
|
+
headers: { Accept: "text/event-stream" },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Endpoint is alive; missing session ID should return 400.
|
|
150
|
+
if (resp.status === 400) {
|
|
151
|
+
ready = true;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// Connection refused while server is still starting.
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await Bun.sleep(200);
|
|
61
159
|
}
|
|
62
|
-
// Release the reader lock so the stream can be consumed later or cleaned up
|
|
63
|
-
reader.releaseLock();
|
|
64
160
|
|
|
65
161
|
if (!ready) {
|
|
66
162
|
proc.kill();
|
|
67
|
-
|
|
163
|
+
await proc.exited.catch(() => undefined);
|
|
164
|
+
await Promise.all([stdoutCollector, stderrCollector]);
|
|
165
|
+
|
|
166
|
+
const reason = exitCode !== null
|
|
167
|
+
? `exited early with code ${exitCode}`
|
|
168
|
+
: "did not become ready before timeout";
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Server failed to start on port ${port}: ${reason}\n${tailLogs(logs)}`,
|
|
171
|
+
);
|
|
68
172
|
}
|
|
69
173
|
|
|
174
|
+
// Keep collectors running in background; they naturally finish when process exits.
|
|
175
|
+
void stdoutCollector;
|
|
176
|
+
void stderrCollector;
|
|
177
|
+
|
|
70
178
|
return { port, url: `http://127.0.0.1:${port}/mcp`, proc };
|
|
71
179
|
}
|
|
72
180
|
|
|
@@ -178,13 +286,19 @@ async function mcpNotification(
|
|
|
178
286
|
// so sharing a server does not affect test independence. Starting one server instead
|
|
179
287
|
// of three avoids startup-time contention when all test files run in parallel.
|
|
180
288
|
let server: ServerHandle;
|
|
289
|
+
let fixtureDir: string;
|
|
290
|
+
let fixtureDbPath: string;
|
|
181
291
|
|
|
182
292
|
beforeAll(async () => {
|
|
183
|
-
|
|
184
|
-
|
|
293
|
+
fixtureDir = mkdtempSync(join(tmpdir(), "rosetta-http-test-"));
|
|
294
|
+
fixtureDbPath = join(fixtureDir, "ros-help.db");
|
|
295
|
+
createFixtureDb(fixtureDbPath);
|
|
296
|
+
server = await startServer(fixtureDbPath);
|
|
297
|
+
}, 130_000);
|
|
185
298
|
|
|
186
299
|
afterAll(async () => {
|
|
187
300
|
await killServer(server);
|
|
301
|
+
rmSync(fixtureDir, { recursive: true, force: true });
|
|
188
302
|
}, 15_000);
|
|
189
303
|
|
|
190
304
|
describe("HTTP transport: session lifecycle", () => {
|
|
@@ -212,7 +326,7 @@ describe("HTTP transport: session lifecycle", () => {
|
|
|
212
326
|
|
|
213
327
|
const result = (messages[0] as Record<string, unknown>).result as Record<string, unknown>;
|
|
214
328
|
const tools = result.tools as Array<{ name: string }>;
|
|
215
|
-
expect(tools.length).toBe(
|
|
329
|
+
expect(tools.length).toBe(13);
|
|
216
330
|
|
|
217
331
|
const toolNames = tools.map((t) => t.name).sort();
|
|
218
332
|
expect(toolNames).toContain("routeros_search");
|
|
@@ -228,6 +342,37 @@ describe("HTTP transport: session lifecycle", () => {
|
|
|
228
342
|
expect(toolNames).toContain("routeros_current_versions");
|
|
229
343
|
});
|
|
230
344
|
|
|
345
|
+
test("resources/list returns dataset resources after initialization", async () => {
|
|
346
|
+
const { sessionId } = await mcpInitialize(server.url);
|
|
347
|
+
await mcpNotification(server.url, sessionId, "notifications/initialized");
|
|
348
|
+
|
|
349
|
+
const messages = await mcpRequest(server.url, sessionId, "resources/list", 21);
|
|
350
|
+
expect(messages.length).toBe(1);
|
|
351
|
+
|
|
352
|
+
const result = (messages[0] as Record<string, unknown>).result as Record<string, unknown>;
|
|
353
|
+
const resources = result.resources as Array<{ uri: string; mimeType?: string }>;
|
|
354
|
+
|
|
355
|
+
expect(resources.some((resource) => resource.uri === "rosetta://datasets/device-test-results.csv")).toBe(true);
|
|
356
|
+
expect(resources.some((resource) => resource.uri === "rosetta://datasets/devices.csv")).toBe(true);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("resources/read returns CSV content for device test dataset", async () => {
|
|
360
|
+
const { sessionId } = await mcpInitialize(server.url);
|
|
361
|
+
await mcpNotification(server.url, sessionId, "notifications/initialized");
|
|
362
|
+
|
|
363
|
+
const messages = await mcpRequest(server.url, sessionId, "resources/read", 22, {
|
|
364
|
+
uri: "rosetta://datasets/device-test-results.csv",
|
|
365
|
+
});
|
|
366
|
+
expect(messages.length).toBe(1);
|
|
367
|
+
|
|
368
|
+
const result = (messages[0] as Record<string, unknown>).result as Record<string, unknown>;
|
|
369
|
+
const contents = result.contents as Array<{ uri: string; mimeType?: string; text?: string }>;
|
|
370
|
+
|
|
371
|
+
expect(contents[0].uri).toBe("rosetta://datasets/device-test-results.csv");
|
|
372
|
+
expect(contents[0].mimeType).toBe("text/csv");
|
|
373
|
+
expect(contents[0].text).toContain("product_name,product_code,architecture,cpu,cpu_cores,cpu_frequency");
|
|
374
|
+
});
|
|
375
|
+
|
|
231
376
|
test("tools/call works for routeros_stats", async () => {
|
|
232
377
|
const { sessionId } = await mcpInitialize(server.url);
|
|
233
378
|
await mcpNotification(server.url, sessionId, "notifications/initialized");
|
|
@@ -402,8 +547,8 @@ describe("HTTP transport: multi-session", () => {
|
|
|
402
547
|
const tools1 = ((msgs1[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
|
|
403
548
|
const tools2 = ((msgs2[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
|
|
404
549
|
|
|
405
|
-
expect(tools1.length).toBe(
|
|
406
|
-
expect(tools2.length).toBe(
|
|
550
|
+
expect(tools1.length).toBe(13);
|
|
551
|
+
expect(tools2.length).toBe(13);
|
|
407
552
|
});
|
|
408
553
|
|
|
409
554
|
test("deleting one session does not affect another", async () => {
|
|
@@ -425,7 +570,7 @@ describe("HTTP transport: multi-session", () => {
|
|
|
425
570
|
// Client2 still works
|
|
426
571
|
const msgs = await mcpRequest(server.url, client2.sessionId, "tools/list", 2);
|
|
427
572
|
const tools = ((msgs[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
|
|
428
|
-
expect(tools.length).toBe(
|
|
573
|
+
expect(tools.length).toBe(13);
|
|
429
574
|
|
|
430
575
|
// Client1 is gone
|
|
431
576
|
const resp = await fetch(server.url, {
|