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:
+154
-89
@@ -1,47 +1,179 @@
|
||||
/**
|
||||
* The mesh's tools as an MCP server, over stdio (novox/hq design 25 §7).
|
||||
* The mesh's tools as an MCP server (novox/hq design 25 §7, design 34).
|
||||
*
|
||||
* **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.
|
||||
* **A thin adapter and nothing more.** Every tool an agent sees is one a module answered for and one
|
||||
* this account 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 code 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.
|
||||
* methods and one framing, and a dependency here would be a dependency on every machine.
|
||||
*
|
||||
* One handler, two transports. Over stdio for a program a person starts (`mesh mcp`), over HTTP on a
|
||||
* machine's loopback for the console the mesh assigns there (`mesh serve`, http.ts). The handler does
|
||||
* not know which asked.
|
||||
*/
|
||||
import type { Broker } from "@novox/mesh-sdk/messaging";
|
||||
|
||||
import { callTool, toolsOn, whyItFailed, type Tool } from "./client.js";
|
||||
import { callTool, toolsOn, whyItFailed, type Listing } 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";
|
||||
export const PROTOCOL = "2025-03-26";
|
||||
|
||||
interface Request {
|
||||
export interface Request {
|
||||
jsonrpc: string;
|
||||
id?: number | string | null;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Reply {
|
||||
jsonrpc: "2.0";
|
||||
id: Request["id"];
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string };
|
||||
}
|
||||
|
||||
/** How long a fetched tool list is kept before the modules are asked again. An agent asks on every
|
||||
* turn; the mesh changes on the order of minutes. */
|
||||
export const LISTING_KEPT_MS = 30_000;
|
||||
|
||||
export interface Surface {
|
||||
/** Answer one request; undefined for a notification, which expects none. */
|
||||
handle(request: Request): Promise<Reply | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve until stdin closes, which is how a host ends a session.
|
||||
* The surface over one bus connection, as one account.
|
||||
*
|
||||
* 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.
|
||||
* The tool list is fetched when first asked and kept for a short while (design 34 §3): asking every
|
||||
* module on every `tools/list` would fan out on every agent turn for something nobody changed, and
|
||||
* never refreshing would hide a module assigned a moment ago.
|
||||
*/
|
||||
export function mcpSurface(bus: Broker, who: string): Surface {
|
||||
let known: { listing: Listing; at: number } | undefined;
|
||||
|
||||
const answer = (id: Request["id"], result: unknown): Reply => ({ jsonrpc: "2.0", id, result });
|
||||
const refuse = (id: Request["id"], code: number, message: string): Reply => ({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: { code, message },
|
||||
});
|
||||
|
||||
const listing = async (): Promise<Listing> => {
|
||||
if (!known || Date.now() - known.at > LISTING_KEPT_MS) {
|
||||
known = { listing: await toolsOn(bus), at: Date.now() };
|
||||
}
|
||||
return known.listing;
|
||||
};
|
||||
|
||||
return {
|
||||
async handle(request) {
|
||||
// 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":
|
||||
return 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 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 <module>.<tool>. The mesh's own verbs (status, push, assign) are not served ` +
|
||||
`on the bus yet.`,
|
||||
});
|
||||
|
||||
case "notifications/initialized":
|
||||
return undefined;
|
||||
|
||||
case "ping":
|
||||
return notification ? undefined : answer(request.id, {});
|
||||
|
||||
case "tools/list": {
|
||||
let have: Listing;
|
||||
try {
|
||||
have = await listing();
|
||||
} catch (e) {
|
||||
return refuse(request.id, -32603, whyItFailed("mesh-catalog.catalog_modules", e));
|
||||
}
|
||||
return answer(request.id, {
|
||||
tools: have.tools.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: 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.
|
||||
_meta: { notAnswering: have.notAnswering },
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
return 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.
|
||||
return answer(request.id, {
|
||||
content: [{ type: "text", text: whyItFailed(name, e) }],
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return notification ? undefined : refuse(request.id, -32601, `mesh's MCP surface has no ${request.method}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A module's declared input as a JSON schema an agent can read.
|
||||
*
|
||||
* The sdk keeps a tool's input opaque, and the catalogue's modules write it as a bare map of
|
||||
* property to description — `{ module: { type, description } }` — which is the `properties` of a
|
||||
* schema rather than a schema. Wrapped here when that is what arrived; passed through when a module
|
||||
* already wrote a schema; an empty object when it declared nothing. The module's words are kept
|
||||
* either way.
|
||||
*/
|
||||
export function asSchema(input: unknown): Record<string, unknown> {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
return { type: "object", properties: {} };
|
||||
}
|
||||
const given = input as Record<string, unknown>;
|
||||
if (given.type === "object" || "properties" in given) return given;
|
||||
if (Object.keys(given).length === 0) return { type: "object", properties: {} };
|
||||
return { type: "object", properties: given };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve over stdio until stdin closes, which is how a host ends a session.
|
||||
*/
|
||||
export async function serveMcp(bus: Broker, who: string): Promise<void> {
|
||||
let known: Tool[] | undefined;
|
||||
|
||||
const surface = mcpSurface(bus, who);
|
||||
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 {
|
||||
@@ -51,75 +183,8 @@ export async function serveMcp(bus: Broker, who: string): Promise<void> {
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
const reply = await surface.handle(request);
|
||||
if (reply) say(reply);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user