UNPKG

pi-mcp-extension

Version:

Full-featured, lightweight MCP extension for the Pi coding agent: stdio, SSE & Streamable HTTP transports, OAuth 2.1 (PKCE + DCR), lazy/eager lifecycles — minimal dependencies, zero bloat.

668 lines (611 loc) • 27.3 kB
/** * Stdio child-process supervision and stderr line decoding. * * Provides pure StringDecoder-based line splitting across chunk boundaries, * StdioClientTransport instrumentation (early PID capture, memoized close, * stderr wiring), descendant process-tree discovery for wrappers such as * `npx`, `uvx`, or `sh -c`, and deterministic SIGTERM → bounded wait → SIGKILL * cleanup with injectable process/timer dependencies. */ import { execFile } from "node:child_process"; import { readFile, readdir } from "node:fs/promises"; import { StringDecoder } from "node:string_decoder"; import { promisify } from "node:util"; import type { ChildProcess } from "node:child_process"; import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; const execFileAsync = promisify(execFile); // ─── Line Splitting ─────────────────────────────────────────────────────────── export interface LineSplitter { write(chunk: Buffer): void; /** Emit the decoded remainder without finalising the decoder (teardown paths). */ flush(): void; /** Finalise the decoder (real EOF) and emit whatever is left. Idempotent. */ end(): void; } /** * Create a pure UTF-8 `StringDecoder`-based line splitter with an idempotent `end()`. * * `flush()` and `end()` differ on purpose: `flush()` emits what has already been decoded and * leaves an incomplete UTF-8 sequence buffered inside the decoder, while `end()` is terminal * and replaces such a sequence with U+FFFD. Teardown uses `flush()` because bytes can still * arrive after a forced close, and finalising the decoder first would turn a character split * across that boundary into replacement characters. */ export function createLineSplitter(onLine: (line: string) => void): LineSplitter { const decoder = new StringDecoder("utf8"); let remainder = ""; let flushed = false; const emitRemainder = (): void => { if (!remainder) return; const trailing = remainder; remainder = ""; onLine(trailing); }; return { write(chunk: Buffer): void { flushed = false; remainder += decoder.write(chunk); const lines = remainder.split("\n"); remainder = lines.pop() ?? ""; for (const rawLine of lines) { onLine(rawLine); } }, flush(): void { emitRemainder(); }, end(): void { if (flushed) return; flushed = true; remainder += decoder.end(); emitRemainder(); }, }; } // ─── Transport Instrumentation ──────────────────────────────────────────────── /** Grace window for an exited child's pipes to drain naturally before force-destroying them. */ const PIPE_DRAIN_GRACE_MS = 100; /** * How long a closed transport's stderr stream must stay quiet before its unterminated remainder * is emitted. * * WHY: nothing ends the SDK's stderr `PassThrough` after close, so the last line has to be * released by us; waiting for silence instead of flushing each chunk keeps a line whole when its * bytes arrive across several ticks, and still surfaces the last one promptly. */ export const POST_CLOSE_FLUSH_DELAY_MS = 100; interface StdioChildRecord { process: ChildProcess; closed: boolean; /** Flush the buffered partial stderr line before the pipe is destroyed. */ flushStderr(): void; } /** * Child processes of instrumented transports, keyed by transport. * * WHY: `StdioClientTransport.close()` clears `_process` synchronously, so the child * handle is gone by the time teardown needs its pipes and its stderr flush. A WeakMap * keeps both reachable without pinning a closed transport in memory. */ const childRecords = new WeakMap<object, StdioChildRecord>(); /** A transport whose child stderr can be wired and whose `close()` can be wrapped. */ interface StderrCarrier { stderr?: unknown; close(): Promise<void>; } /** * Split a stdio transport's stderr into lines and make them survive `close()`. * * Returns the provisional flush that teardown paths call before destroying the pipes, or * `null` when the transport has no stderr stream to read. * * WHY: `StdioClientTransport.close()` never ends its stderr `PassThrough` — it only waits * (bounded) for the child's `'close'` event — and a descendant that inherited the stderr write * end keeps that event from firing. A line written without a trailing newline would then stay * in the splitter's remainder forever. Flushing when close settles, and again after * `POST_CLOSE_FLUSH_DELAY_MS` of silence for any chunks that arrive afterwards (nothing will * emit `'end'` to do it), keeps the child's last words. * Both flushes are provisional: they must not finalise the decoder, because the bytes of a * character can be split across the close boundary; only the stream's own `'end'`/`'close'` * finalises it. */ export function instrumentStderr( transport: StderrCarrier, onLine: (line: string) => void, ): (() => void) | null { const stream = transport.stderr as NodeJS.EventEmitter | null | undefined; if (!stream || typeof stream.on !== "function") return null; const splitter = createLineSplitter(onLine); let closed = false; let idleFlush: NodeJS.Timeout | null = null; const cancelIdleFlush = (): void => { if (idleFlush) { clearTimeout(idleFlush); idleFlush = null; } }; const flush = (): void => { cancelIdleFlush(); splitter.flush(); }; // WHY: after close settles nothing emits 'end', so a remainder nobody else will flush has to be // released — but a single chunk boundary is not the end of a line. Waiting for the stream to go // quiet keeps a line whose chunks straddle several ticks whole, while still surfacing the last // unterminated one within POST_CLOSE_FLUSH_DELAY_MS. const scheduleIdleFlush = (): void => { cancelIdleFlush(); idleFlush = setTimeout(() => { idleFlush = null; splitter.flush(); }, POST_CLOSE_FLUSH_DELAY_MS); idleFlush.unref(); }; stream.on("data", (chunk: Buffer) => { splitter.write(chunk); if (closed) scheduleIdleFlush(); }); const onStreamEnd = (): void => { cancelIdleFlush(); splitter.end(); }; stream.on("end", onStreamEnd); stream.on("close", onStreamEnd); const origClose = transport.close.bind(transport); transport.close = (): Promise<void> => origClose().finally(() => { closed = true; flush(); }); return flush; } export interface InstrumentStdioTransportOptions { onPid(pid: number): void; onStderrLine(line: string): void; } /** * Wrap a `StdioClientTransport` to capture its child PID as soon as it spawns, * memoize `close()` across duplicate calls, and wire `stderr` through `instrumentStderr`. */ export function instrumentStdioTransport( transport: StdioClientTransport, { onPid, onStderrLine }: InstrumentStdioTransportOptions, ): void { // WHY: Capture child PID immediately when StdioClientTransport spawns the process // (before `initialize` is sent), so a timeout or cancellation during `initialize` // can still terminate the child process deterministically before retrying. const spawnedProcess = (): ChildProcess | undefined => (transport as unknown as { _process?: ChildProcess })._process; // Wire stderr first: `instrumentStderr` wraps `close()` with the flush, and the memoization // below has to sit on top of that wrapper. const flushStderr = instrumentStderr(transport, onStderrLine); const recordProcess = (proc: ChildProcess | undefined): void => { if (!proc?.pid) return; if (!childRecords.has(transport)) { const record: StdioChildRecord = { process: proc, closed: false, flushStderr: () => flushStderr?.(), }; proc.once?.("close", () => { record.closed = true; }); childRecords.set(transport, record); } onPid(proc.pid); }; const origStart = transport.start.bind(transport); transport.start = async () => { const startPromise = origStart(); recordProcess(spawnedProcess()); await startPromise; const proc = spawnedProcess(); if (proc?.pid) { recordProcess(proc); } else if (transport.pid) { onPid(transport.pid); } }; // WHY: Client.connect's catch block fires `void this.close()` without awaiting it, // which clears `this._process = undefined` synchronously on the first call. Memoize // transport.close() so our `await client.close()` awaits the actual close completion. let closePromise: Promise<void> | null = null; const origClose = transport.close.bind(transport); transport.close = () => { if (!closePromise) { closePromise = origClose(); } return closePromise; }; } /** * Wait briefly for an exited child process to drain its stdio pipes and emit `'close'` * naturally before `destroyStdioStreams` force-destroys the parent's pipe read ends. * * WHY: On Windows, `process.stderr` is an overlapped named pipe whose final bytes can * still be in flight in libuv when `TerminateProcess` completes. Letting `'close'` fire * naturally preserves trailing stderr output when no grandchild holds the pipe open, while * bounding the wait to `PIPE_DRAIN_GRACE_MS` when a surviving grandchild keeps it open. */ export async function waitForChildClose( transport: object | null | undefined, timeoutMs = PIPE_DRAIN_GRACE_MS, ): Promise<void> { if (!transport) return; const record = childRecords.get(transport); if (!record || record.closed || typeof record.process.once !== "function") return; await new Promise<void>((resolve) => { const timer = setTimeout(() => { record.process.removeListener?.("close", onClose); resolve(); }, timeoutMs); const onClose = (): void => { clearTimeout(timer); resolve(); }; record.process.once("close", onClose); }); } /** * Destroy the child's `stdout`/`stderr` pipes once the process tree is gone. * * WHY: A surviving grandchild (e.g. a server spawned by an `npx` wrapper) inherits the * wrapper's pipe write ends, so Node never emits `'close'` for the wrapper and * `StdioClientTransport.close()` burns its full 4000ms escalation budget waiting for the * `'close'` event. Destroying the parent's read ends lets `close()` resolve immediately * even when a descendant escaped the tree kill. `stdin` is left alone: it is our write * end and the SDK still ends it as part of its own close sequence. */ export function destroyStdioStreams(transport: object | null | undefined): void { if (!transport) return; const record = childRecords.get(transport); if (!record) return; childRecords.delete(transport); // WHY: Flush the buffered partial stderr line first. Destroying our read end keeps the // SDK's PassThrough from ever emitting 'end', so without this a forced teardown drops the // server's last words (the buffered remainder of a line with no trailing newline). record.flushStderr(); record.process.stdout?.destroy(); record.process.stderr?.destroy(); } // ─── Descendant Discovery ───────────────────────────────────────────────────── /** * Upper bound on tracked descendants. * * WHY: A wrapper that respawns children while we walk the tree could otherwise make * discovery unbounded; teardown only needs the tree as it stood when we snapshotted it. */ const MAX_DESCENDANTS = 256; /** Read the direct child PIDs of `pid` from Linux `/proc`. */ async function readProcChildren(pid: number): Promise<number[]> { // WHY: /proc/<pid>/task/<tid>/children lists each thread's direct children, so a // multithreaded wrapper is covered without scanning every process in /proc. const taskDir = `/proc/${pid}/task`; const tids = await readdir(taskDir); const children: number[] = []; for (const tid of tids) { const raw = await readChildrenFile(`${taskDir}/${tid}/children`); for (const token of raw.split(/\s+/)) { if (token) children.push(Number(token)); } } return children; } async function readChildrenFile(path: string): Promise<string> { try { return await readFile(path, "utf8"); } catch (err) { // WHY: A thread can exit between readdir() and readFile(), and a process that dies // mid-walk simply has no children left to report. if ((err as NodeJS.ErrnoException).code === "ENOENT") return ""; throw err; } } /** Timeout for a single `ps` process-table snapshot on non-Linux POSIX systems. */ const PS_TIMEOUT_MS = 1_000; /** * Read the full `ps` process table and index it as `parentPid → childPids`. * * WHY: macOS and other non-Linux POSIX systems have no `/proc`; `ps -A -o pid=,ppid=` * dumps the entire process table in one call so a single snapshot can serve the whole * tree walk without spawning `ps` once per node. */ async function readPsProcessTable(): Promise<Map<number, number[]>> { const { stdout } = await execFileAsync("ps", ["-A", "-o", "pid=,ppid="], { maxBuffer: 16 * 1024 * 1024, timeout: PS_TIMEOUT_MS, }); return indexProcessTable(stdout); } /** Index `ps -A -o pid=,ppid=` output as a parent PID → child PIDs map. */ export function indexProcessTable(stdout: string): Map<number, number[]> { const childrenOf = new Map<number, number[]>(); for (const line of stdout.split("\n")) { const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line); if (!match) continue; const childPid = Number(match[1]); const parentPid = Number(match[2]); const siblings = childrenOf.get(parentPid); if (siblings) { siblings.push(childPid); } else { childrenOf.set(parentPid, [childPid]); } } return childrenOf; } function createPlatformChildReader(): (pid: number) => Promise<number[]> { if (process.platform === "linux") return readProcChildren; if (process.platform === "win32") { // WHY: Windows has no `/proc` or `ps`, and the portable tree kill (`taskkill /T`) // must be driven by the caller's own signal semantics. Discovery reports nothing so // teardown falls back to killing the top-level PID plus destroying the pipes. return () => Promise.resolve([]); } let tablePromise: Promise<Map<number, number[]>> | null = null; return async (pid: number): Promise<number[]> => { tablePromise ??= readPsProcessTable(); const childrenOf = await tablePromise; return childrenOf.get(pid) ?? []; }; } /** * Snapshot the descendant PIDs of `pid`, deepest generation first. * * Returns `[]` when discovery is unavailable (Windows, containers without `ps` or * `/proc`), which leaves the caller's single-PID teardown intact. */ export async function listDescendantPids( pid: number, readChildren: (pid: number) => Promise<number[]> = createPlatformChildReader(), ): Promise<number[]> { try { const descendants = await walkDescendants(pid, readChildren); // Breadth-first order reversed: each generation is signalled before its parent, so a // wrapper cannot respawn or reap a child that is still being torn down. return descendants.reverse(); } catch { // WHY: Discovery is best-effort by design: the PID can exit between the liveness check // and the `/proc` walk, and minimal containers ship neither `ps` nor `/proc`. A tree we // cannot read must never fail the caller's shutdown path — teardown still kills the // root PID and releases the inherited pipes. return []; } } async function walkDescendants( pid: number, readChildren: (pid: number) => Promise<number[]>, ): Promise<number[]> { const descendants: number[] = []; const seen = new Set<number>([pid]); const pending = await readChildren(pid); while (pending.length > 0 && descendants.length < MAX_DESCENDANTS) { const next = pending.shift(); if (next === undefined || seen.has(next)) continue; seen.add(next); descendants.push(next); pending.push(...(await readChildren(next))); } return descendants; } // ─── Process Supervision ────────────────────────────────────────────────────── export interface StdioProcessDeps { kill(pid: number, signal?: NodeJS.Signals | number): void | boolean; sleep(ms: number): Promise<void>; now(): number; /** Snapshot descendant PIDs of `pid`, deepest first. Resolves `[]` when unavailable. */ listDescendantPids(pid: number): Promise<number[]>; } const defaultDeps: StdioProcessDeps = { kill: (pid, signal) => process.kill(pid, signal), sleep: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)), now: () => Date.now(), listDescendantPids: (pid) => listDescendantPids(pid), }; function resolveDeps(deps?: Partial<StdioProcessDeps>): StdioProcessDeps { if (!deps) return defaultDeps; return { kill: deps.kill ?? defaultDeps.kill, sleep: deps.sleep ?? defaultDeps.sleep, now: deps.now ?? defaultDeps.now, listDescendantPids: deps.listDescendantPids ?? defaultDeps.listDescendantPids, }; } /** What one `kill(2)` attempt reported about a PID. */ type PidSignalOutcome = /** The signal was delivered: the PID resolves to a process this caller may signal. */ | "signalled" /** ESRCH: nothing holds this PID any more. */ | "gone" /** EPERM or another platform refusal: the PID is not this caller's to signal. */ | "denied"; /** * POSIX liveness: a PID is alive while `kill(pid, 0)` fails with anything but ESRCH. * * WHY: EPERM means the PID is taken but belongs to a process this caller may not signal (a * recycled PID under another uid, a sandboxed child, PID 1). A caller that asks only whether the * PID is *occupied* must read that as occupied, not free; teardown asks the stricter * `isPidSignalable` instead. Auth-lock's staleness check needs this reading too and keeps its own * probe (`defaultIsPidAlive` in `src/auth-lock.ts`), so it does not call this one. */ export function isPidAlive(pid: number, deps?: Partial<Pick<StdioProcessDeps, "kill">>): boolean { return signalPid(pid, 0, resolveDeps(deps)) !== "gone"; } function signalPid( pid: number, signal: NodeJS.Signals | number, d: StdioProcessDeps, ): PidSignalOutcome { try { d.kill(pid, signal); return "signalled"; } catch (err) { if ((err as NodeJS.ErrnoException).code === "ESRCH") { return "gone"; } // WHY: EPERM and platform refusals mean this PID cannot be signalled (a recycled PID // or a sandboxed process). Teardown still has to signal the rest of the tree within // the bounded wait instead of rejecting the caller's shutdown path. return "denied"; } } /** * A PID this teardown can still act on: alive *and* signalable. * * WHY: a PID the caller cannot signal is not one a bounded wait can make progress on. `EPERM` * means no signal of ours reaches that PID — the child has exited and the PID was recycled to a * process under another uid, the child changed credentials (a wrapper that `setuid`s), or a * policy layer refuses the signal — so polling it re-reads the same `EPERM` every 25ms until * the timeout, and escalating to SIGKILL only buys a second refused signal. * * This answers "can we still reach it", never "is it our child": a PID recycled to another * process of the *same* uid stays signalable and is signalled, exactly as before this guard * existed. Identity would need the child's start time compared against `/proc/<pid>/stat`, and * the `ChildProcess` handle is gone by the time teardown runs. */ function isPidSignalable(pid: number, d: StdioProcessDeps): boolean { return signalPid(pid, 0, d) === "signalled"; } function allExited(pids: readonly number[], d: StdioProcessDeps): boolean { return pids.every((pid) => !isPidSignalable(pid, d)); } /** * Send `signal` to the PIDs that accept it, then wait only for those to exit. * * Resolves `true` when nothing that could still be running is left, which is what gates the * SIGKILL escalation. A PID that reported ESRCH is gone, and one that reported EPERM is not * ours to wait for — but it is not assumed dead either. */ async function signalAndWaitForExit( pids: readonly number[], signal: NodeJS.Signals, timeoutMs: number, d: StdioProcessDeps, ): Promise<boolean> { const attempts = pids.map((pid) => ({ pid, outcome: signalPid(pid, signal, d) })); const signalled = attempts.filter((a) => a.outcome === "signalled").map((a) => a.pid); const refused = attempts.filter((a) => a.outcome === "denied").map((a) => a.pid); // A window can only be ended by a signal that was actually delivered, so nothing is waited // for when nothing was. (The refused targets still decide the result below.) if (signalled.length > 0) { const deadline = d.now() + timeoutMs; while (d.now() < deadline && !allExited(signalled, d)) { await d.sleep(25); } } // WHY: a refused signal is not proof of a dead target. Permission is mediated per signal on // some systems — SELinux keeps separate `sigterm`/`sigkill` permissions, AppArmor matches a // signal set, and a seccomp filter can match the signal argument — so SIGTERM can be refused // while SIGKILL is allowed. A refused target that a liveness probe still finds signalable // therefore keeps the tree alive and sends the caller straight to the SIGKILL escalation, // without paying for a wait window this signal can never end; one that is no longer // signalable is out of reach (recycled to another uid, or the child changed credentials) and // counts as gone. ESRCH targets are never re-probed: that verdict is final, and re-probing a // recycled PID could sign it up for a SIGKILL it has nothing to do with. return allExited(signalled, d) && !refused.some((pid) => isPidSignalable(pid, d)); } /** * Escalate to SIGKILL across the surviving tree. * * WHY: The wrapper can spawn new children while it ignores SIGTERM, so the tree is * re-snapshotted; the original snapshot still covers descendants of a wrapper that * already exited (they were re-parented to init and are no longer discoverable). */ async function escalateToSigkill( rootPid: number, descendants: readonly number[], d: StdioProcessDeps, ): Promise<void> { const rescanned = await d.listDescendantPids(rootPid); const survivors = [...new Set([...rescanned, ...descendants])].filter((candidate) => isPidSignalable(candidate, d), ); const targets = [...survivors, rootPid].filter((candidate) => isPidSignalable(candidate, d)); if (targets.length === 0) return; await signalAndWaitForExit(targets, "SIGKILL", 200, d); } export async function ensurePidTerminated( pid: number | null | undefined, deps?: Partial<StdioProcessDeps>, ): Promise<void> { const d = resolveDeps(deps); if (!pid) return; const root = signalPid(pid, 0, d); // WHY: ESRCH is the only verdict that says the PID is free — nothing to snapshot, nothing to // signal. EPERM does not describe the tree: the root may be out of reach while descendants // are ours (a privileged wrapper that dropped to our uid for the real server), so they are // still signalled below. if (root === "gone") return; // WHY: Snapshot before signalling any PID: a wrapper that exits first re-parents its // children to init, after which they can no longer be found by parent PID. The snapshot runs // even for an out-of-reach root, because that root may still own signalable descendants. const descendants = await d.listDescendantPids(pid); const targets = descendants.filter((candidate) => isPidSignalable(candidate, d)); // WHY: a root nobody can signal is skipped as a target — no wait window can end it — but it // must not hide the descendants that a signal can still reach. if (root === "signalled") targets.push(pid); if (targets.length === 0) return; if (await signalAndWaitForExit(targets, "SIGTERM", 500, d)) return; await escalateToSigkill(pid, descendants, d); } // ─── Teardown ───────────────────────────────────────────────────────────────── /** * Narrow an unknown candidate to a positive integer OS PID (`> 0`). * * WHY: `process.kill(0, sig)` signals the caller's entire process group and `kill(-1, sig)` * signals every process of the current uid, so only positive integer PIDs are valid targets. */ function asPositivePid(pid: unknown): number | null { return typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? pid : null; } /** * The PID to tear down for a stdio transport, from the most to the least authoritative source. * * WHY: `server.childPid` is normally set at spawn time, but it is not the only place the PID * can come from — the instrumented record outlives `_process` (which * `StdioClientTransport.close()` clears synchronously), and a transport we never instrumented * still exposes `pid`. Resolving all of them in one place is what keeps a connect cleanup and a * shutdown from disagreeing about which process they own. */ export function resolveTransportPid( recordedPid: number | null | undefined, transport: object | null | undefined, ): number | null { const recorded = asPositivePid(recordedPid); if (!transport) return recorded; const record = childRecords.get(transport); // WHY: once the child we spawned has closed, every PID reachable from this transport belongs // to a process that has already exited — and a released PID can be recycled to an unrelated // process under the same uid, which teardown would then signal. That covers `recordedPid` // too: `server.childPid` is never cleared when the child exits on its own (crash → onclose → // _recover → _shutdown passes it here). Nothing that used to work is lost: a dead root ends // `ensurePidTerminated` at its ESRCH liveness probe before any descendant is walked. if (record?.closed) return null; const internal = (transport as { _process?: { pid?: unknown } })._process; const exposed = (transport as { pid?: unknown }).pid; return ( recorded ?? asPositivePid(record?.process.pid) ?? asPositivePid(internal?.pid) ?? asPositivePid(exposed) ); } /** * Terminate the stdio child process tree, then release the child's pipe handles. * * WHY: `client.close()` waits for the child's `'close'` event, which never fires while a * leaked grandchild still holds the inherited stdout/stderr write ends — the SDK then * burns its full 2 × 2,000ms escalation budget. Killing the tree first and destroying the * pipes afterwards keeps teardown bounded even if a descendant escaped the tree kill. */ export async function terminateStdioTree( pid: number | null, transport: object | null | undefined, deps?: Partial<StdioProcessDeps>, ): Promise<void> { try { await ensurePidTerminated(pid, deps); await waitForChildClose(transport); } finally { destroyStdioStreams(transport); } }