diff --git a/src/client.ts b/src/client.ts
index 4b23529..e45071d 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -40,6 +40,9 @@ export interface Tool {
input?: unknown;
/** True for a role's tool: addressed to the seat, answered by whoever holds it (ADR 0132). */
seat?: boolean;
+ /** A role's scope: `node` for a seat held once per machine, whose verb is asked of one machine
+ * (design 33 §4) and takes `node` for it; `mesh` or absent otherwise. */
+ scope?: string;
/** Where the tool is answered, as the mesh issued it (ADR 0160): the plain subject first when the
* module answers for itself anywhere, then one per machine. Absent for a runtime older than this. */
subjects?: string[];
@@ -177,11 +180,11 @@ export async function toolsOn(bus: Broker): Promise
{
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;
+ // A node-scoped seat's tool is asked of one machine (design 33 §4): listed with its scope, so
+ // a caller names the machine and the call carries it — `seat:.@`. Left out
+ // of the listing, the verb never resolved as a seat's and nothing served it (ADR 0169).
for (const t of s.tools ?? []) {
- tools.push({ module: s.seat, name: t.name, description: t.description, input: t.input, seat: true });
+ tools.push({ module: s.seat, name: t.name, description: t.description, input: t.input, seat: true, scope: s.scope });
}
}
} else {
diff --git a/src/mcp.ts b/src/mcp.ts
index c0c5704..14d9573 100644
--- a/src/mcp.ts
+++ b/src/mcp.ts
@@ -122,7 +122,9 @@ export function mcpSurface(bus: Broker, who: string): Surface {
// the module runs on several (novox/hq ADR 0159); a seat's verb takes none, the
// seat's scope decides. An empty object is a tool that takes nothing, which is a
// real answer and not a missing one.
- inputSchema: t.seat ? asSchema(t.input) : withNode(asSchema(t.input)),
+ inputSchema: t.seat && t.scope !== "node" ? asSchema(t.input)
+ : t.seat ? withNode(asSchema(t.input), "the machine whose seat answers; required, the seat is held once per machine", true)
+ : withNode(asSchema(t.input)),
})),
// Silence, named (design 34 §3): the modules the catalogue holds and nothing answered
// for. Not a tool, so not in `tools`; not dropped either.
@@ -139,9 +141,18 @@ export function mcpSurface(bus: Broker, who: string): Surface {
// 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 bare = given.split("@", 1)[0];
+ const isSeatVerb = roles ? toolKey(bare, roles).startsWith("seat:") : false;
+ // A node-scoped seat's verb is asked of one machine (design 33 §4, ADR 0169): `node`
+ // names it and travels in the subject, as for a module's tool.
+ const nodeScoped = isSeatVerb && (have?.tools.some((t) => t.seat && t.scope === "node" &&
+ `${t.module}.${t.name}` === bare) ?? false);
+ const takesNode = !isSeatVerb || nodeScoped;
+ const node = takesNode && typeof args.node === "string" && args.node !== "" ? args.node : "";
+ if (takesNode) delete args.node;
+ if (nodeScoped && !node && !given.includes("@")) {
+ return refuse(request.id, -32602, `${given} is a machine's seat's verb: name the machine with \`node\``);
+ }
const name = node && !given.includes("@") ? `${given}@${node}` : given;
try {
const { result, node: answeredBy } = await callTool(bus, name, args, roles, have);
@@ -229,13 +240,19 @@ async function* lines(): AsyncGenerator {
/** Every module tool takes an optional `node`: the machine to ask when the module runs on several
* (novox/hq ADR 0159). Added to the listing, stripped before the call, never seen by the module. */
-function withNode(schema: Record): Record {
+function withNode(schema: Record, description?: string, required = false): Record {
const properties = { ...((schema.properties as Record | undefined) ?? {}) };
if (!("node" in properties)) {
properties.node = {
type: "string",
- description: "the machine to ask, when this module runs on several; else whichever answers, and the answer says which",
+ description: description ??
+ "the machine to ask, when this module runs on several; else whichever answers, and the answer says which",
};
}
- return { ...schema, type: "object", properties };
+ const out: Record = { ...schema, type: "object", properties };
+ if (required) {
+ const have = Array.isArray(schema.required) ? (schema.required as string[]) : [];
+ out.required = have.includes("node") ? have : [...have, "node"];
+ }
+ return out;
}
diff --git a/test/client.test.ts b/test/client.test.ts
index f7fe14f..d438524 100644
--- a/test/client.test.ts
+++ b/test/client.test.ts
@@ -14,7 +14,7 @@ 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";
+import { callTool, seatsIn, toolKey, toolsOn, whyItFailed } from "../dist/client.js";
const url = process.env.MESH_TEST_NATS;
@@ -64,12 +64,15 @@ 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", "mesh-controller.status", "shop.price"],
+ ["mesh-catalog.catalog_modules", "mesh-controller.status", "node-dns-resolver.lookup", "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.
+ // A role's tool is marked as one; a node-scoped seat's carries its scope, so a caller names
+ // the machine and the verb resolves as the seat's (design 33 §4, ADR 0169).
assert.ok(have.tools.find((x) => x.module === "mesh-controller")!.seat);
- assert.ok(!have.tools.some((x) => x.module === "node-dns-resolver"));
+ const lookup = have.tools.find((x) => x.module === "node-dns-resolver")!;
+ assert.ok(lookup.seat && lookup.scope === "node");
+ assert.equal(toolKey("node-dns-resolver.lookup", seatsIn(have)), "seat:node-dns-resolver.lookup");
// 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.
diff --git a/test/mcp.test.ts b/test/mcp.test.ts
index a8983e7..687bd86 100644
--- a/test/mcp.test.ts
+++ b/test/mcp.test.ts
@@ -30,11 +30,16 @@ 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: {} },
- { name: "push", description: "tell a machine", input: { node: { type: "string" } } },
- ] }],
+ seats: [
+ { seat: "mesh-controller", scope: "mesh", tools: [
+ { name: "status", description: "what is wrong", input: {} },
+ { name: "push", description: "tell a machine", input: { node: { type: "string" } } },
+ ] },
+ // A seat held once per machine (design 33 §4, ADR 0169): its verb is asked of one.
+ { seat: "node-dns-resolver", scope: "node", tools: [{ name: "lookup", description: "one machine's", input: {} }] },
+ ],
}));
+ await controller.handle("seat:node-dns-resolver.lookup@anchor", async () => ({ machine: "anchor", answered: true }));
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 () => {
@@ -99,9 +104,14 @@ 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.push", "mesh-controller.status", "shop.price"],
+ assert.deepEqual(listed.map((x: { name: string }) => x.name),
+ ["mesh-controller.push", "mesh-controller.status", "node-dns-resolver.lookup", "shop.price"],
"the modules' tools and the roles', named the way a person names them");
- const price = listed[2];
+ // A node-scoped seat's verb takes the machine, and requires it (ADR 0169).
+ const lookup = listed[2];
+ assert.equal(lookup.inputSchema.properties.node.type, "string");
+ assert.deepEqual(lookup.inputSchema.required, ["node"]);
+ const price = listed[3];
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.
@@ -169,3 +179,18 @@ test("a host calls the mesh's own verb through the seat", async (t) => {
assert.ok(!result.isError, JSON.stringify(replies[0]));
assert.deepEqual(JSON.parse(result.content[0].text), { output: "all quiet", ok: true });
});
+
+test("a node-scoped seat's verb is asked of the machine named, and refused without 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", id: 2, method: "tools/call", params: { name: "node-dns-resolver.lookup", arguments: { node: "anchor" } } },
+ { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "node-dns-resolver.lookup", arguments: {} } },
+ ]);
+ const byId = new Map(replies.map((r) => [r.id, r]));
+ const answered = byId.get(2)!.result;
+ assert.ok(!answered.isError, JSON.stringify(answered));
+ assert.match(answered.content[0].text, /"machine": "anchor"/, "the machine's holder answered");
+ assert.match(byId.get(3)!.error?.message ?? JSON.stringify(byId.get(3)), /name the machine/);
+});