Files
mesh-tools/test/mcp.test.ts
T
jschoubben 80b02740ab The console: mesh serve on loopback, and every runtime answers tools
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.
2026-09-30 16:19:22 +02:00

133 lines
6.0 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 }));
t.after(async () => {
await catalogue.close();
await shop.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.equal(listed.length, 1);
assert.equal(listed[0].name, "shop.price", "a tool is named the way a person names it");
assert.ok(listed[0].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.
assert.deepEqual(listed[0].inputSchema, { type: "object", properties: { of: { type: "string" } } });
// 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/);
});