From 670b486ac0b45a0c6c13f17528a5e957e273a27f Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 1 Oct 2026 15:42:35 +0200 Subject: [PATCH 1/3] A seat's verb keeps a node of its own; only a module's tool gives it to the subject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console moved every call's node into the subject (ADR 0159), so mesh-controller.push {node: x} became a call to the seat's verb on machine x, which nothing serves — the mesh's own verbs could not be given a machine from the console at all. A role's verb takes no machine from the console; its arguments are its own. --- src/mcp.ts | 16 ++++++++++------ test/mcp.test.ts | 21 +++++++++++++++++++-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/mcp.ts b/src/mcp.ts index af6adba..c0c5704 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -15,7 +15,7 @@ */ import type { Broker } from "@novox/mesh-sdk/messaging"; -import { callTool, seatsIn, toolsOn, whyItFailed, type Listing, type Seats } from "./client.js"; +import { callTool, seatsIn, toolKey, 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. */ @@ -134,13 +134,17 @@ export function mcpSurface(bus: Broker, who: string): Surface { const given = String(request.params?.name ?? ""); const args = { ...((request.params?.arguments as Record | undefined) ?? {}) }; // The machine, when the caller names one, travels in the subject and never reaches the - // module's arguments (novox/hq ADR 0159). - const node = typeof args.node === "string" && args.node !== "" ? args.node : ""; - delete args.node; + // module's arguments (novox/hq ADR 0159) — for a module's tool. A seat's verb takes no + // machine from the console (the seat's scope decides), so a `node` among its arguments + // is the verb's own, as `push` and `assign` take one, and is handed through untouched. + const have = await listing().catch(() => undefined); + const roles = have && seatsIn(have); + const isSeatVerb = roles ? toolKey(given.split("@", 1)[0], roles).startsWith("seat:") : false; + const node = !isSeatVerb && typeof args.node === "string" && args.node !== "" ? args.node : ""; + if (!isSeatVerb) delete args.node; const name = node && !given.includes("@") ? `${given}@${node}` : given; try { - const have = await listing().catch(() => undefined); - const { result, node: answeredBy } = await callTool(bus, name, args, have && seatsIn(have), have); + const { result, node: answeredBy } = await callTool(bus, name, args, roles, have); // 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. Which machine answered follows it as its own line. diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 81e5858..ce97480 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -30,9 +30,13 @@ async function aMeshAndACredential(t: { after: (fn: () => Promise | void) 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: {} }] }], + seats: [{ seat: "mesh-controller", scope: "mesh", tools: [ + { name: "status", description: "what is wrong", input: {} }, + { name: "push", description: "tell a machine", input: { node: { type: "string" } } }, + ] }], })); await controller.handle("seat:mesh-controller.status", async () => ({ output: "all quiet", ok: true })); + await controller.handle("seat:mesh-controller.push", async (body: { node?: string }) => ({ told: body.node ?? "nobody" })); t.after(async () => { await catalogue.close(); await shop.close(); @@ -95,7 +99,7 @@ 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.deepEqual(listed.map((x: { name: string }) => x.name), ["mesh-controller.status", "shop.price"], + assert.deepEqual(listed.map((x: { name: string }) => x.name), ["mesh-controller.push", "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"); @@ -141,6 +145,19 @@ test("a method this surface does not have is refused, and a notification is not" assert.match(replies[0].error.message, /resources\/list/); }); +// A seat's verb that takes a machine as its own argument — `push ` — keeps it: the console +// moves `node` into the subject for a module's tool only (ADR 0159), never for a role's verb. +test("a seat's verb keeps a node of its own; only a module's tool gives it to the subject", 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.push", arguments: { node: "anchor" } } }, + ]); + const result = replies[0].result; + assert.ok(!result.isError, JSON.stringify(replies[0])); + assert.deepEqual(JSON.parse(result.content[0].text), { told: "anchor" }); +}); + 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); From 809e2b11ec5142fa2b79e24f56335b5b330d51fe Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 1 Oct 2026 15:42:58 +0200 Subject: [PATCH 2/3] The listing test indexes the module's tool after the seat's two verbs --- test/mcp.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mcp.test.ts b/test/mcp.test.ts index ce97480..6d4e54b 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -101,7 +101,7 @@ test("a host initialises, lists the mesh's tools and calls one", async (t) => { const listed = byId.get(2)!.result.tools; assert.deepEqual(listed.map((x: { name: string }) => x.name), ["mesh-controller.push", "mesh-controller.status", "shop.price"], "the modules' tools and the roles', named the way a person names them"); - const price = listed[1]; + const price = listed[2]; 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 — and // `node`, the machine to ask when the module runs on several (novox/hq ADR 0159), beside them. From ce8c37a7fc422a64add09164a6dc45f5c2bb7f2b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 1 Oct 2026 15:43:36 +0200 Subject: [PATCH 3/3] The listing test tells the seat's two verbs apart: one keeps its own node, the other takes none --- test/mcp.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 6d4e54b..a8983e7 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -107,7 +107,8 @@ test("a host initialises, lists the mesh's tools and calls one", async (t) => { // `node`, the machine to ask when the module runs on several (novox/hq ADR 0159), beside them. assert.deepEqual(price.inputSchema.properties.of, { type: "string" }); assert.equal(price.inputSchema.properties.node.type, "string", "a module's tool takes the machine to ask"); - assert.ok(!listed[0].inputSchema.properties?.node, "a seat's verb takes no machine; the seat's scope decides"); + assert.ok(!listed[1].inputSchema.properties?.node, "a seat's verb takes no machine; the seat's scope decides"); + assert.equal(listed[0].inputSchema.properties?.node?.type, "string", "a seat's verb that takes a node of its own keeps it"); // Silence is named: the module the catalogue holds and nothing answered for. assert.deepEqual(byId.get(2)!.result._meta.notAnswering, ["ghost"]);