Files
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

106 lines
4.4 KiB
TypeScript

/**
* 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 <module>.<tool> 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/);
});