Every served entrypoint is started as a process speaking MCP over stdio, told its module and node; one that is not executable is refused by name. The import path, the SDK resolve hook (issue 209) and the per-registration hand-off go. The one-module form the per-module containers use is still imported until they move (to-be 38 WP4c). A child's mesh/publish is published as its module and answered once accepted; a child that dies says why in its own last words. Fixtures are served through launchers exactly as the builder writes them.
193 lines
8.8 KiB
TypeScript
193 lines
8.8 KiB
TypeScript
// 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 `<seat>.<verb>` 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<Launched> {
|
|
let child: ChildProcess | undefined;
|
|
let nextId = 1;
|
|
const pending = new Map<number, Pending>();
|
|
let stopped = false;
|
|
|
|
const start = async (): Promise<void> => {
|
|
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<string, unknown>) => 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<unknown>))
|
|
.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<never>((_, 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<any> =>
|
|
Promise.race([
|
|
new Promise<any>((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<any> => {
|
|
if (!child) await start();
|
|
return (child as ChildProcess & { ask: (m: string, p: unknown, ms: number) => Promise<any> }).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<string, ToolDefinition[]>();
|
|
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<string, unknown> | 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;
|
|
},
|
|
};
|
|
}
|