From 71965ef9584a62a61b9d97ce5b119b976a6b1844 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 30 Sep 2026 17:41:56 +0200 Subject: [PATCH] The console lists and calls a role's tools seat:. addresses a role's tool (with @ for a node-scoped seat); the listing asks the mesh-controller seat's tools verb beside the modules and marks a role's tools; . resolves to the seat when the seat declares that verb, a module's own name otherwise (novox/hq ADR 0154). --- src/broker-nats.ts | 12 +++++++- src/client.ts | 74 +++++++++++++++++++++++++++++++++++++++------ src/mcp.ts | 20 +++++++++--- src/mesh.ts | 9 ++++-- test/client.test.ts | 38 +++++++++++++++++++++-- test/mcp.test.ts | 26 +++++++++++++--- 6 files changed, 156 insertions(+), 23 deletions(-) diff --git a/src/broker-nats.ts b/src/broker-nats.ts index aae89c8..3650310 100644 --- a/src/broker-nats.ts +++ b/src/broker-nats.ts @@ -313,8 +313,18 @@ function eventSubject(type: string, self: string): string { } /** A tool's subject. A bare name is this module's own tool; `.` addresses - * another's, which is how a request reaches a module that is not this one. */ + * another's, which is how a request reaches a module that is not this one; `seat:.` + * addresses a role's tool, answered by whoever holds the seat (novox/hq ADR 0132) — with + * `seat:.@` for a node-scoped seat, whose tool carries the machine (design 33 §4). */ function toolSubject(key: string, self: string): string { + if (key.startsWith("seat:")) { + const rest = key.slice("seat:".length); + const dot = rest.indexOf("."); + if (dot < 0) throw new Error(`"${key}" names a seat and no verb: seat:.`); + const seat = rest.slice(0, dot); + const [verb, node] = rest.slice(dot + 1).split("@", 2); + return node ? `mesh.seat.${seat}.tool.${verb}.${node}` : `mesh.seat.${seat}.tool.${verb}`; + } const dot = key.indexOf("."); if (dot < 0) return `mesh.mod.${self}.tool.${key}`; return `mesh.mod.${key.slice(0, dot)}.tool.${key.slice(dot + 1)}`; diff --git a/src/client.ts b/src/client.ts index 1dd3838..e46640c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -26,13 +26,20 @@ import { TOOLS_VERB, type ToolsAnswer } from "./runtime.js"; /** Where the catalogue answers which modules the mesh holds. */ const CATALOGUE_MODULES = "mesh-catalog.catalog_modules"; +/** Where the mesh answers every role's tools, from its records: the mesh-controller seat's own + * `tools` verb (novox/hq ADR 0154, design 33 §5). */ +const SEAT_TOOLS = "seat:mesh-controller.tools"; + /** A tool as its module describes it. */ export interface Tool { + /** The module that serves it — or, for a role's tool, the seat. */ module: string; name: string; description?: string; /** The JSON schema of what it takes, as the module declared it. */ input?: unknown; + /** True for a role's tool: addressed to the seat, answered by whoever holds it (ADR 0132). */ + seat?: boolean; } /** @@ -45,10 +52,39 @@ export interface Tool { export interface Listing { tools: Tool[]; /** Modules the catalogue holds whose runtime did not answer `tools`: not assigned, not up, or built - * before the runtime answered it. Each may still be called by name. */ + * before the runtime answered it. Each may still be called by name. The mesh's own records are + * listed here as `mesh-controller (seat)` when the control plane did not answer. */ notAnswering: string[]; } +/** The seats and the verbs each declares, from the last listing, so a call can tell a role's tool + * from a module's when the two share a prefix (a module and a seat may share a name). */ +export type Seats = Map>; + +/** The roles' tools, keyed the way `toolKey` names them. */ +export function seatsIn(have: Listing): Seats { + const seats: Seats = new Map(); + for (const t of have.tools) { + if (!t.seat) continue; + if (!seats.has(t.module)) seats.set(t.module, new Set()); + seats.get(t.module)!.add(t.name); + } + return seats; +} + +/** The key a call uses for `.`: a role's when the prefix is a seat declaring that + * verb, a module's otherwise. Both names for one capability are deliberate and bounded (ADR 0132); + * the seat wins only for a verb it actually declares, so a module's own tool is never shadowed. */ +export function toolKey(name: string, seats?: Seats): string { + if (name.startsWith("seat:")) return name; + const dot = name.indexOf("."); + if (dot < 0) return name; + const prefix = name.slice(0, dot); + const verb = name.slice(dot + 1); + if (seats?.get(prefix)?.has(verb)) return `seat:${prefix}.${verb}`; + return name; +} + /** * A person's credential, as `operator issue` prints it. * @@ -118,10 +154,17 @@ export async function connectAsTheConsole(path: string): Promise<{ bus: Broker; * refuses at once a request nothing serves, so the cost is bounded by the modules that are up. */ export async function toolsOn(bus: Broker): Promise { - const answered = await bus.request, { modules?: { module: string }[] }>( - CATALOGUE_MODULES, - {}, - ); + const [answered, roles] = await Promise.all([ + bus.request, { modules?: { module: string }[] }>(CATALOGUE_MODULES, {}), + // The roles' tools, from the mesh's records (design 33 §5). Asked beside the modules rather + // than first: a control plane that is restarting must not hide every module's tools with it. + bus + .request, { seats?: { seat: string; scope?: string; tools?: ToolsAnswer["tools"] }[] }>( + SEAT_TOOLS, + {}, + ) + .catch(() => undefined), + ]); const names = (answered.modules ?? []).map((m) => m.module).filter((m) => typeof m === "string"); const asked = await Promise.allSettled( @@ -129,6 +172,18 @@ export async function toolsOn(bus: Broker): Promise { ); const tools: Tool[] = []; const notAnswering: string[] = []; + if (roles) { + for (const s of roles.seats ?? []) { + // A node-scoped seat's tool is asked of one machine, and the listing does not know which; + // those wait for a caller naming the node (`seat:.@`). + if (s.scope === "node") continue; + for (const t of s.tools ?? []) { + tools.push({ module: s.seat, name: t.name, description: t.description, input: t.input, seat: true }); + } + } + } else { + notAnswering.push("mesh-controller (seat)"); + } asked.forEach((outcome, i) => { const module = names[i]!; if (outcome.status === "fulfilled" && Array.isArray(outcome.value?.tools)) { @@ -146,13 +201,13 @@ export async function toolsOn(bus: Broker): Promise { /** Call one tool. The key is `.`, which is what a person types and what the account * permits — one vocabulary, so a refusal names the thing they asked for. */ -export async function callTool(bus: Broker, key: string, args: unknown): Promise { +export async function callTool(bus: Broker, key: string, args: unknown, seats?: Seats): Promise { if (!key.includes(".")) { throw new Error( `"${key}" does not name a tool: write ., as \`mesh tools\` lists them`, ); } - return bus.request(key, args ?? {}); + return bus.request(toolKey(key, seats), args ?? {}); } /** @@ -165,8 +220,9 @@ export async function callTool(bus: Broker, key: string, args: unknown): Promise export function whyItFailed(key: string, err: unknown): string { const message = err instanceof Error ? err.message : String(err); if (/no responders|503/i.test(message)) { - return `nothing serves ${key}. The module may not be assigned to any machine, or it is down — ` + - "`mesh tools` lists what answered."; + return `nothing serves ${key}. The module may not be assigned to any machine, or it is down` + + (key.startsWith("seat:") ? ", or nothing holds that seat" : "") + + " — `mesh tools` lists what answered."; } if (/permissions violation|authorization/i.test(message)) { return `this account may not call ${key}. What it may call was fixed when it was issued — a ` + diff --git a/src/mcp.ts b/src/mcp.ts index 2f08f02..2456bbe 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -15,7 +15,7 @@ */ import type { Broker } from "@novox/mesh-sdk/messaging"; -import { callTool, toolsOn, whyItFailed, type Listing } from "./client.js"; +import { callTool, seatsIn, toolsOn, whyItFailed, type Listing, type Seats } from "./client.js"; /** The protocol version this speaks. Stated, because a host that wants another should be told so * rather than discovering it through a shape it did not expect. */ @@ -67,6 +67,16 @@ export function mcpSurface(bus: Broker, who: string): Surface { } return known.listing; }; + // The roles the last listing knew, so `.` resolves to the seat. Fetched once if a + // call arrives before any list did; a listing that failed leaves no roles, and the name is then + // a module's, which is the right fallback for a mesh whose control plane is away. + const roles = async (): Promise => { + try { + return seatsIn(await listing()); + } catch { + return undefined; + } + }; return { async handle(request) { @@ -86,9 +96,9 @@ export function mcpSurface(bus: Broker, who: string): Surface { `These are the tools of a Novox mesh, reached as ${who}. Every call goes to the module ` + `that serves it; what may be called was fixed when this account was issued, so a ` + `refusal means the account, not the tool. The list is what the running modules ` + - `answered; a module that did not answer is named in the list's _meta and can still be ` + - `called by .. The mesh's own verbs (status, push, assign) are not served ` + - `on the bus yet.`, + `answered, plus every role's tools from the mesh's records — the mesh's own verbs ` + + `(mesh-controller.status, .push, .assign …) among them; a module that did not answer ` + + `is named in the list's _meta and can still be called by ..`, }); case "notifications/initialized": @@ -122,7 +132,7 @@ export function mcpSurface(bus: Broker, who: string): Surface { const name = String(request.params?.name ?? ""); const args = request.params?.arguments ?? {}; try { - const result = await callTool(bus, name, args); + const result = await callTool(bus, name, args, await roles()); // Text, because that is what every host renders. The content is the module's answer // as JSON, unshaped: an adapter that flattened it would be deciding what matters in // somebody else's answer. diff --git a/src/mesh.ts b/src/mesh.ts index 877ef00..1361752 100644 --- a/src/mesh.ts +++ b/src/mesh.ts @@ -27,6 +27,7 @@ import { connectAs, connectAsTheConsole, credentialFrom, + seatsIn, toolsOn, whyItFailed, type Listing, @@ -135,7 +136,8 @@ function printListing(have: Listing, who?: string): void { // "not yours", and those need different people to fix them. for (const t of have.tools) { const name = `${t.module}.${t.name}`; - console.log(t.description ? `${name.padEnd(36)} ${t.description}` : name); + const line = t.description ? `${name.padEnd(36)} ${t.description}` : name; + console.log(t.seat ? `${line} (a role's tool: answered by whoever holds the ${t.module} seat)` : line); } if (have.notAnswering.length > 0) { console.log( @@ -169,7 +171,10 @@ async function calling(bus: Broker, args: string[]): Promise { const parsed = await argumentsFrom(args); if (parsed === undefined) return 1; try { - const answer = await callTool(bus, key, parsed); + // `.` reaches the role when the mesh lists that verb for the seat; `seat:` says so + // outright and asks nothing first. + const roles = key.startsWith("seat:") ? undefined : seatsIn(await toolsOn(bus).catch(() => ({ tools: [], notAnswering: [] }))); + const answer = await callTool(bus, key, parsed, roles); console.log(JSON.stringify(answer, null, 2)); return 0; } catch (e) { diff --git a/test/client.test.ts b/test/client.test.ts index c5ef26e..1e26e48 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -36,10 +36,21 @@ async function aMeshWithTools() { tools: [{ name: "price", description: "what something costs", input: { type: "object" } }], })); await shop.handle("price", async (body: { of?: string }) => ({ of: body.of ?? "nothing", cost: 12 })); + // And the mesh's own records, served by the holder of the mesh-controller seat (ADR 0154): one + // role's tool, and the verb that lists every role's. + const controller = await connectNats({ url: url!, module: "mesh-controller" }); + await controller.handle("seat:mesh-controller.tools", async () => ({ + seats: [ + { seat: "mesh-controller", scope: "mesh", tools: [{ name: "status", description: "what is wrong", input: {} }] }, + { seat: "node-dns-resolver", scope: "node", tools: [{ name: "lookup", description: "one machine's", input: {} }] }, + ], + })); + await controller.handle("seat:mesh-controller.status", async () => ({ output: "all quiet", ok: true })); return { async close() { await catalogue.close(); await shop.close(); + await controller.close(); }, }; } @@ -53,9 +64,12 @@ test("a person sees what the running modules answer, sorted, and who did not ans const have = await toolsOn(person); assert.deepEqual( 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", + ["mesh-catalog.catalog_modules", "mesh-controller.status", "shop.price"], + "the list is what the modules answered plus every role's tools, in a stable order", ); + // A role's tool is marked as one; a node-scoped seat's waits for a caller naming the node. + assert.ok(have.tools.find((x) => x.module === "mesh-controller")!.seat); + assert.ok(!have.tools.some((x) => x.module === "node-dns-resolver")); // 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. @@ -114,3 +128,23 @@ test("each way a call fails says what to do about it", () => { assert.match(whyItFailed("shop.price", new Error("timeout")), /did not answer in time/); assert.match(whyItFailed("shop.price", new Error("something else")), /something else/); }); + +test("a role's tool is reached through the seat, and a module's own name is never shadowed", async (t) => { + if (!url) return t.skip("MESH_TEST_NATS unset"); + const { seatsIn, toolKey } = await import("../dist/client.js"); + const mesh = await aMeshWithTools(); + const person = await connectNats({ url, module: "person.ada" }); + try { + const roles = seatsIn(await toolsOn(person)); + assert.equal(toolKey("mesh-controller.status", roles), "seat:mesh-controller.status"); + assert.equal(toolKey("mesh-controller.other", roles), "mesh-controller.other", "a verb the seat does not declare is a module's"); + assert.equal(toolKey("shop.price", roles), "shop.price"); + const answer = await callTool(person, "mesh-controller.status", {}, roles); + assert.deepEqual(answer, { output: "all quiet", ok: true }); + const direct = await callTool(person, "seat:mesh-controller.status", {}); + assert.deepEqual(direct, { output: "all quiet", ok: true }); + } finally { + await person.close(); + await mesh.close(); + } +}); diff --git a/test/mcp.test.ts b/test/mcp.test.ts index f7952eb..3f9c4db 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -28,9 +28,15 @@ async function aMeshAndACredential(t: { after: (fn: () => Promise | void) 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 })); + const controller = await connectNats({ url: url!, module: "mesh-controller" }); + await controller.handle("seat:mesh-controller.tools", async () => ({ + seats: [{ seat: "mesh-controller", scope: "mesh", tools: [{ name: "status", description: "what is wrong", input: {} }] }], + })); + await controller.handle("seat:mesh-controller.status", async () => ({ output: "all quiet", ok: true })); t.after(async () => { await catalogue.close(); await shop.close(); + await controller.close(); }); const { mkdtemp, writeFile } = await import("node:fs/promises"); @@ -89,11 +95,12 @@ test("a host initialises, lists the mesh's tools and calls one", async (t) => { 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"); + assert.deepEqual(listed.map((x: { name: string }) => x.name), ["mesh-controller.status", "shop.price"], + "the modules' tools and the roles', named the way a person names them"); + const price = listed[1]; + assert.ok(price.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" } } }); + assert.deepEqual(price.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"]); @@ -130,3 +137,14 @@ test("a method this surface does not have is refused, and a notification is not" assert.equal(replies[0].error.code, -32601); assert.match(replies[0].error.message, /resources\/list/); }); + +test("a host calls the mesh's own verb through the seat", 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: "mesh-controller.status", arguments: {} } }, + ]); + const result = replies[0].result; + assert.ok(!result.isError, JSON.stringify(replies[0])); + assert.deepEqual(JSON.parse(result.content[0].text), { output: "all quiet", ok: true }); +});