Every served entrypoint is started as a process speaking MCP over stdio, told its module and node; one that is not executable is refused by name. The import path, the SDK resolve hook (issue 209) and the per-registration hand-off go. The one-module form the per-module containers use is still imported until they move (to-be 38 WP4c). A child's mesh/publish is published as its module and answered once accepted; a child that dies says why in its own last words. Fixtures are served through launchers exactly as the builder writes them.
64 lines
3.1 KiB
TypeScript
64 lines
3.1 KiB
TypeScript
/**
|
|
* The runtime as the node-tools module (novox/hq ADR 0175 §6, to-be 38 WP3): started the way the
|
|
* host starts it — `main.js` with the node's credential and MESH_TOOL_MODULES — it serves the bundles
|
|
* AND answers MCP on loopback as the console, through which a tool it serves can be called.
|
|
*
|
|
* docker run -d --rm --name t -p 14232:4222 nats:2.10-alpine -js
|
|
* MESH_TEST_NATS=nats://127.0.0.1:14232 node --test --experimental-strip-types test/node-tools-serve.test.ts
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { connectNats } from "../dist/broker-nats.js";
|
|
|
|
const url = process.env.MESH_TEST_NATS;
|
|
const fixture = (name: string) => fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url));
|
|
|
|
async function post(endpoint: string, body: unknown): Promise<any> {
|
|
const res = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
return res.json();
|
|
}
|
|
|
|
test("as node-tools, serve loads the bundles and is the console on loopback", async (t) => {
|
|
if (!url) return t.skip("MESH_TEST_NATS unset");
|
|
// The catalogue, for discovery; the node credential names the runtime module and no claims.
|
|
const catalogue = await connectNats({ url, module: "mesh-catalog" });
|
|
await catalogue.handle("catalog_modules", async () => ({ modules: [{ module: "alpha" }] }));
|
|
t.after(() => catalogue.close());
|
|
const dir = await mkdtemp("/tmp/node-tools-");
|
|
const credential = join(dir, "broker");
|
|
await writeFile(credential, JSON.stringify({ url, node: "desk", module: "node-tools", user: "desk.node-tools", password: "x" }));
|
|
const child: ChildProcess = spawn(process.execPath, ["dist/main.js"], {
|
|
env: {
|
|
...process.env,
|
|
MESH_BROKER_FILE: credential,
|
|
MESH_TOOL_MODULES: `alpha=${fixture("many-alpha.serve.mjs")}`,
|
|
MESH_CONSOLE_LISTEN: "127.0.0.1:0",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
t.after(() => {
|
|
child.kill("SIGTERM");
|
|
});
|
|
const endpoint = await new Promise<string>((resolve, reject) => {
|
|
let out = "";
|
|
let err = "";
|
|
child.stdout!.on("data", (d) => {
|
|
out += d.toString();
|
|
const m = /listening on (http:\/\/[^/]+\/mcp) as desk\.node-tools/.exec(out);
|
|
if (m) resolve(m[1]!);
|
|
});
|
|
child.stderr!.on("data", (d) => (err += d.toString()));
|
|
child.on("exit", (code) => reject(new Error(`serve exited ${code}: ${err}`)));
|
|
});
|
|
const listed = await post(endpoint, { jsonrpc: "2.0", id: 1, method: "tools/list" });
|
|
assert.deepEqual(listed.result.tools.map((x: any) => x.name).filter((n: string) => n.startsWith("alpha.")), ["alpha.one", "alpha.two"]);
|
|
// A tool the same process serves on the bus, called through the console it also is.
|
|
const called = await post(endpoint, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "alpha.one", arguments: { node: "desk" } } });
|
|
assert.deepEqual(JSON.parse(called.result.content[0].text), { alpha: 1 });
|
|
});
|