/** * Quarkus MCP client — wraps the shared McpClient with stdio transport. * * The child process lifecycle (spawn, kill, close listeners) lives here * because it's specific to how quarkus-agent-mcp is launched. */ import { type ChildProcessWithoutNullStreams } from "node:child_process"; import { createInterface } from "node:readline"; import { McpClient as McpClientBase, type McpTransport, type McpTool, type McpToolResult } from "./vendor/mcp-client.ts"; import { spawnSafe } from "./vendor/spawn-safe.ts"; export type { McpTool, McpToolResult }; // --------------------------------------------------------------------------- // StdioTransport // --------------------------------------------------------------------------- /** * MCP transport over a child process's stdin/stdout. * Messages are newline-delimited JSON (one message per line). */ class StdioTransport implements McpTransport { onMessage: (data: string) => void = () => {}; constructor( private readonly readable: NodeJS.ReadableStream, private readonly writable: NodeJS.WritableStream, ) {} async connect(): Promise { const rl = createInterface({ input: this.readable }); rl.on("line", (line) => { if (line.trim()) this.onMessage(line); }); } async send(message: string): Promise { (this.writable as NodeJS.WritableStream & { write(s: string): void }).write(message + "\n"); } async close(): Promise { (this.writable as NodeJS.WritableStream & { end(): void }).end(); } } // --------------------------------------------------------------------------- // McpClient // --------------------------------------------------------------------------- /** Error thrown when the MCP child process exits before the handshake completes. */ export class McpStartupError extends Error { constructor( message: string, public readonly exitCode: number | null, public readonly stderr: string, ) { super(message); this.name = "McpStartupError"; } } export class McpClient { private proc: ChildProcessWithoutNullStreams; private client: McpClientBase; private ready: Promise; private closed = false; private closeListeners: Array<() => void> = []; private stderrChunks: Buffer[] = []; constructor(command: string, args: string[], cwd: string, env?: Record) { const { child, whenSpawnError } = spawnSafe(command, args, { cwd, env: { ...process.env, ...env }, stdio: ["pipe", "pipe", "pipe"], }); this.proc = child; this.proc.stderr.on("data", (chunk: Buffer) => { this.stderrChunks.push(chunk); }); const transport = new StdioTransport(this.proc.stdout, this.proc.stdin); this.client = new McpClientBase(transport, { clientInfo: { name: "pi-quarkus-mcp", version: "1.0.0" }, protocolVersion: "2025-03-26", capabilities: { roots: { listChanged: false } }, }); // Reject the ready promise with a rich error when the process exits // unexpectedly during startup, before the handshake completes. const whenProcessClose = new Promise((_resolve, reject) => { this.proc.on("close", (code) => { this.closed = true; this.client.close().catch(() => {}); for (const cb of this.closeListeners) cb(); const stderr = this.stderrChunks.map((b) => b.toString()).join("").trim(); const codeStr = code !== null ? `exit code ${code}` : "no exit code"; const summary = `quarkus-agent-mcp exited unexpectedly (${codeStr})`; reject(new McpStartupError(summary, code, stderr)); }); }); this.ready = Promise.race([this.client.connect(), whenSpawnError, whenProcessClose]); } addCloseListener(cb: () => void): void { this.closeListeners.push(cb); } get tools(): McpTool[] { return this.client.tools; } /** Wait for the MCP handshake and tool discovery to complete. */ async waitReady(): Promise { await this.ready; } /** Call a tool by name with the given arguments. */ async callTool(name: string, args: Record, signal?: AbortSignal): Promise { await this.ready; return this.client.callTool(name, args, undefined, signal); } /** Gracefully shut down the MCP client. */ async close(): Promise { if (this.closed) return; try { await this.client.request("shutdown", {}).catch(() => {}); } catch { // ignore } await this.client.close(); this.proc.kill(); this.closed = true; } }