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:
+86
-31
@@ -1,30 +1,32 @@
|
||||
/**
|
||||
* A person's client: the mesh's tools from a workstation (novox/hq design 25 §7).
|
||||
* The mesh's tools, for whoever is on a machine (novox/hq design 25 §7, design 34).
|
||||
*
|
||||
* Two surfaces over one thing. A command line, for somebody at a terminal; an MCP server, for an
|
||||
* agent. Both are adapters over the same three calls — what tools are there, what does this one take,
|
||||
* call it — because a second way of reaching a tool is a second thing to keep correct.
|
||||
*
|
||||
* **It uses the same client a module's runtime uses.** Not a second protocol and not a bridge: a
|
||||
* person connects as their own bus user, publishes on the tool subjects their account permits, and the
|
||||
* server refuses anything else. So "what may this person do" is answered by the same permission list
|
||||
* that answers it for a module, and there is nothing here for an audit to read separately.
|
||||
* **It uses the same client a module's runtime uses.** Not a second protocol and not a bridge: the
|
||||
* caller connects as its own bus user — a person's, or the console's — publishes on the tool subjects
|
||||
* that account permits, and the server refuses anything else. So "what may this ask" is answered by the
|
||||
* same permission list that answers it for a module, and there is nothing here for an audit to read
|
||||
* separately.
|
||||
*
|
||||
* What a person may NOT do is the more interesting half, and none of it is enforced here — it is the
|
||||
* account (design 25 §4): they cannot publish an event, so they cannot claim a module said something;
|
||||
* they have no consumer, so there is no delivery to acknowledge; and they cannot answer a request, so
|
||||
* they cannot impersonate a module on a bus where anyone may serve a tool.
|
||||
* What the caller may NOT do is the more interesting half, and none of it is enforced here — it is the
|
||||
* account (design 25 §4): it cannot publish an event, so it cannot claim a module said something; it
|
||||
* has no consumer, so there is no delivery to acknowledge; and it cannot answer a request, so it cannot
|
||||
* impersonate a module on a bus where anyone may serve a tool.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { Broker } from "@novox/mesh-sdk/messaging";
|
||||
|
||||
import { connectNats, type Credential } from "./broker-nats.js";
|
||||
import { TOOLS_VERB, type ToolsAnswer } from "./runtime.js";
|
||||
|
||||
/** Where the catalogue answers what tools the mesh has. */
|
||||
const CATALOGUE_TOOLS = "mesh-catalog.catalog_tools";
|
||||
/** Where the catalogue answers which modules the mesh holds. */
|
||||
const CATALOGUE_MODULES = "mesh-catalog.catalog_modules";
|
||||
|
||||
/** A tool as the catalogue describes one. */
|
||||
/** A tool as its module describes it. */
|
||||
export interface Tool {
|
||||
module: string;
|
||||
name: string;
|
||||
@@ -33,6 +35,20 @@ export interface Tool {
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the mesh could say about its tools when asked (design 34 §3).
|
||||
*
|
||||
* **Silence is named, never dropped.** A module the catalogue holds and nothing answered for is in
|
||||
* `notAnswering`, because a tool that is not offered looks exactly like a tool that does not exist,
|
||||
* and those need different people to fix them.
|
||||
*/
|
||||
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. */
|
||||
notAnswering: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A person's credential, as `operator issue` prints it.
|
||||
*
|
||||
@@ -72,24 +88,63 @@ export async function connectAs(held: PersonCredential): Promise<Broker> {
|
||||
}
|
||||
|
||||
/**
|
||||
* What tools the mesh has, asked of the catalogue.
|
||||
*
|
||||
* **Asked, not configured.** The catalogue is the only thing that knows what is installed, and a
|
||||
* client carrying its own list would be a list that goes stale the first time a module is assigned —
|
||||
* silently, because a tool that is not offered looks exactly like a tool that does not exist.
|
||||
* Connect as the console: the module credential the mesh delivered (novox/hq ADR 0152), read from
|
||||
* the same variable every runtime reads. It names the node and the module, so the account's inbox
|
||||
* and subjects derive from what the mesh authorised and from nothing in this process's environment.
|
||||
*/
|
||||
export async function toolsOn(bus: Broker): Promise<Tool[]> {
|
||||
const answered = await bus.request<Record<string, never>, { tools?: Tool[] } | Tool[]>(
|
||||
CATALOGUE_TOOLS,
|
||||
{},
|
||||
);
|
||||
const tools = Array.isArray(answered) ? answered : (answered.tools ?? []);
|
||||
return tools
|
||||
.slice()
|
||||
.sort((a: Tool, b: Tool) => `${a.module}.${a.name}`.localeCompare(`${b.module}.${b.name}`));
|
||||
export async function connectAsTheConsole(path: string): Promise<{ bus: Broker; who: string }> {
|
||||
const raw = await readFile(path, "utf8");
|
||||
let held: Credential;
|
||||
try {
|
||||
held = JSON.parse(raw) as Credential;
|
||||
} catch (e) {
|
||||
throw new Error(`${path} is not a broker credential: ${(e as Error).message}`);
|
||||
}
|
||||
if (!held.url || !held.module || !held.user) {
|
||||
throw new Error(
|
||||
`${path} names no bus, module or user: the console runs on the credential the mesh sealed to ` +
|
||||
"this machine for it, and nothing else",
|
||||
);
|
||||
}
|
||||
return { bus: await connectNats(held), who: `${held.node ?? "?"}.${held.module}` };
|
||||
}
|
||||
|
||||
/** Call one tool. The key is `<module>.<tool>`, which is what a person types and what their account
|
||||
/**
|
||||
* What tools the mesh has, asked of the modules (design 34 §3).
|
||||
*
|
||||
* The catalogue says which modules the mesh holds; each module says what it serves, through the one
|
||||
* verb its runtime answers for it. **Asked, not configured**: a client carrying its own list would be a
|
||||
* list that goes stale the first time a module is assigned. Every module is asked at once, and the bus
|
||||
* 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<Listing> {
|
||||
const answered = await bus.request<Record<string, never>, { modules?: { module: string }[] }>(
|
||||
CATALOGUE_MODULES,
|
||||
{},
|
||||
);
|
||||
const names = (answered.modules ?? []).map((m) => m.module).filter((m) => typeof m === "string");
|
||||
|
||||
const asked = await Promise.allSettled(
|
||||
names.map((module) => bus.request<Record<string, never>, ToolsAnswer>(`${module}.${TOOLS_VERB}`, {})),
|
||||
);
|
||||
const tools: Tool[] = [];
|
||||
const notAnswering: string[] = [];
|
||||
asked.forEach((outcome, i) => {
|
||||
const module = names[i]!;
|
||||
if (outcome.status === "fulfilled" && Array.isArray(outcome.value?.tools)) {
|
||||
for (const t of outcome.value.tools) {
|
||||
tools.push({ module, name: t.name, description: t.description, input: t.input });
|
||||
}
|
||||
} else {
|
||||
notAnswering.push(module);
|
||||
}
|
||||
});
|
||||
tools.sort((a, b) => `${a.module}.${a.name}`.localeCompare(`${b.module}.${b.name}`));
|
||||
notAnswering.sort();
|
||||
return { tools, notAnswering };
|
||||
}
|
||||
|
||||
/** Call one tool. The key is `<module>.<tool>`, 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<unknown> {
|
||||
if (!key.includes(".")) {
|
||||
@@ -104,18 +159,18 @@ export async function callTool(bus: Broker, key: string, args: unknown): Promise
|
||||
* Why a call failed, said so that the remedy is in the words.
|
||||
*
|
||||
* Three answers a person actually gets, and they need different things done: nobody serves that tool,
|
||||
* the mesh refused this person, or the tool itself failed. Without this they are one timeout and a
|
||||
* the mesh refused this account, or the tool itself failed. Without this they are one timeout and a
|
||||
* stack trace.
|
||||
*/
|
||||
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 the catalogue says is there.";
|
||||
"`mesh tools` lists what answered.";
|
||||
}
|
||||
if (/permissions violation|authorization/i.test(message)) {
|
||||
return `this credential may not call ${key}. What it may call was fixed when it was issued; ` +
|
||||
"`operator issue` again with the tool named, or ask somebody who can.";
|
||||
return `this account may not call ${key}. What it may call was fixed when it was issued — a ` +
|
||||
"person's by `operator issue`, the console's by its manifest.";
|
||||
}
|
||||
if (/timeout/i.test(message)) {
|
||||
return `${key} did not answer in time. Something is serving it, so this is the tool being slow ` +
|
||||
|
||||
Reference in New Issue
Block a user