/** * 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 }) { const catalogue = await connectNats({ url: url!, module: "mesh-catalog" }); const shop = await connectNats({ url: url!, module: "shop" }); await catalogue.handle("catalog_tools", async () => ({ tools: [{ module: "shop", name: "price", description: "what something costs" }], })); 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[]> { 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); 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, "2024-11-05"); 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"); 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/); });