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.
197 lines
9.9 KiB
TypeScript
197 lines
9.9 KiB
TypeScript
/**
|
|
* The MCP surface, driven the way a host drives it.
|
|
*
|
|
* **The claim worth checking is that it is the same thing the command line is.** An agent and a
|
|
* person must see the same tools and get the same answers, or this becomes a second definition of what
|
|
* a tool is — which is exactly what a thin adapter is supposed to avoid.
|
|
*
|
|
* 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/mcp.test.ts
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
import { spawn } from "node:child_process";
|
|
|
|
import { connectNats } from "../dist/broker-nats.js";
|
|
|
|
const url = process.env.MESH_TEST_NATS;
|
|
|
|
/** A module answering the catalogue's list and one tool, plus a credential file the client reads. */
|
|
async function aMeshAndACredential(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" }, { module: "ghost" }],
|
|
}));
|
|
await shop.handle("tools", async () => ({
|
|
module: "shop",
|
|
tools: [{ name: "price", description: "what something costs", input: { of: { type: "string" } } }],
|
|
}));
|
|
await shop.handle("price", async (body: { of?: string }) => ({ of: body.of ?? "nothing", cost: 12 }));
|
|
const controller = await connectNats({ url: url!, module: "mesh-controller" });
|
|
await controller.handle("seat:mesh-controller.tools", async () => ({
|
|
seats: [
|
|
{ seat: "mesh-controller", scope: "mesh", tools: [
|
|
{ name: "status", description: "what is wrong", input: {} },
|
|
{ name: "push", description: "tell a machine", input: { node: { type: "string" } } },
|
|
] },
|
|
// A seat held once per machine (design 33 §4, ADR 0170): its verb is asked of one.
|
|
{ seat: "node-dns-resolver", scope: "node", tools: [{ name: "lookup", description: "one machine's", input: {} }] },
|
|
],
|
|
}));
|
|
await controller.handle("seat:node-dns-resolver.lookup@anchor", async () => ({ machine: "anchor", answered: true }));
|
|
await controller.handle("seat:mesh-controller.status", async () => ({ output: "all quiet", ok: true }));
|
|
await controller.handle("seat:mesh-controller.push", async (body: { node?: string }) => ({ told: body.node ?? "nobody" }));
|
|
t.after(async () => {
|
|
await catalogue.close();
|
|
await shop.close();
|
|
await controller.close();
|
|
});
|
|
|
|
const { mkdtemp, writeFile } = await import("node:fs/promises");
|
|
const { join } = await import("node:path");
|
|
const dir = await mkdtemp("/tmp/mesh-client-");
|
|
const path = join(dir, "credential.json");
|
|
await writeFile(
|
|
path,
|
|
JSON.stringify({ url, user: "person.ada", password: "x", person: "ada", invokes: ["shop.price"] }),
|
|
);
|
|
return path;
|
|
}
|
|
|
|
/** Drive `mesh mcp` over stdio and collect the replies, as a host would. */
|
|
function driving(credential: string, requests: unknown[]): Promise<Record<string, any>[]> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(process.execPath, ["dist/mesh.js", "mcp", "--credential", credential], {
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
let out = "";
|
|
let err = "";
|
|
child.stdout.on("data", (d) => (out += d.toString()));
|
|
child.stderr.on("data", (d) => (err += d.toString()));
|
|
child.on("error", reject);
|
|
child.on("close", () => {
|
|
const replies = out
|
|
.split("\n")
|
|
.filter((l) => l.trim() !== "")
|
|
.map((l) => JSON.parse(l) as Record<string, any>);
|
|
if (replies.length === 0 && err !== "") reject(new Error(err));
|
|
else resolve(replies);
|
|
});
|
|
for (const r of requests) child.stdin.write(`${JSON.stringify(r)}\n`);
|
|
child.stdin.end();
|
|
});
|
|
}
|
|
|
|
test("a host initialises, lists the mesh's tools and calls one", async (t) => {
|
|
if (!url) return t.skip("MESH_TEST_NATS unset");
|
|
const credential = await aMeshAndACredential(t);
|
|
|
|
const replies = await driving(credential, [
|
|
{ jsonrpc: "2.0", id: 1, method: "initialize", params: {} },
|
|
{ jsonrpc: "2.0", method: "notifications/initialized" },
|
|
{ jsonrpc: "2.0", id: 2, method: "tools/list" },
|
|
{ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "shop.price", arguments: { of: "a hat" } } },
|
|
]);
|
|
|
|
const byId = new Map(replies.map((r) => [r.id, r]));
|
|
// A notification is answered with nothing, or a host waiting on ids sees a reply it cannot match.
|
|
assert.equal(replies.length, 3, `expected three replies, got ${JSON.stringify(replies)}`);
|
|
|
|
const hello = byId.get(1)!.result;
|
|
assert.equal(hello.protocolVersion, "2025-03-26");
|
|
assert.ok(hello.capabilities.tools, "a server offering no tools is not this one");
|
|
assert.match(hello.instructions, /ada/, "the handshake says whose authority a call is made under");
|
|
|
|
const listed = byId.get(2)!.result.tools;
|
|
assert.deepEqual(listed.map((x: { name: string }) => x.name),
|
|
["mesh-controller.push", "mesh-controller.status", "node-dns-resolver.lookup", "shop.price"],
|
|
"the modules' tools and the roles', named the way a person names them");
|
|
// A node-scoped seat's verb takes the machine, and requires it (ADR 0170).
|
|
const lookup = listed[2];
|
|
assert.equal(lookup.inputSchema.properties.node.type, "string");
|
|
assert.deepEqual(lookup.inputSchema.required, ["node"]);
|
|
const price = listed[3];
|
|
assert.ok(price.inputSchema, "a tool with no schema is one an agent cannot call");
|
|
// A module's bare property map arrives as a schema an agent can read, its words kept — and
|
|
// `node`, the machine to ask when the module runs on several (novox/hq ADR 0159), beside them.
|
|
assert.deepEqual(price.inputSchema.properties.of, { type: "string" });
|
|
assert.equal(price.inputSchema.properties.node.type, "string", "a module's tool takes the machine to ask");
|
|
assert.ok(!listed[1].inputSchema.properties?.node, "a seat's verb takes no machine; the seat's scope decides");
|
|
assert.equal(listed[0].inputSchema.properties?.node?.type, "string", "a seat's verb that takes a node of its own keeps it");
|
|
// Silence is named: the module the catalogue holds and nothing answered for.
|
|
assert.deepEqual(byId.get(2)!.result._meta.notAnswering, ["ghost"]);
|
|
|
|
const called = byId.get(3)!.result;
|
|
assert.ok(!called.isError, `the call failed: ${JSON.stringify(called)}`);
|
|
// The module's own answer, unshaped. An adapter that summarised it would be deciding what matters
|
|
// in somebody else's answer.
|
|
assert.deepEqual(JSON.parse(called.content[0].text), { of: "a hat", cost: 12 });
|
|
});
|
|
|
|
test("a tool nobody serves comes back as an error the agent can act on", async (t) => {
|
|
if (!url) return t.skip("MESH_TEST_NATS unset");
|
|
const credential = await aMeshAndACredential(t);
|
|
|
|
const replies = await driving(credential, [
|
|
{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "ghost.missing", arguments: {} } },
|
|
]);
|
|
const result = replies[0].result;
|
|
// isError, not a protocol failure: the call was well-formed and the mesh answered it — with an
|
|
// absence. A JSON-RPC error would tell the agent its request was malformed, which it was not.
|
|
assert.ok(result?.isError, `expected a tool error, got ${JSON.stringify(replies[0])}`);
|
|
assert.match(result.content[0].text, /nothing serves ghost\.missing/);
|
|
});
|
|
|
|
test("a method this surface does not have is refused, and a notification is not", async (t) => {
|
|
if (!url) return t.skip("MESH_TEST_NATS unset");
|
|
const credential = await aMeshAndACredential(t);
|
|
|
|
const replies = await driving(credential, [
|
|
{ jsonrpc: "2.0", id: 1, method: "resources/list" },
|
|
{ jsonrpc: "2.0", method: "notifications/cancelled" },
|
|
]);
|
|
assert.equal(replies.length, 1, "a notification was answered");
|
|
assert.equal(replies[0].error.code, -32601);
|
|
assert.match(replies[0].error.message, /resources\/list/);
|
|
});
|
|
|
|
// A seat's verb that takes a machine as its own argument — `push <node>` — keeps it: the console
|
|
// moves `node` into the subject for a module's tool only (ADR 0159), never for a role's verb.
|
|
test("a seat's verb keeps a node of its own; only a module's tool gives it to the subject", async (t) => {
|
|
if (!url) return t.skip("MESH_TEST_NATS unset");
|
|
const credential = await aMeshAndACredential(t);
|
|
const replies = await driving(credential, [
|
|
{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "mesh-controller.push", arguments: { node: "anchor" } } },
|
|
]);
|
|
const result = replies[0].result;
|
|
assert.ok(!result.isError, JSON.stringify(replies[0]));
|
|
assert.deepEqual(JSON.parse(result.content[0].text), { told: "anchor" });
|
|
});
|
|
|
|
test("a host calls the mesh's own verb through the seat", async (t) => {
|
|
if (!url) return t.skip("MESH_TEST_NATS unset");
|
|
const credential = await aMeshAndACredential(t);
|
|
const replies = await driving(credential, [
|
|
{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "mesh-controller.status", arguments: {} } },
|
|
]);
|
|
const result = replies[0].result;
|
|
assert.ok(!result.isError, JSON.stringify(replies[0]));
|
|
assert.deepEqual(JSON.parse(result.content[0].text), { output: "all quiet", ok: true });
|
|
});
|
|
|
|
test("a node-scoped seat's verb is asked of the machine named, and refused without one", async (t) => {
|
|
if (!url) return t.skip("MESH_TEST_NATS unset");
|
|
const credential = await aMeshAndACredential(t);
|
|
const replies = await driving(credential, [
|
|
{ jsonrpc: "2.0", id: 1, method: "initialize", params: {} },
|
|
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "node-dns-resolver.lookup", arguments: { node: "anchor" } } },
|
|
{ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "node-dns-resolver.lookup", arguments: {} } },
|
|
]);
|
|
const byId = new Map(replies.map((r) => [r.id, r]));
|
|
const answered = byId.get(2)!.result;
|
|
assert.ok(!answered.isError, JSON.stringify(answered));
|
|
assert.match(answered.content[0].text, /"machine": "anchor"/, "the machine's holder answered");
|
|
assert.match(byId.get(3)!.error?.message ?? JSON.stringify(byId.get(3)), /name the machine/);
|
|
});
|