Files
mesh-tools/test/mcp.test.ts
jschoubben 9acc40145a A person's client: the mesh's tools from a workstation
Design 25 §7's second item. Two surfaces over one thing — a command line for somebody
at a terminal, an MCP server for an agent — and both are adapters over the same three
calls: what tools are there, what does this one take, call it. A second way of reaching
a tool would be a second thing to keep correct.

It uses the client a module's runtime uses. Not a bridge and not a second protocol: a
person connects as their own bus user and publishes on the tool subjects their account
permits, so "what may this person do" is answered by the same permission list that
answers it for a module, and an audit has nothing separate to read.

`mesh tools` lists what the *catalogue* has, not what this credential may call. The two
differ and the difference is the point: somebody seeing only their own tools cannot tell
"not installed" from "not yours", and those need different people to fix them.

A failed call says which of three things happened, because the remedies are in three
different places: nobody serves that tool, this credential may not call it, or the tool
itself was slow. Without that they are one timeout and a stack trace.

The MCP surface decides nothing. The tool names are the ones a person types, the schemas
are the modules' own, and an answer is passed through unshaped — an adapter that
summarised somebody else's answer would be deciding what matters in it. A tool that fails
comes back as a tool error rather than a protocol error, because the request was
well-formed and the mesh answered it.

Written against the protocol directly: it is three methods and one framing, and a
dependency here would be a dependency on every workstation.

Tests drive both surfaces against a real bus, including that a host's notification is
answered with nothing and an unknown method is refused. They run one file at a time,
because each stands up a module serving the same tool subjects and run together their
requests get split between them — which showed up as one test reading another's answer.
2026-09-27 17:03:13 +02:00

125 lines
5.5 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_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<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, "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/);
});