The runtime serves a tools verb per module with names, descriptions and schemas (design 34 §3), and refuses a module naming its own tool tools. Discovery asks catalog_modules then each module, naming what did not answer. One MCP handler over two transports: stdio (mesh mcp) and loopback HTTP (mesh serve, the mesh-console module, novox/hq ADR 0152); serve refuses any bind but loopback. tools/call may go through a running console with --console and no credential.
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();
|
|
}
|
|
});
|