#!/usr/bin/env node /** * MCP spawn-tee — in-process stderr capture + lifecycle observability. * * Claude Code spawns each MCP server itself; the platform never holds a * ChildProcess handle. This shim sits between Claude Code and the real MCP * server: Claude Code runs `node `, and the shim runs the * real entry IN ITS OWN PROCESS via dynamic import() — one node runtime, not * two. Before importing, it replaces process.stderr.write with a tee so every * stderr byte the server writes is mirrored to the log sinks; stdin and stdout * (the JSON-RPC channel) are never touched. * * Claude Code CLI → shim (this file) = node running in-process * stdin/stdout : untouched (JSON-RPC channel) * stderr : process.stderr.write teed → raw sink + passthrough * * Destinations: * - `${LOG_DIR}/mcp--.log` — raw stderr + lifecycle lines. * The sole raw sink. Task 1721 retired the per-date twin: its date was * pinned at spawn, no reader ever parsed it, and a since-removed stderr * tee wrote the same file, so every line landed twice. * - `${LOG_DIR}/mcp--nosession.log` — the same, for a spawn with * no SESSION_ID (enumeration). One fixed file per server. * - `server.log` via the loopback log-ingest route — best-effort mirror of * the [mcp-helper] lifecycle lines. * * Lifecycle lines, correlation key `session= server=` — unchanged: * [mcp-helper] op=spawn ... pid= entry= * [mcp-helper] op=boot ... head= * [mcp-helper] op=exit ... code= signal= lifetimeMs= stderr-tail= * * Process model (Task 989): the shim IS the server's process. op=exit fires * from process.on("exit"), so it covers a normal exit, a non-zero exit, an * uncaught throw (code 1), and a catchable-signal exit (SIGTERM/SIGINT/SIGHUP, * whose handlers record the signal name). An external SIGKILL is uncatchable * and leaves no op=exit line — the per-session stderr tail already on disk and * Claude Code's own transport-drop are the evidence for that death mode. * * The shim never writes to fd 1 (stdout) — that is the JSON-RPC channel. */ import { appendFileSync, mkdirSync } from "node:fs"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; const SERVER_NAME = process.env.MCP_SPAWN_TEE_NAME ?? "unknown"; const LOG_DIR = process.env.LOG_DIR; const SESSION_ID = process.env.SESSION_ID; const PLATFORM_PORT = process.env.PLATFORM_PORT; const ENTRY = process.argv[2]; const SESSION_ID8 = SESSION_ID ? SESSION_ID.slice(0, 8) : "—"; const spawnStamp = Date.now(); // The one raw sink (Task 1721). A sessionless spawn (rc-daemon stamps // SESSION_ID='' — rc-daemon.ts:513 — while still stamping LOG_DIR) shares one // fixed file per server rather than a date-named one: the date was never read // by any reader and named a process lifetime, not a day. Interleaved spawns in // the nosession file are separated by the [mcp-helper] op=spawn pid= lines. const rawSinkPath = LOG_DIR ? resolve( LOG_DIR, SESSION_ID ? `mcp-${SERVER_NAME}-${SESSION_ID}.log` : `mcp-${SERVER_NAME}-nosession.log`, ) : undefined; // Rolling tail of entry stderr for op=exit. Capped so a chatty server cannot // grow the buffer without bound. const TAIL_CAP = 2048; let stderrTail = ""; let bootEmitted = false; let exitEmitted = false; let exitSignal: NodeJS.Signals | undefined; // The real stderr writer, captured before the tee replaces it. Lifecycle lines // and stderr passthrough both go through this so they are never re-teed into // the raw sink nor recursed back into the patched writer. const rawStderrWrite = process.stderr.write.bind(process.stderr); // LOG_DIR is created once, lazily, on the first successful append — not per // chunk. teeWrite runs on the server's own stderr hot path now, so a per-call // mkdirSync would be two syscalls per log line for nothing. let logDirReady = false; function appendSafe(path: string | undefined, data: string | Uint8Array): void { if (!path || !LOG_DIR) return; try { if (!logDirReady) { mkdirSync(LOG_DIR, { recursive: true }); logDirReady = true; } appendFileSync(path, data); } catch { /* unwritable destination — never mask primary output */ } } // Best-effort mirror of a lifecycle line to server.log via the loopback // log-ingest route. Fire-and-forget: the per-session file is authoritative, so // a dropped POST loses nothing. On the "exit" event the loop is stopping, so // the op=exit mirror may not flush — the per-session line, written sync, holds. function postToServerLog(suffix: string, level: "info" | "error"): void { if (!PLATFORM_PORT) return; try { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 500); t.unref?.(); // never let the abort timer keep the shim's event loop alive void fetch(`http://127.0.0.1:${PLATFORM_PORT}/api/admin/log-ingest`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ tag: "mcp-helper", level, line: suffix }), signal: ctrl.signal, }).then(() => clearTimeout(t)).catch(() => clearTimeout(t)); } catch { /* fetch threw synchronously — best-effort only */ } } // Emit one [mcp-helper] lifecycle line: authoritative per-session file (sync, // survives process.exit), the shim's own stderr via the RAW writer (journald / // Claude Code visibility, never re-teed), and a best-effort server.log mirror. // `suffix` is the line body after the tag and carries no newline (the // log-ingest route rejects newlines). function emitLifecycle(suffix: string, level: "info" | "error"): void { const line = `[mcp-helper] ${suffix}\n`; appendSafe(rawSinkPath, line); try { rawStderrWrite(line); } catch { /* stderr closed */ } postToServerLog(suffix, level); } if (!ENTRY) { emitLifecycle(`op=error session=${SESSION_ID8} server=${SERVER_NAME} reason="no entry given (argv[2] missing)"`, "error"); process.exit(2); } // Replace process.stderr.write with a tee: mirror every server stderr byte to // the raw sink, keep a rolling tail for op=exit, emit // op=boot on the first bytes, then pass the write through to the real stderr. const teeWrite = ((...args: unknown[]): boolean => { const chunk = args[0] as string | Uint8Array; appendSafe(rawSinkPath, chunk); // raw sink (Task 1721) const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); stderrTail = (stderrTail + text).slice(-TAIL_CAP); if (!bootEmitted) { bootEmitted = true; const head = text.split("\n")[0].slice(0, 200); emitLifecycle(`op=boot session=${SESSION_ID8} server=${SERVER_NAME} head=${JSON.stringify(head)}`, "info"); } return (rawStderrWrite as (...a: unknown[]) => boolean)(...args); // passthrough }) as typeof process.stderr.write; process.stderr.write = teeWrite; // Catchable-signal handling. The shim drives the exit ONLY when it is the sole // listener for the signal — i.e. the imported entry installed no handler of its // own. In that case it records the signal (so op=exit carries signal=) and // exits with 128+signum, mirroring the prior wrapper's exit-status convention. // // When the entry DID install a handler, the shim defers entirely and records // nothing: the entry's handler decides the exit code, and op=exit reflects that // exit verbatim. This matches the prior two-process model, where a child that // caught the signal and exited cleanly was observed as code=0 signal=— (a clean // self-exit), not as a signal death. Setting exitSignal here unconditionally // would mislabel every graceful signal-driven shutdown as a signal kill. function onSignal(sig: NodeJS.Signals, code: number): () => void { return () => { if (process.listenerCount(sig) === 1) { exitSignal = sig; process.exit(code); } }; } process.on("SIGTERM", onSignal("SIGTERM", 143)); process.on("SIGINT", onSignal("SIGINT", 130)); process.on("SIGHUP", onSignal("SIGHUP", 129)); // op=exit, emitted once from the process "exit" event. Sync-only — the loop is // stopping. When a catchable signal caused the exit, report code=— signal= // to match the prior wrapper's format; otherwise code= signal=—. process.on("exit", (code) => { if (exitEmitted) return; exitEmitted = true; const lifetimeMs = Date.now() - spawnStamp; const tail = stderrTail.slice(-200); const codeField = exitSignal ? "—" : String(code); const level: "info" | "error" = exitSignal || code !== 0 ? "error" : "info"; emitLifecycle( `op=exit session=${SESSION_ID8} server=${SERVER_NAME} pid=${process.pid} ` + `code=${codeField} signal=${exitSignal ?? "—"} lifetimeMs=${lifetimeMs} stderr-tail=${JSON.stringify(tail)}`, level, ); }); emitLifecycle(`op=spawn session=${SESSION_ID8} server=${SERVER_NAME} pid=${process.pid} entry=${ENTRY}`, "info"); // Run the real MCP server in THIS process. Dynamic import() (not top-level // await — this file compiles to CommonJS) loads the ESM entry; a load-time // failure mirrors the old child spawn-error path (op=error, exit 127). The // op=exit handler is suppressed on this path so op=error stays the sole record. import(pathToFileURL(ENTRY).href).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); exitEmitted = true; emitLifecycle(`op=error session=${SESSION_ID8} server=${SERVER_NAME} reason=${JSON.stringify(`import error: ${msg}`)}`, "error"); process.exit(127); });