One repository, two modules (ADR 0069). `node-tools/` holds the runtime — its code, tests, package and the manifest of the module the controller composes a process for on every machine it is assigned to: a bundle of `src/main.js`, the interpreter as a package, a place for the node's credential, the loopback port the console declared, and leave to call every tool. Nothing about how it runs: which bundles to load, where the credential is and whose machine it is are the controller's to compose (WP2). The root module `mesh-tools` keeps the two images TypeScript bundles are compiled in and a module's own service may run in; it is no longer how tools reach a node. As node-tools, `serve` is also the console (ADR 0175 §6): the same process answers MCP on loopback for whoever is on the machine, through which the tools it serves can be called. A module's own runtime in a container keeps serving without a listener. The toolchain image now carries /app/runtime — a package.json saying the compiled files are ES modules and the production node_modules — for the builder to copy into every TypeScript bundle, so a bundle unpacked on a machine starts (ADR 0188 §5; the builder's side is the controller's). Proven here by compiling node-tools with the toolchain's exact flags and starting the result. The AMQP probe script is gone with the bus it probed.
114 lines
4.8 KiB
TypeScript
114 lines
4.8 KiB
TypeScript
/**
|
|
* The console: the same surface over HTTP on loopback, started the way the mesh starts it — on the
|
|
* module credential in MESH_BROKER_FILE (novox/hq ADR 0152, design 34 §2).
|
|
*
|
|
* 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/http.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 { connectNats } from "../dist/broker-nats.js";
|
|
import { serveMcpHttp } from "../dist/http.js";
|
|
|
|
const url = process.env.MESH_TEST_NATS;
|
|
|
|
async function aMesh(t: { after: (fn: () => Promise<void> | void) => void }) {
|
|
const catalogue = await connectNats({ url: url!, module: "mesh-catalog" });
|
|
const shop = await connectNats({ url: url!, module: "shop" });
|
|
await catalogue.handle("catalog_modules", async () => ({ modules: [{ module: "shop" }] }));
|
|
await shop.handle("tools", async () => ({
|
|
module: "shop",
|
|
tools: [{ name: "price", description: "what something costs", input: {} }],
|
|
}));
|
|
await shop.handle("price", async (body: { of?: string }) => ({ of: body.of ?? "nothing", cost: 12 }));
|
|
t.after(async () => {
|
|
await catalogue.close();
|
|
await shop.close();
|
|
});
|
|
}
|
|
|
|
/** `mesh serve` as the mesh runs it: MESH_BROKER_FILE, a listen address, nothing else. */
|
|
async function aConsole(t: { after: (fn: () => Promise<void> | void) => void }): Promise<string> {
|
|
const dir = await mkdtemp("/tmp/mesh-console-");
|
|
const credential = join(dir, "broker");
|
|
await writeFile(credential, JSON.stringify({ url, node: "desk", module: "mesh-console", user: "desk.mesh-console", password: "x" }));
|
|
const child: ChildProcess = spawn(process.execPath, ["dist/mesh.js", "serve", "--listen", "127.0.0.1:0"], {
|
|
env: { ...process.env, MESH_BROKER_FILE: credential, MESH_CREDENTIAL: "" },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
t.after(() => {
|
|
child.kill("SIGTERM");
|
|
});
|
|
return new Promise((resolve, reject) => {
|
|
let out = "";
|
|
let err = "";
|
|
child.stdout!.on("data", (d) => {
|
|
out += d.toString();
|
|
const m = /listening on (http:\/\/[^/]+\/mcp)/.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}`)));
|
|
});
|
|
}
|
|
|
|
async function post(endpoint: string, body: unknown): Promise<{ status: number; json?: any }> {
|
|
const res = await fetch(endpoint, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", accept: "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const text = await res.text();
|
|
return { status: res.status, json: text ? JSON.parse(text) : undefined };
|
|
}
|
|
|
|
test("the console answers a host on loopback, as the account the mesh gave it", async (t) => {
|
|
if (!url) return t.skip("MESH_TEST_NATS unset");
|
|
await aMesh(t);
|
|
const endpoint = await aConsole(t);
|
|
|
|
const hello = await post(endpoint, { jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
|
|
assert.equal(hello.status, 200);
|
|
assert.match(hello.json.result.instructions, /desk\.mesh-console/, "the handshake names the console's account");
|
|
|
|
const heard = await post(endpoint, { jsonrpc: "2.0", method: "notifications/initialized" });
|
|
assert.equal(heard.status, 202, "a notification is heard and not answered");
|
|
|
|
const listed = await post(endpoint, { jsonrpc: "2.0", id: 2, method: "tools/list" });
|
|
assert.deepEqual(listed.json.result.tools.map((x: { name: string }) => x.name), ["shop.price"]);
|
|
|
|
const called = await post(endpoint, {
|
|
jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "shop.price", arguments: { of: "a hat" } },
|
|
});
|
|
assert.deepEqual(JSON.parse(called.json.result.content[0].text), { of: "a hat", cost: 12 });
|
|
|
|
// A person's client through the same endpoint, with no credential of its own.
|
|
const { main } = await import("../dist/mesh.js");
|
|
const logged: string[] = [];
|
|
const was = console.log;
|
|
console.log = (line: string) => logged.push(String(line));
|
|
try {
|
|
assert.equal(await main(["tools", "--console", endpoint]), 0);
|
|
} finally {
|
|
console.log = was;
|
|
}
|
|
assert.ok(logged.some((l) => l.startsWith("shop.price")), `the client did not list through the console: ${logged}`);
|
|
});
|
|
|
|
test("the console binds loopback and nowhere else", async (t) => {
|
|
if (!url) return t.skip("MESH_TEST_NATS unset");
|
|
const bus = await connectNats({ url, module: "mesh-console", node: "desk" });
|
|
try {
|
|
await assert.rejects(() => serveMcpHttp(bus, "desk.mesh-console", "0.0.0.0:0"), /loopback and nowhere else/);
|
|
const up = await serveMcpHttp(bus, "desk.mesh-console", "127.0.0.1:0");
|
|
assert.match(up.address, /^127\.0\.0\.1:\d+$/);
|
|
await up.close();
|
|
} finally {
|
|
await bus.close();
|
|
}
|
|
});
|