A person's client: the mesh's tools from a workstation
Design 25 §7's second item. Two surfaces over one thing — a command line for somebody at a terminal, an MCP server for an agent — and both are adapters over the same three calls: what tools are there, what does this one take, call it. A second way of reaching a tool would be a second thing to keep correct. It uses the client a module's runtime uses. Not a bridge and not a second protocol: a person connects as their own bus user and publishes on the tool subjects their account permits, so "what may this person do" is answered by the same permission list that answers it for a module, and an audit has nothing separate to read. `mesh tools` lists what the *catalogue* has, not what this credential may call. The two differ and the difference is the point: somebody seeing only their own tools cannot tell "not installed" from "not yours", and those need different people to fix them. A failed call says which of three things happened, because the remedies are in three different places: nobody serves that tool, this credential may not call it, or the tool itself was slow. Without that they are one timeout and a stack trace. The MCP surface decides nothing. The tool names are the ones a person types, the schemas are the modules' own, and an answer is passed through unshaped — an adapter that summarised somebody else's answer would be deciding what matters in it. A tool that fails comes back as a tool error rather than a protocol error, because the request was well-formed and the mesh answered it. Written against the protocol directly: it is three methods and one framing, and a dependency here would be a dependency on every workstation. Tests drive both surfaces against a real bus, including that a host's notification is answered with nothing and an unknown method is refused. They run one file at a time, because each stands up a module serving the same tool subjects and run together their requests get split between them — which showed up as one test reading another's answer.
This commit is contained in:
+125
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* A person's client: the mesh's tools from a workstation (novox/hq design 25 §7).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { Broker } from "@novox/mesh-sdk/messaging";
|
||||
|
||||
import { connectNats, type Credential } from "./broker-nats.js";
|
||||
|
||||
/** Where the catalogue answers what tools the mesh has. */
|
||||
const CATALOGUE_TOOLS = "mesh-catalog.catalog_tools";
|
||||
|
||||
/** A tool as the catalogue describes one. */
|
||||
export interface Tool {
|
||||
module: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
/** The JSON schema of what it takes, as the module declared it. */
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A person's credential, as `operator issue` prints it.
|
||||
*
|
||||
* The same shape a module is handed, minus the parts a module needs and a person does not: no node,
|
||||
* because a person is not on a machine, and no module, because they are not one.
|
||||
*/
|
||||
export interface PersonCredential extends Credential {
|
||||
person?: string;
|
||||
invokes?: string[];
|
||||
}
|
||||
|
||||
/** Read the credential from the file `operator issue` produced. */
|
||||
export async function credentialFrom(path: string): Promise<PersonCredential> {
|
||||
const raw = await readFile(path, "utf8");
|
||||
let held: PersonCredential;
|
||||
try {
|
||||
held = JSON.parse(raw) as PersonCredential;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`${path} is not a credential this mesh issued: ${(e as Error).message}. ` +
|
||||
"It is the JSON `operator issue` printed, saved verbatim.",
|
||||
);
|
||||
}
|
||||
if (!held.url || !held.user || !held.password) {
|
||||
throw new Error(
|
||||
`${path} names no bus, user or password. It is the JSON \`operator issue\` printed, saved ` +
|
||||
"verbatim — not an edited copy of it.",
|
||||
);
|
||||
}
|
||||
return held;
|
||||
}
|
||||
|
||||
/** Connect as this person. The module name the runtime wants is their own user, because every subject
|
||||
* it derives is for a tool somebody else serves. */
|
||||
export async function connectAs(held: PersonCredential): Promise<Broker> {
|
||||
return connectNats({ ...held, module: held.user });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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}`));
|
||||
}
|
||||
|
||||
/** Call one tool. The key is `<module>.<tool>`, which is what a person types and what their 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(".")) {
|
||||
throw new Error(
|
||||
`"${key}" does not name a tool: write <module>.<tool>, as \`mesh tools\` lists them`,
|
||||
);
|
||||
}
|
||||
return bus.request<unknown, unknown>(key, args ?? {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.";
|
||||
}
|
||||
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.";
|
||||
}
|
||||
if (/timeout/i.test(message)) {
|
||||
return `${key} did not answer in time. Something is serving it, so this is the tool being slow ` +
|
||||
"rather than absent.";
|
||||
}
|
||||
return `${key} failed: ${message}`;
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* The mesh's tools as an MCP server, over stdio (novox/hq design 25 §7).
|
||||
*
|
||||
* **A thin adapter and nothing more.** Every tool an agent sees is one the catalogue listed and one
|
||||
* this credential may call; the schema is the module's own; the answer is the module's own. Nothing
|
||||
* here decides anything, which is why it is short — an MCP surface that reshaped arguments or
|
||||
* summarised answers would be a second definition of what a tool is, and the module's manifest is the
|
||||
* first.
|
||||
*
|
||||
* Implemented against the protocol directly rather than through a library: the surface is three
|
||||
* methods and one framing, and a dependency here would be a dependency on every workstation.
|
||||
*/
|
||||
import type { Broker } from "@novox/mesh-sdk/messaging";
|
||||
|
||||
import { callTool, toolsOn, whyItFailed, type Tool } 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. */
|
||||
const PROTOCOL = "2024-11-05";
|
||||
|
||||
interface Request {
|
||||
jsonrpc: string;
|
||||
id?: number | string | null;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve until stdin closes, which is how a host ends a session.
|
||||
*
|
||||
* The tool list is fetched once, on the first `tools/list`, and kept. An agent asks for it repeatedly
|
||||
* and the catalogue's answer does not change mid-session; refetching would make every turn cost a
|
||||
* round trip to a module for something nobody changed.
|
||||
*/
|
||||
export async function serveMcp(bus: Broker, who: string): Promise<void> {
|
||||
let known: Tool[] | undefined;
|
||||
|
||||
const say = (message: unknown) => {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
};
|
||||
const answer = (id: Request["id"], result: unknown) => say({ jsonrpc: "2.0", id, result });
|
||||
const refuse = (id: Request["id"], code: number, message: string) =>
|
||||
say({ jsonrpc: "2.0", id, error: { code, message } });
|
||||
|
||||
for await (const line of lines()) {
|
||||
let request: Request;
|
||||
try {
|
||||
request = JSON.parse(line) as Request;
|
||||
} catch {
|
||||
// Unparseable, and with no id there is nobody to tell. Skipped rather than answered, because a
|
||||
// reply to a request that was never framed is noise on the same channel.
|
||||
continue;
|
||||
}
|
||||
// A notification has no id and expects no answer; `initialized` is the one every host sends.
|
||||
const notification = request.id === undefined || request.id === null;
|
||||
|
||||
switch (request.method) {
|
||||
case "initialize":
|
||||
answer(request.id, {
|
||||
protocolVersion: PROTOCOL,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "mesh", version: "1" },
|
||||
// Said in the handshake, because an agent that knows whose authority it is acting under can
|
||||
// say so when a call is refused — and a refusal is the one thing here that is not the
|
||||
// mesh's fault or the tool's.
|
||||
instructions:
|
||||
`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 credential was issued, so a ` +
|
||||
`refusal means the credential, not the tool.`,
|
||||
});
|
||||
break;
|
||||
|
||||
case "notifications/initialized":
|
||||
break;
|
||||
|
||||
case "tools/list": {
|
||||
try {
|
||||
known ??= await toolsOn(bus);
|
||||
} catch (e) {
|
||||
refuse(request.id, -32603, whyItFailed("mesh-catalog.catalog_tools", e));
|
||||
break;
|
||||
}
|
||||
answer(request.id, {
|
||||
tools: known.map((t) => ({
|
||||
name: `${t.module}.${t.name}`,
|
||||
description: t.description ?? `${t.name}, served by ${t.module}`,
|
||||
// The module's own schema, passed through. An empty object is a tool that takes nothing,
|
||||
// which is a real answer and not a missing one.
|
||||
inputSchema: t.input ?? { type: "object", properties: {} },
|
||||
})),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "tools/call": {
|
||||
const name = String(request.params?.name ?? "");
|
||||
const args = request.params?.arguments ?? {};
|
||||
try {
|
||||
const result = await callTool(bus, name, args);
|
||||
// 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.
|
||||
answer(request.id, {
|
||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
||||
});
|
||||
} catch (e) {
|
||||
// **An error the agent can act on, not a stack.** isError rather than a protocol failure,
|
||||
// because the call was well-formed and the mesh answered it — with a refusal, an absence or
|
||||
// a fault, and the words say which.
|
||||
answer(request.id, {
|
||||
content: [{ type: "text", text: whyItFailed(name, e) }],
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
if (!notification) {
|
||||
refuse(request.id, -32601, `mesh's MCP surface has no ${request.method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** stdin as newline-framed messages, which is what MCP over stdio is. */
|
||||
async function* lines(): AsyncGenerator<string> {
|
||||
let buffered = "";
|
||||
for await (const chunk of process.stdin) {
|
||||
buffered += (chunk as Buffer).toString("utf8");
|
||||
let at: number;
|
||||
while ((at = buffered.indexOf("\n")) >= 0) {
|
||||
const line = buffered.slice(0, at).trim();
|
||||
buffered = buffered.slice(at + 1);
|
||||
if (line !== "") yield line;
|
||||
}
|
||||
}
|
||||
if (buffered.trim() !== "") yield buffered.trim();
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* `mesh` — the mesh's tools from a workstation, for a person (novox/hq design 25 §7).
|
||||
*
|
||||
* Three verbs and nothing else. What tools are there, call one, and serve the same two to an agent
|
||||
* over MCP. Deliberately thin: everything that could be a decision is one the mesh already made, and a
|
||||
* client that grew opinions would be a second place the mesh's behaviour is defined.
|
||||
*
|
||||
* mesh tools what this credential may call
|
||||
* mesh call <module>.<tool> [json] call one, arguments as JSON on the command line or on stdin
|
||||
* mesh mcp the same, as an MCP server over stdio
|
||||
*
|
||||
* The credential comes from MESH_CREDENTIAL, or --credential. It is the JSON `operator issue` printed.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { callTool, connectAs, credentialFrom, toolsOn, whyItFailed, type Tool } from "./client.js";
|
||||
import { serveMcp } from "./mcp.js";
|
||||
|
||||
const usage = `mesh tools
|
||||
mesh call <module>.<tool> [json]
|
||||
mesh mcp
|
||||
|
||||
--credential <file> the JSON \`operator issue\` printed; default $MESH_CREDENTIAL`;
|
||||
|
||||
async function main(argv: string[]): Promise<number> {
|
||||
const args = [...argv];
|
||||
let credentialPath = process.env.MESH_CREDENTIAL ?? "";
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--credential") {
|
||||
credentialPath = args[i + 1] ?? "";
|
||||
args.splice(i, 2);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
const verb = args.shift();
|
||||
if (!verb || verb === "help" || verb === "--help") {
|
||||
console.log(usage);
|
||||
return verb ? 0 : 1;
|
||||
}
|
||||
if (!credentialPath) {
|
||||
console.error(
|
||||
"no credential: set MESH_CREDENTIAL or pass --credential <file>. It is the JSON " +
|
||||
"`operator issue` printed, saved verbatim.",
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const held = await credentialFrom(credentialPath);
|
||||
const bus = await connectAs(held);
|
||||
try {
|
||||
switch (verb) {
|
||||
case "tools":
|
||||
return await listing(bus, held.person);
|
||||
case "call":
|
||||
return await calling(bus, args);
|
||||
case "mcp":
|
||||
// Serves until stdin closes, which is how an MCP host ends a session.
|
||||
await serveMcp(bus, held.person ?? held.user ?? "somebody");
|
||||
return 0;
|
||||
default:
|
||||
console.error(`mesh has no "${verb}".\n\n${usage}`);
|
||||
return 1;
|
||||
}
|
||||
} finally {
|
||||
await bus.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function listing(bus: Awaited<ReturnType<typeof connectAs>>, who?: string): Promise<number> {
|
||||
let tools: Tool[];
|
||||
try {
|
||||
tools = await toolsOn(bus);
|
||||
} catch (e) {
|
||||
console.error(whyItFailed("mesh-catalog.catalog_tools", e));
|
||||
return 1;
|
||||
}
|
||||
if (tools.length === 0) {
|
||||
console.log("the catalogue lists no tools; nothing on this mesh serves any");
|
||||
return 0;
|
||||
}
|
||||
// **What the catalogue has, not what this credential may call.** The two differ and the difference
|
||||
// is the point: a person seeing only their own tools cannot tell "not installed" from "not yours",
|
||||
// and those need different people to fix them.
|
||||
for (const t of tools) {
|
||||
const name = `${t.module}.${t.name}`;
|
||||
console.log(t.description ? `${name.padEnd(36)} ${t.description}` : name);
|
||||
}
|
||||
if (who) {
|
||||
console.log(`\nthis is what the mesh has. What ${who} may call was fixed when the credential was issued.`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function calling(
|
||||
bus: Awaited<ReturnType<typeof connectAs>>,
|
||||
args: string[],
|
||||
): Promise<number> {
|
||||
const key = args.shift();
|
||||
if (!key) {
|
||||
console.error("mesh call <module>.<tool> [json]");
|
||||
return 1;
|
||||
}
|
||||
const raw = args.length > 0 ? args.join(" ") : await maybeStdin();
|
||||
let parsed: unknown = {};
|
||||
if (raw.trim() !== "") {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.error(`the arguments are not JSON: ${(e as Error).message}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const answer = await callTool(bus, key, parsed);
|
||||
console.log(JSON.stringify(answer, null, 2));
|
||||
return 0;
|
||||
} catch (e) {
|
||||
console.error(whyItFailed(key, e));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Arguments on stdin, for a call whose JSON is too long or too quoted to type. Empty when stdin is a
|
||||
* terminal, so `mesh call x.y` with no arguments does not hang waiting for something nobody is
|
||||
* typing. */
|
||||
async function maybeStdin(): Promise<string> {
|
||||
if (process.stdin.isTTY) return "";
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
// Only when run, so a test can import the pieces.
|
||||
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
|
||||
main(process.argv.slice(2))
|
||||
.then((code) => process.exit(code))
|
||||
.catch((e) => {
|
||||
console.error(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { main, usage };
|
||||
export const _readFile = readFile;
|
||||
Reference in New Issue
Block a user