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.
133 lines
6.0 KiB
TypeScript
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/);
|
|
});
|