@tikoci/rosetta 0.3.0 → 0.4.0
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 +135 -197
- package/package.json +1 -1
- package/src/db.ts +31 -1
- package/src/extract-test-results.ts +359 -0
- package/src/mcp-http.test.ts +443 -0
- package/src/mcp.ts +92 -17
- package/src/query.test.ts +47 -3
- package/src/query.ts +38 -3
- package/src/release.test.ts +106 -0
- package/src/setup.ts +22 -0
package/src/query.test.ts
CHANGED
|
@@ -87,13 +87,15 @@ beforeAll(() => {
|
|
|
87
87
|
license_level, operating_system, ram, ram_mb, storage, storage_mb,
|
|
88
88
|
poe_in, poe_out, wireless_24_chains, wireless_5_chains,
|
|
89
89
|
eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
|
|
90
|
-
eth_multigig, usb_ports, sim_slots, msrp_usd
|
|
90
|
+
eth_multigig, usb_ports, sim_slots, msrp_usd,
|
|
91
|
+
product_url, block_diagram_url)
|
|
91
92
|
VALUES
|
|
92
93
|
('hAP ax3', 'C53UiG+5HPaxD2HPaxD', 'ARM 64bit', 'IPQ-6010', 4, 'auto (864 - 1800) MHz',
|
|
93
94
|
4, 'RouterOS v7', '1 GB', 1024, '128 MB', 128,
|
|
94
95
|
'802.3af/at', NULL, 2, 2,
|
|
95
96
|
NULL, 4, 1, NULL, NULL,
|
|
96
|
-
NULL, 1, NULL, 139.00
|
|
97
|
+
NULL, 1, NULL, 139.00,
|
|
98
|
+
'https://mikrotik.com/product/hap_ax3', 'https://cdn.mikrotik.com/web-assets/product_files/hap_ax3_123.png')`);
|
|
97
99
|
|
|
98
100
|
db.run(`INSERT INTO devices
|
|
99
101
|
(product_name, product_code, architecture, cpu, cpu_cores, cpu_frequency,
|
|
@@ -161,6 +163,17 @@ beforeAll(() => {
|
|
|
161
163
|
NULL, 13, NULL, NULL, NULL,
|
|
162
164
|
NULL, NULL, NULL, 369.00)`);
|
|
163
165
|
|
|
166
|
+
// Device test results fixtures (hAP ax3 = id 1)
|
|
167
|
+
db.run(`INSERT INTO device_test_results
|
|
168
|
+
(device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
|
|
169
|
+
VALUES (1, 'ethernet', 'Routing', '25 ip filter rules', 512, 755.9, 3096.2)`);
|
|
170
|
+
db.run(`INSERT INTO device_test_results
|
|
171
|
+
(device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
|
|
172
|
+
VALUES (1, 'ethernet', 'Routing', 'none (fast path)', 512, 2332.0, 9551.9)`);
|
|
173
|
+
db.run(`INSERT INTO device_test_results
|
|
174
|
+
(device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
|
|
175
|
+
VALUES (1, 'ipsec', 'Single tunnel', 'AES-128-CBC + SHA1', 1400, 120.9, 1354.1)`);
|
|
176
|
+
|
|
164
177
|
// Page 3: a "large" page with sections for TOC testing
|
|
165
178
|
// Text is ~200 chars to keep fixture small, but we'll use max_length=50 to trigger truncation
|
|
166
179
|
db.run(`INSERT INTO pages
|
|
@@ -783,6 +796,37 @@ describe("searchDevices", () => {
|
|
|
783
796
|
const res = searchDevices("");
|
|
784
797
|
expect(res.results).toHaveLength(0);
|
|
785
798
|
});
|
|
799
|
+
|
|
800
|
+
test("exact match includes test_results", () => {
|
|
801
|
+
const res = searchDevices("hAP ax3");
|
|
802
|
+
expect(res.mode).toBe("exact");
|
|
803
|
+
expect(res.results).toHaveLength(1);
|
|
804
|
+
const dev = res.results[0];
|
|
805
|
+
expect(dev.test_results).toBeDefined();
|
|
806
|
+
expect(dev.test_results!.length).toBe(3);
|
|
807
|
+
expect(dev.test_results!.some((t) => t.test_type === "ethernet")).toBe(true);
|
|
808
|
+
expect(dev.test_results!.some((t) => t.test_type === "ipsec")).toBe(true);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test("LIKE match with ≤5 results includes test_results", () => {
|
|
812
|
+
const res = searchDevices("RB1100");
|
|
813
|
+
expect(res.mode).toBe("like");
|
|
814
|
+
expect(res.results.length).toBeLessThanOrEqual(5);
|
|
815
|
+
// RB1100 devices have no test results, but the field should still be populated (empty array)
|
|
816
|
+
expect(res.results.every((d) => Array.isArray(d.test_results))).toBe(true);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test("exact match includes product_url and block_diagram_url", () => {
|
|
820
|
+
const res = searchDevices("hAP ax3");
|
|
821
|
+
expect(res.results[0].product_url).toBe("https://mikrotik.com/product/hap_ax3");
|
|
822
|
+
expect(res.results[0].block_diagram_url).toContain("cdn.mikrotik.com");
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("devices without product_url return null", () => {
|
|
826
|
+
const res = searchDevices("CCR2216-1G-12XS-2XQ");
|
|
827
|
+
expect(res.results[0].product_url).toBeNull();
|
|
828
|
+
expect(res.results[0].block_diagram_url).toBeNull();
|
|
829
|
+
});
|
|
786
830
|
});
|
|
787
831
|
|
|
788
832
|
// ---------------------------------------------------------------------------
|
|
@@ -943,7 +987,7 @@ describe("schema", () => {
|
|
|
943
987
|
const expected = [
|
|
944
988
|
"pages", "properties", "callouts", "sections",
|
|
945
989
|
"commands", "command_versions", "ros_versions",
|
|
946
|
-
"devices", "changelogs", "schema_migrations",
|
|
990
|
+
"devices", "device_test_results", "changelogs", "schema_migrations",
|
|
947
991
|
];
|
|
948
992
|
for (const table of expected) {
|
|
949
993
|
expect(names).toContain(table);
|
package/src/query.ts
CHANGED
|
@@ -669,6 +669,15 @@ export function browseCommandsAtVersion(
|
|
|
669
669
|
|
|
670
670
|
// ── Device lookup and search ──
|
|
671
671
|
|
|
672
|
+
export type DeviceTestResult = {
|
|
673
|
+
test_type: string;
|
|
674
|
+
mode: string;
|
|
675
|
+
configuration: string;
|
|
676
|
+
packet_size: number;
|
|
677
|
+
throughput_kpps: number | null;
|
|
678
|
+
throughput_mbps: number | null;
|
|
679
|
+
};
|
|
680
|
+
|
|
672
681
|
export type DeviceResult = {
|
|
673
682
|
id: number;
|
|
674
683
|
product_name: string;
|
|
@@ -698,6 +707,9 @@ export type DeviceResult = {
|
|
|
698
707
|
usb_ports: number | null;
|
|
699
708
|
sim_slots: number | null;
|
|
700
709
|
msrp_usd: number | null;
|
|
710
|
+
product_url: string | null;
|
|
711
|
+
block_diagram_url: string | null;
|
|
712
|
+
test_results?: DeviceTestResult[];
|
|
701
713
|
};
|
|
702
714
|
|
|
703
715
|
export type DeviceFilters = {
|
|
@@ -715,9 +727,29 @@ const DEVICE_SELECT = `SELECT id, product_name, product_code, architecture, cpu,
|
|
|
715
727
|
ram, ram_mb, storage, storage_mb, dimensions, poe_in, poe_out,
|
|
716
728
|
max_power_w, wireless_24_chains, wireless_5_chains,
|
|
717
729
|
eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
|
|
718
|
-
eth_multigig, usb_ports, sim_slots, msrp_usd
|
|
730
|
+
eth_multigig, usb_ports, sim_slots, msrp_usd,
|
|
731
|
+
product_url, block_diagram_url
|
|
719
732
|
FROM devices`;
|
|
720
733
|
|
|
734
|
+
/** Get test results for a device by ID. */
|
|
735
|
+
function getDeviceTestResults(deviceId: number): DeviceTestResult[] {
|
|
736
|
+
return db.prepare(
|
|
737
|
+
`SELECT test_type, mode, configuration, packet_size,
|
|
738
|
+
throughput_kpps, throughput_mbps
|
|
739
|
+
FROM device_test_results
|
|
740
|
+
WHERE device_id = ?
|
|
741
|
+
ORDER BY test_type, mode, configuration, packet_size DESC`
|
|
742
|
+
).all(deviceId) as DeviceTestResult[];
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Attach test results to device results (for single/exact lookups). */
|
|
746
|
+
function attachTestResults(devices: DeviceResult[]): DeviceResult[] {
|
|
747
|
+
for (const dev of devices) {
|
|
748
|
+
dev.test_results = getDeviceTestResults(dev.id);
|
|
749
|
+
}
|
|
750
|
+
return devices;
|
|
751
|
+
}
|
|
752
|
+
|
|
721
753
|
/** Build FTS5 query for devices — appends prefix '*' to every term.
|
|
722
754
|
* Model numbers like "RB1100" need prefix matching to find "RB1100AHx4".
|
|
723
755
|
* No compound term handling (not relevant for device names). */
|
|
@@ -739,7 +771,7 @@ export function searchDevices(
|
|
|
739
771
|
.prepare(`${DEVICE_SELECT} WHERE product_name = ? COLLATE NOCASE OR product_code = ? COLLATE NOCASE`)
|
|
740
772
|
.all(query, query) as DeviceResult[];
|
|
741
773
|
if (exact.length > 0) {
|
|
742
|
-
return { results: exact, mode: "exact", total: exact.length };
|
|
774
|
+
return { results: attachTestResults(exact), mode: "exact", total: exact.length };
|
|
743
775
|
}
|
|
744
776
|
}
|
|
745
777
|
|
|
@@ -760,6 +792,8 @@ export function searchDevices(
|
|
|
760
792
|
const likeSql = `${DEVICE_SELECT} d WHERE ${likeConditions.join(" AND ")} ORDER BY d.product_name LIMIT ?`;
|
|
761
793
|
const likeResults = db.prepare(likeSql).all(...likeParams, limit) as DeviceResult[];
|
|
762
794
|
if (likeResults.length > 0) {
|
|
795
|
+
// Attach test results for small result sets (likely specific device lookups)
|
|
796
|
+
if (likeResults.length <= 5) attachTestResults(likeResults);
|
|
763
797
|
return { results: likeResults, mode: "like", total: likeResults.length };
|
|
764
798
|
}
|
|
765
799
|
}
|
|
@@ -807,7 +841,8 @@ export function searchDevices(
|
|
|
807
841
|
d.ram, d.ram_mb, d.storage, d.storage_mb, d.dimensions, d.poe_in, d.poe_out,
|
|
808
842
|
d.max_power_w, d.wireless_24_chains, d.wireless_5_chains,
|
|
809
843
|
d.eth_fast, d.eth_gigabit, d.eth_2500, d.sfp_ports, d.sfp_plus_ports,
|
|
810
|
-
d.eth_multigig, d.usb_ports, d.sim_slots, d.msrp_usd
|
|
844
|
+
d.eth_multigig, d.usb_ports, d.sim_slots, d.msrp_usd,
|
|
845
|
+
d.product_url, d.block_diagram_url
|
|
811
846
|
FROM devices_fts fts
|
|
812
847
|
JOIN devices d ON d.id = fts.rowid
|
|
813
848
|
WHERE devices_fts MATCH ?${filterWhere}
|
package/src/release.test.ts
CHANGED
|
@@ -238,6 +238,7 @@ describe("release.yml", () => {
|
|
|
238
238
|
expect(src).toContain("extract-properties.ts");
|
|
239
239
|
expect(src).toContain("extract-commands.ts");
|
|
240
240
|
expect(src).toContain("extract-devices.ts");
|
|
241
|
+
expect(src).toContain("extract-test-results.ts");
|
|
241
242
|
expect(src).toContain("extract-changelogs.ts");
|
|
242
243
|
expect(src).toContain("link-commands.ts");
|
|
243
244
|
});
|
|
@@ -280,3 +281,108 @@ describe("CLI flags", () => {
|
|
|
280
281
|
expect(src).toContain("--setup");
|
|
281
282
|
});
|
|
282
283
|
});
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// HTTP transport structural checks — catch per-session breakage at build time
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
describe("HTTP transport structure", () => {
|
|
290
|
+
const src = readText("src/mcp.ts");
|
|
291
|
+
|
|
292
|
+
test("uses per-session transport routing (not single shared transport)", () => {
|
|
293
|
+
// The single-transport pattern was: `await server.connect(httpTransport)` at module level
|
|
294
|
+
// followed by `httpTransport.handleRequest(req)`. The per-session pattern has a
|
|
295
|
+
// transports Map and creates transport/server per session.
|
|
296
|
+
expect(src).toContain("new Map");
|
|
297
|
+
expect(src).toContain("transports.set");
|
|
298
|
+
expect(src).toContain("transports.get");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("creates new McpServer per session, not one shared instance", () => {
|
|
302
|
+
// createServer() factory must exist and be called per-session
|
|
303
|
+
expect(src).toContain("function createServer()");
|
|
304
|
+
expect(src).toContain("createServer()");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("checks isInitializeRequest before creating transport", () => {
|
|
308
|
+
expect(src).toContain("isInitializeRequest");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("registers onsessioninitialized callback", () => {
|
|
312
|
+
expect(src).toContain("onsessioninitialized");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("cleans up transport on close", () => {
|
|
316
|
+
expect(src).toContain("transport.onclose");
|
|
317
|
+
expect(src).toContain("transports.delete");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("passes parsedBody to handleRequest after consuming body", () => {
|
|
321
|
+
// Once we req.json() for isInitializeRequest check, the body is consumed.
|
|
322
|
+
// Must pass parsedBody so the transport doesn't try to re-parse.
|
|
323
|
+
expect(src).toContain("parsedBody");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("handles missing session ID on non-initialize requests", () => {
|
|
327
|
+
expect(src).toContain("No valid session ID provided");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("handles invalid session ID with 404", () => {
|
|
331
|
+
expect(src).toContain("Session not found");
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// Container / entrypoint checks
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
describe("container entrypoint", () => {
|
|
340
|
+
test("entrypoint script exists", () => {
|
|
341
|
+
expect(existsSync(path.join(ROOT, "scripts/container-entrypoint.sh"))).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("defaults to --http mode", () => {
|
|
345
|
+
const src = readText("scripts/container-entrypoint.sh");
|
|
346
|
+
expect(src).toContain("--http");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("defaults to 0.0.0.0 host binding", () => {
|
|
350
|
+
const src = readText("scripts/container-entrypoint.sh");
|
|
351
|
+
expect(src).toContain("0.0.0.0");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("supports TLS via env vars", () => {
|
|
355
|
+
const src = readText("scripts/container-entrypoint.sh");
|
|
356
|
+
expect(src).toContain("TLS_CERT_PATH");
|
|
357
|
+
expect(src).toContain("TLS_KEY_PATH");
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// Dockerfile structure
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
describe("Dockerfile.release", () => {
|
|
366
|
+
test("copies entrypoint script", () => {
|
|
367
|
+
const src = readText("Dockerfile.release");
|
|
368
|
+
expect(src).toContain("container-entrypoint.sh");
|
|
369
|
+
expect(src).toContain("ENTRYPOINT");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("copies database into image", () => {
|
|
373
|
+
const src = readText("Dockerfile.release");
|
|
374
|
+
expect(src).toContain("ros-help.db");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("exposes port 8080", () => {
|
|
378
|
+
const src = readText("Dockerfile.release");
|
|
379
|
+
expect(src).toContain("EXPOSE 8080");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("injects build constants", () => {
|
|
383
|
+
const src = readText("Dockerfile.release");
|
|
384
|
+
expect(src).toContain("IS_COMPILED");
|
|
385
|
+
expect(src).toContain("VERSION");
|
|
386
|
+
expect(src).toContain("REPO_URL");
|
|
387
|
+
});
|
|
388
|
+
});
|
package/src/setup.ts
CHANGED
|
@@ -65,6 +65,7 @@ export async function runSetup(force = false) {
|
|
|
65
65
|
const dbPath = resolveDbPath(import.meta.dirname);
|
|
66
66
|
|
|
67
67
|
console.log(`rosetta ${RELEASE_VERSION}`);
|
|
68
|
+
console.log(` ${link("https://github.com/tikoci/rosetta")}`);
|
|
68
69
|
console.log();
|
|
69
70
|
|
|
70
71
|
// ── Download DB if needed ──
|
|
@@ -301,8 +302,29 @@ function printHttpConfig(startCmd: string) {
|
|
|
301
302
|
console.log(" For LAN access, replace localhost with the server's IP address.");
|
|
302
303
|
console.log(" Use a reverse proxy (nginx, caddy) for production HTTPS.");
|
|
303
304
|
console.log();
|
|
305
|
+
|
|
306
|
+
printMikroTikConfig();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Format a clickable terminal hyperlink using OSC 8 escape sequences. */
|
|
310
|
+
function link(url: string, display?: string): string {
|
|
311
|
+
return `\x1b]8;;${url}\x07${display ?? url}\x1b]8;;\x07`;
|
|
304
312
|
}
|
|
305
313
|
|
|
314
|
+
function printMikroTikConfig() {
|
|
315
|
+
console.log("─".repeat(60));
|
|
316
|
+
console.log("MikroTik /app container (RouterOS 7.22+, x86 or ARM64):");
|
|
317
|
+
console.log("─".repeat(60));
|
|
318
|
+
console.log();
|
|
319
|
+
console.log(" Run directly on your MikroTik router — any MCP client on");
|
|
320
|
+
console.log(" the network can connect to the URL shown in the router UI.");
|
|
321
|
+
console.log();
|
|
322
|
+
console.log(" Requires: container package + device-mode enabled.");
|
|
323
|
+
console.log(` See: ${link("https://github.com/tikoci/rosetta#install-on-mikrotik-app", "README — Install on MikroTik")}`);
|
|
324
|
+
console.log();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
306
328
|
// Run directly
|
|
307
329
|
if (import.meta.main) {
|
|
308
330
|
const force = process.argv.includes("--force");
|