The console moved every call's node into the subject (ADR 0159), so mesh-controller.push {node: x}
became a call to the seat's verb on machine x, which nothing serves — the mesh's own verbs could not
be given a machine from the console at all. A role's verb takes no machine from the console; its
arguments are its own.
171 lines
8.3 KiB
TypeScript
171 lines
8.3 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" } } },
|
|
] }],
|
|
}));
|
|
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", "shop.price"],
|
|
"the modules' tools and the roles', named the way a person names them");
|
|
const price = listed[1];
|
|
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[0].inputSchema.properties?.node, "a seat's verb takes no machine; the seat's scope decides");
|
|
// 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 });
|
|
});
|