/** * A person's client, against a real bus. * * What is worth checking is not that a request/reply works — the runtime's own tests cover that — but * that the two surfaces are the same thing. An agent and a person must see the same tools and get the * same answers, or the MCP surface becomes a second definition of what a tool is. * * 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/client.test.ts */ import assert from "node:assert/strict"; import { test } from "node:test"; // The built output, not the source: the client imports its siblings as `.js`, which is what ships and // what every other file here does, and cannot be loaded as TypeScript directly. `pretest` builds. import { connectNats } from "../dist/broker-nats.js"; import { callTool, toolsOn, whyItFailed } from "../dist/client.js"; const url = process.env.MESH_TEST_NATS; /** A module serving the catalogue's tool list and one tool of its own, so the client has a mesh to * talk to. Two connections, because a person and a module are different users even in a test. */ async function aMeshWithTools() { 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", input: { type: "object" } }, { module: "mesh-catalog", name: "catalog_tools", description: "what tools the mesh has" }, ], })); await shop.handle("price", async (body: { of?: string }) => ({ of: body.of ?? "nothing", cost: 12 })); return { async close() { await catalogue.close(); await shop.close(); }, }; } test("a person sees what the catalogue says the mesh has, sorted", async (t) => { if (!url) return t.skip("MESH_TEST_NATS unset"); const mesh = await aMeshWithTools(); const person = await connectNats({ url, module: "person.ada" }); try { const tools = await toolsOn(person); assert.deepEqual( tools.map((x) => `${x.module}.${x.name}`), ["mesh-catalog.catalog_tools", "shop.price"], "the list is what the catalogue answered, in a stable order", ); } finally { await person.close(); await mesh.close(); } }); test("a person calls a tool and gets the module's own answer, unshaped", async (t) => { if (!url) return t.skip("MESH_TEST_NATS unset"); const mesh = await aMeshWithTools(); const person = await connectNats({ url, module: "person.ada" }); try { const answer = await callTool(person, "shop.price", { of: "a hat" }); assert.deepEqual(answer, { of: "a hat", cost: 12 }); } finally { await person.close(); await mesh.close(); } }); test("a tool nobody serves says so at once, and says what to do about it", async (t) => { if (!url) return t.skip("MESH_TEST_NATS unset"); const person = await connectNats({ url: url!, module: "person.ada" }); try { const began = Date.now(); await assert.rejects(() => callTool(person, "ghost.missing", {})); // At once, not after the whole wait: "that module is down" and "that tool is slow" need // different things done, and a timeout cannot tell them apart. assert.ok(Date.now() - began < 5_000, "a tool nobody serves waited out the timeout"); } finally { await person.close(); } }); test("a name that is not . is refused before anything is sent", async (t) => { if (!url) return t.skip("MESH_TEST_NATS unset"); const person = await connectNats({ url: url!, module: "person.ada" }); try { await assert.rejects(() => callTool(person, "price", {}), /does not name a tool/); } finally { await person.close(); } }); test("each way a call fails says what to do about it", () => { // The three answers a person actually gets. Without this they are one timeout and a stack trace, // and the remedies are in three different places. assert.match(whyItFailed("shop.price", new Error("no responders")), /nothing serves shop\.price/); assert.match( whyItFailed("shop.price", new Error("Permissions Violation for Publish")), /may not call shop\.price/, ); assert.match(whyItFailed("shop.price", new Error("timeout")), /did not answer in time/); assert.match(whyItFailed("shop.price", new Error("something else")), /something else/); });