// A tools bundle as a process the runtime launches (novox/hq ADR 0188). // // The runtime does not run a tool's code itself when the bundle is not JavaScript: it starts the // bundle's executable as a child with the runtime's environment and speaks MCP over stdio to it — // `initialize`, `tools/list` once, `tools/call` per call. A Rust binary, a Go binary, a Python // script and a Node script are the same thing from here: a process that answers those. Everything // the mesh adds — the subjects from the membership, the held seats, the `tools` answer, a bundle // that failed named and the others serving — is the runtime's, outside this file. // // A tool the child lists as `.` is the module's implementation of that seat's verb; // any other name is the module's own tool. The same rule the in-process registration follows. import { spawn, type ChildProcess } from "node:child_process"; import { accessSync, constants } from "node:fs"; import type { ToolDefinition } from "@novox/mesh-sdk/tools"; import { broker, type Envelope } from "@novox/mesh-sdk/messaging"; import { atWork } from "./broker-nats.js"; /** The protocol version this speaks; a bundle says the same. */ export const PROTOCOL = "2025-03-26"; /** How long a child has to answer `initialize` and `tools/list` before it is a failed bundle, and * how long a call may take before the caller is told the tool is slow rather than absent. */ const HANDSHAKE_MS = 10_000; const CALL_MS = 30_000; /** Whether an entrypoint is launched as a process rather than imported: anything that is not a * plain JavaScript file, and a JavaScript file marked executable — a bundle written against the * protocol in TypeScript, served the same way as any other language. */ export function launches(entry: string): boolean { const javascript = /\.(m|c)?js$/.test(entry); let executable = false; try { accessSync(entry, constants.X_OK); executable = true; } catch { // not executable, or not there — importing will say which } return !javascript || executable; } /** What a launched bundle registers: the groups the in-process path would have, by name. */ export interface Launched { registrations: { module: string; tools: ToolDefinition[] }[]; stop(): void; } interface Pending { resolve(v: any): void; reject(e: Error): void; timer: NodeJS.Timeout; } /** * Launch a bundle and learn its tools. Rejects when the child cannot be started or does not complete * the handshake, which the runtime records as the bundle having failed. A child that exits later is * started again on the next call, once; a call in flight when it died is told so. */ export async function launch(module: string, entry: string, env: NodeJS.ProcessEnv = process.env): Promise { let child: ChildProcess | undefined; let nextId = 1; const pending = new Map(); let stopped = false; const start = async (): Promise => { const proc = spawn(entry, [], { stdio: ["pipe", "pipe", "pipe"], env }); child = proc; let buffered = ""; proc.stdout!.on("data", (chunk: Buffer) => { buffered += chunk.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) continue; let reply: { id?: number | string; method?: string; params?: unknown; result?: unknown; error?: { message?: string } }; try { reply = JSON.parse(line); } catch { console.log(`[mesh-tools] ${module}'s bundle said something that is not a reply: ${line.slice(0, 120)}`); continue; } // **The bundle asks the runtime to emit** (novox/hq ADR 0193): published on the bus as this // module, and answered once the bus has accepted it, so the tool's emit means what it means // in-process. Nothing else a bundle may ask. if (typeof reply.method === "string") { const id = reply.id; const answer = (m: Record) => proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", id, ...m }) + "\n"); if (reply.method !== "mesh/publish") { if (id !== undefined) answer({ error: { code: -32601, message: `the runtime answers no ${reply.method} from a bundle` } }); continue; } atWork.run({ module }, () => broker().publish(reply.params as Envelope)) .then(() => { if (id !== undefined) answer({ result: {} }); }) .catch((err: unknown) => { if (id !== undefined) answer({ error: { code: -32000, message: err instanceof Error ? err.message : String(err) } }); }); continue; } const waiting = typeof reply.id === "number" ? pending.get(reply.id) : undefined; if (!waiting) continue; pending.delete(reply.id as number); clearTimeout(waiting.timer); if (reply.error) waiting.reject(new Error(reply.error.message ?? "the bundle refused the request")); else waiting.resolve(reply.result); } }); // stderr is the bundle's log; kept under the module's name so a fault reads where it belongs. // The last thing it said is kept, so a bundle that dies says why in its own words, not by code. let lastSaid = ""; proc.stderr!.on("data", (chunk: Buffer) => { for (const line of chunk.toString("utf8").split("\n")) { if (!line.trim()) continue; console.log(`[${module}] ${line}`); if (/\S/.test(line) && !/^\s+at\s/.test(line) && !/^Node\.js v/.test(line)) lastSaid = line.trim(); } }); const exited = new Promise((_, reject) => { proc.once("error", (err) => reject(err)); proc.once("exit", (code, signal) => { const why = `${module}'s bundle exited (${signal ?? code})` + (lastSaid ? `: ${lastSaid}` : ""); for (const [id, p] of pending) { pending.delete(id); clearTimeout(p.timer); p.reject(new Error(why)); } if (child === proc) child = undefined; if (!stopped) console.log(`[mesh-tools] ${why}; started again on its next call`); reject(new Error(why)); }); }); const ask = (method: string, params: unknown, ms: number): Promise => Promise.race([ new Promise((resolve, reject) => { const id = nextId++; const timer = setTimeout(() => { pending.delete(id); reject(new Error(`${module}'s bundle did not answer ${method} in ${ms / 1000}s`)); }, ms); pending.set(id, { resolve, reject, timer }); proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); }), exited, ]); exited.catch(() => {}); // observed through the race; never unhandled (proc as ChildProcess & { ask?: typeof ask }).ask = ask; await ask("initialize", { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: "node-tools", version: "1" } }, HANDSHAKE_MS); proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n"); }; const asking = async (method: string, params: unknown, ms: number): Promise => { if (!child) await start(); return (child as ChildProcess & { ask: (m: string, p: unknown, ms: number) => Promise }).ask(method, params, ms); }; await start(); const listed = (await asking("tools/list", {}, HANDSHAKE_MS)) as { tools?: { name: string; description?: string; inputSchema?: unknown }[] }; const groups = new Map(); for (const t of listed.tools ?? []) { const dot = t.name.indexOf("."); const under = dot < 0 ? module : t.name.slice(0, dot); const name = dot < 0 ? t.name : t.name.slice(dot + 1); const tools = groups.get(under) ?? []; tools.push({ name, description: t.description ?? "", input: (t.inputSchema as Record | undefined) ?? {}, run: async (args) => { const result = (await asking("tools/call", { name: t.name, arguments: args ?? {} }, CALL_MS)) as { content?: { type: string; text?: string }[]; isError?: boolean; }; const text = result?.content?.find((c) => c.type === "text")?.text ?? ""; if (result?.isError) throw new Error(text || `${module}.${t.name} failed`); // The bundle's answer is JSON as text (that is what every MCP host renders); handed back as // the value it encodes so a caller on the bus sees what an in-process tool would return. try { return JSON.parse(text); } catch { return text; } }, }); groups.set(under, tools); } return { registrations: [...groups].map(([under, tools]) => ({ module: under, tools })), stop: () => { stopped = true; child?.kill("SIGTERM"); child = undefined; }, }; }