A tools bundle speaks MCP over stdio: the loop in TypeScript, and skeleton SDKs for Python, Go, Rust and C (hq ADR 0187)
The node's runtime launches a tools bundle as a process and speaks MCP over stdio to it, so a tool may be written in any language and the transport stays in the runtime (ADR 0039's refusal, kept). Each SDK is the loop and the tool type and nothing else: `@novox/mesh-sdk/stdio` (serveStdio, and serveRegisteredOverStdio for a bundle that already registers the in-process way), python/, go/, rust/ and c/ beside it, each with an example answering `greet` and the seat verb `node-lamp.on`. Skeletons, by the operator's direction: the bare minimum one bundle per language needs to be built and answer, proven here by each example answering tools/list and tools/call on stdio.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* A tools bundle as a process the node's runtime launches (novox/hq ADR 0187).
|
||||
*
|
||||
* The runtime speaks MCP over stdio to it: `initialize`, `tools/list` once, `tools/call` per call,
|
||||
* newline-framed JSON-RPC on stdin and stdout. This is the whole of what a tools bundle in any
|
||||
* language must do, and this file is the TypeScript SDK's share of it: the loop, so a module writes
|
||||
* its tools and nothing else. A tool named `<seat>.<verb>` is the module's implementation of that
|
||||
* seat's verb; any other name is the module's own tool.
|
||||
*
|
||||
* **No transport here.** The bus, the subjects, the memberships are the runtime's (ADR 0039, 0175);
|
||||
* a bundle knows only stdin and stdout, which is why a bus change rebuilds no bundle in any language.
|
||||
*
|
||||
* stdout is the protocol. A tool that wants to log writes to stderr, which the runtime keeps.
|
||||
*/
|
||||
import type { ToolDefinition } from "../contracts/index.js";
|
||||
import { collectTools } from "../tools/index.js";
|
||||
|
||||
/** The protocol version this speaks; the runtime says the same. */
|
||||
export const PROTOCOL = "2025-03-26";
|
||||
|
||||
interface Request {
|
||||
jsonrpc: string;
|
||||
id?: number | string | null;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A module's declared input as a JSON schema: the bare property map the catalogue's modules write
|
||||
* is wrapped, a schema is passed through, nothing is an empty object. */
|
||||
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 these tools to the runtime over stdio until stdin closes. `name` is the module's; a tool
|
||||
* whose name is `<seat>.<verb>` is served as the seat's. Resolves when the runtime hangs up.
|
||||
*/
|
||||
export async function serveStdio(name: string, tools: ToolDefinition[]): Promise<void> {
|
||||
const byName = new Map(tools.map((t) => [t.name, t]));
|
||||
const say = (message: unknown): void => {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
};
|
||||
for await (const line of lines()) {
|
||||
let request: Request;
|
||||
try {
|
||||
request = JSON.parse(line) as Request;
|
||||
} catch {
|
||||
continue; // not framed; nobody to answer
|
||||
}
|
||||
const notification = request.id === undefined || request.id === null;
|
||||
switch (request.method) {
|
||||
case "initialize":
|
||||
say({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name, version: "1" } } });
|
||||
break;
|
||||
case "notifications/initialized":
|
||||
break;
|
||||
case "ping":
|
||||
if (!notification) say({ jsonrpc: "2.0", id: request.id, result: {} });
|
||||
break;
|
||||
case "tools/list":
|
||||
say({
|
||||
jsonrpc: "2.0", id: request.id,
|
||||
result: { tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: asSchema(t.input) })) },
|
||||
});
|
||||
break;
|
||||
case "tools/call": {
|
||||
const wanted = String(request.params?.name ?? "");
|
||||
const tool = byName.get(wanted);
|
||||
if (!tool) {
|
||||
say({ jsonrpc: "2.0", id: request.id, error: { code: -32602, message: `${name} has no tool ${wanted}` } });
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const result = await tool.run((request.params?.arguments as Record<string, unknown> | undefined) ?? {});
|
||||
say({ jsonrpc: "2.0", id: request.id, result: { content: [{ type: "text", text: JSON.stringify(result ?? null) }] } });
|
||||
} catch (err) {
|
||||
// The tool failed; said as a tool error, not a protocol one, because the call was well-formed.
|
||||
say({ jsonrpc: "2.0", id: request.id, result: { content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], isError: true } });
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
if (!notification) say({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: `no ${request.method}` } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve everything registered with `registerModuleTools` over stdio: the one-line entry for a bundle
|
||||
* that already registers the way the in-process shortcut expects, so one source serves both ways.
|
||||
*/
|
||||
export async function serveRegisteredOverStdio(): Promise<void> {
|
||||
const groups = collectTools();
|
||||
const name = groups[0]?.module ?? "bundle";
|
||||
const tools: ToolDefinition[] = [];
|
||||
for (const g of groups) {
|
||||
for (const t of g.tools) tools.push(g.module === name ? t : { ...t, name: `${g.module}.${t.name}` });
|
||||
}
|
||||
await serveStdio(name, tools);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user