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:
jochen
2026-10-02 20:34:19 +02:00
parent 163f85ac35
commit 78817c949e
24 changed files with 699 additions and 2 deletions
+118
View File
@@ -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();
}