The console: mesh serve on loopback, and every runtime answers tools
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.
This commit is contained in:
+23
-12
@@ -18,16 +18,22 @@ 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. */
|
||||
/** A mesh as discovery sees it (design 34 §3): the catalogue holding three modules, two of them up
|
||||
* and answering `tools`, one held and not running. 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 catalogue.handle("catalog_modules", async () => ({
|
||||
modules: [{ module: "shop" }, { module: "mesh-catalog" }, { module: "ghost" }],
|
||||
}));
|
||||
await catalogue.handle("tools", async () => ({
|
||||
module: "mesh-catalog",
|
||||
tools: [{ name: "catalog_modules", description: "what modules the mesh has", input: {} }],
|
||||
}));
|
||||
await shop.handle("tools", async () => ({
|
||||
module: "shop",
|
||||
tools: [{ name: "price", description: "what something costs", input: { type: "object" } }],
|
||||
}));
|
||||
await shop.handle("price", async (body: { of?: string }) => ({ of: body.of ?? "nothing", cost: 12 }));
|
||||
return {
|
||||
@@ -38,17 +44,22 @@ async function aMeshWithTools() {
|
||||
};
|
||||
}
|
||||
|
||||
test("a person sees what the catalogue says the mesh has, sorted", async (t) => {
|
||||
test("a person sees what the running modules answer, sorted, and who did not answer", 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);
|
||||
const began = Date.now();
|
||||
const have = 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",
|
||||
have.tools.map((x) => `${x.module}.${x.name}`),
|
||||
["mesh-catalog.catalog_modules", "shop.price"],
|
||||
"the list is what the modules answered, in a stable order",
|
||||
);
|
||||
// Silence is named, never dropped: a module the catalogue holds and nothing answered for.
|
||||
assert.deepEqual(have.notAnswering, ["ghost"]);
|
||||
// And at once: a module that is not running costs nothing, or the list is unusable.
|
||||
assert.ok(Date.now() - began < 5_000, "an absent module waited out the timeout");
|
||||
} finally {
|
||||
await person.close();
|
||||
await mesh.close();
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
// A module naming a tool after the verb the runtime answers for every module — refused at load.
|
||||
import { registerModuleTools } from "@novox/mesh-sdk/tools";
|
||||
|
||||
registerModuleTools("clash", () => [
|
||||
{ name: "tools", description: "mine, not the runtime's", input: {}, run: async () => ({}) },
|
||||
]);
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
// A module's tool entrypoint, as the runtime imports one: registers and returns.
|
||||
import { registerModuleTools } from "@novox/mesh-sdk/tools";
|
||||
|
||||
registerModuleTools("shop", () => [
|
||||
{
|
||||
name: "price",
|
||||
description: "what something costs",
|
||||
input: { of: { type: "string", description: "the thing" } },
|
||||
run: async (args) => ({ of: args.of ?? "nothing", cost: 12 }),
|
||||
},
|
||||
{ name: "refund", description: "give it back", input: {}, run: async () => ({ done: true }) },
|
||||
]);
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* The console: the same surface over HTTP on loopback, started the way the mesh starts it — on the
|
||||
* module credential in MESH_BROKER_FILE (novox/hq ADR 0152, design 34 §2).
|
||||
*
|
||||
* 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/http.test.ts
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { connectNats } from "../dist/broker-nats.js";
|
||||
import { serveMcpHttp } from "../dist/http.js";
|
||||
|
||||
const url = process.env.MESH_TEST_NATS;
|
||||
|
||||
async function aMesh(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" }] }));
|
||||
await shop.handle("tools", async () => ({
|
||||
module: "shop",
|
||||
tools: [{ name: "price", description: "what something costs", input: {} }],
|
||||
}));
|
||||
await shop.handle("price", async (body: { of?: string }) => ({ of: body.of ?? "nothing", cost: 12 }));
|
||||
t.after(async () => {
|
||||
await catalogue.close();
|
||||
await shop.close();
|
||||
});
|
||||
}
|
||||
|
||||
/** `mesh serve` as the mesh runs it: MESH_BROKER_FILE, a listen address, nothing else. */
|
||||
async function aConsole(t: { after: (fn: () => Promise<void> | void) => void }): Promise<string> {
|
||||
const dir = await mkdtemp("/tmp/mesh-console-");
|
||||
const credential = join(dir, "broker");
|
||||
await writeFile(credential, JSON.stringify({ url, node: "desk", module: "mesh-console", user: "desk.mesh-console", password: "x" }));
|
||||
const child: ChildProcess = spawn(process.execPath, ["dist/mesh.js", "serve", "--listen", "127.0.0.1:0"], {
|
||||
env: { ...process.env, MESH_BROKER_FILE: credential, MESH_CREDENTIAL: "" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
t.after(() => {
|
||||
child.kill("SIGTERM");
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout!.on("data", (d) => {
|
||||
out += d.toString();
|
||||
const m = /listening on (http:\/\/[^/]+\/mcp)/.exec(out);
|
||||
if (m) resolve(m[1]!);
|
||||
});
|
||||
child.stderr!.on("data", (d) => (err += d.toString()));
|
||||
child.on("exit", (code) => reject(new Error(`serve exited ${code}: ${err}`)));
|
||||
});
|
||||
}
|
||||
|
||||
async function post(endpoint: string, body: unknown): Promise<{ status: number; json?: any }> {
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
return { status: res.status, json: text ? JSON.parse(text) : undefined };
|
||||
}
|
||||
|
||||
test("the console answers a host on loopback, as the account the mesh gave it", async (t) => {
|
||||
if (!url) return t.skip("MESH_TEST_NATS unset");
|
||||
await aMesh(t);
|
||||
const endpoint = await aConsole(t);
|
||||
|
||||
const hello = await post(endpoint, { jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
|
||||
assert.equal(hello.status, 200);
|
||||
assert.match(hello.json.result.instructions, /desk\.mesh-console/, "the handshake names the console's account");
|
||||
|
||||
const heard = await post(endpoint, { jsonrpc: "2.0", method: "notifications/initialized" });
|
||||
assert.equal(heard.status, 202, "a notification is heard and not answered");
|
||||
|
||||
const listed = await post(endpoint, { jsonrpc: "2.0", id: 2, method: "tools/list" });
|
||||
assert.deepEqual(listed.json.result.tools.map((x: { name: string }) => x.name), ["shop.price"]);
|
||||
|
||||
const called = await post(endpoint, {
|
||||
jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "shop.price", arguments: { of: "a hat" } },
|
||||
});
|
||||
assert.deepEqual(JSON.parse(called.json.result.content[0].text), { of: "a hat", cost: 12 });
|
||||
|
||||
// A person's client through the same endpoint, with no credential of its own.
|
||||
const { main } = await import("../dist/mesh.js");
|
||||
const logged: string[] = [];
|
||||
const was = console.log;
|
||||
console.log = (line: string) => logged.push(String(line));
|
||||
try {
|
||||
assert.equal(await main(["tools", "--console", endpoint]), 0);
|
||||
} finally {
|
||||
console.log = was;
|
||||
}
|
||||
assert.ok(logged.some((l) => l.startsWith("shop.price")), `the client did not list through the console: ${logged}`);
|
||||
});
|
||||
|
||||
test("the console binds loopback and nowhere else", async (t) => {
|
||||
if (!url) return t.skip("MESH_TEST_NATS unset");
|
||||
const bus = await connectNats({ url, module: "mesh-console", node: "desk" });
|
||||
try {
|
||||
await assert.rejects(() => serveMcpHttp(bus, "desk.mesh-console", "0.0.0.0:0"), /loopback and nowhere else/);
|
||||
const up = await serveMcpHttp(bus, "desk.mesh-console", "127.0.0.1:0");
|
||||
assert.match(up.address, /^127\.0\.0\.1:\d+$/);
|
||||
await up.close();
|
||||
} finally {
|
||||
await bus.close();
|
||||
}
|
||||
});
|
||||
+11
-3
@@ -20,8 +20,12 @@ const url = process.env.MESH_TEST_NATS;
|
||||
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 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 () => {
|
||||
@@ -80,7 +84,7 @@ test("a host initialises, lists the mesh's tools and calls one", async (t) => {
|
||||
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.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");
|
||||
|
||||
@@ -88,6 +92,10 @@ test("a host initialises, lists the mesh's tools and calls one", async (t) => {
|
||||
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)}`);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Every module's runtime answers `tools` for it (novox/hq ADR 0152, design 34 §3): the names,
|
||||
* descriptions and schemas from the code that answers them. Against a real bus, because the claim is
|
||||
* what a second connection gets back.
|
||||
*
|
||||
* 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/runtime-tools.test.ts
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { resetTools } from "@novox/mesh-sdk/tools";
|
||||
|
||||
import { connectNats } from "../dist/broker-nats.js";
|
||||
import { runTools } from "../dist/runtime.js";
|
||||
|
||||
const url = process.env.MESH_TEST_NATS;
|
||||
const fixture = (name: string) => fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url));
|
||||
|
||||
test("a module registering two tools answers three names, the third being what it serves", async (t) => {
|
||||
if (!url) return t.skip("MESH_TEST_NATS unset");
|
||||
resetTools();
|
||||
const shop = await connectNats({ url, node: "one", module: "shop" });
|
||||
const asker = await connectNats({ url, module: "person.ada" });
|
||||
const stop = await runTools({ broker: shop, moduleEntrypoints: [fixture("shop-tools.mjs")] });
|
||||
try {
|
||||
const answer = await asker.request<Record<string, never>, { module: string; tools: { name: string; input: unknown }[] }>(
|
||||
"shop.tools",
|
||||
{},
|
||||
);
|
||||
assert.equal(answer.module, "shop");
|
||||
assert.deepEqual(answer.tools.map((x) => x.name), ["price", "refund"]);
|
||||
// The schema travels with the name: a name alone is not callable by something that has never
|
||||
// seen the mesh before.
|
||||
assert.deepEqual(answer.tools[0].input, { of: { type: "string", description: "the thing" } });
|
||||
// And the tools themselves still answer beside it.
|
||||
const priced = await asker.request<{ of: string }, { cost: number }>("shop.price", { of: "a hat" });
|
||||
assert.equal(priced.cost, 12);
|
||||
} finally {
|
||||
stop();
|
||||
await asker.close();
|
||||
await shop.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a module naming a tool of its own `tools` is refused at load", async (t) => {
|
||||
if (!url) return t.skip("MESH_TEST_NATS unset");
|
||||
resetTools();
|
||||
const clash = await connectNats({ url, node: "one", module: "clash" });
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => runTools({ broker: clash, moduleEntrypoints: [fixture("clash-tools.mjs")] }),
|
||||
/names a tool "tools"/,
|
||||
);
|
||||
} finally {
|
||||
resetTools();
|
||||
await clash.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user