import { type ChildProcess, type IOType } from "node:child_process"; import process from "node:process"; import { type Stream } from "node:stream"; import { type Transport } from "@modelcontextprotocol/sdk/shared/transport"; import { type JSONRPCMessage, JSONRPCMessageSchema, } from "@modelcontextprotocol/sdk/types.js"; import { execa } from "execa"; /** * Buffers a continuous stdio stream into discrete JSON-RPC messages. */ export class ReadBuffer { private _buffer?: Buffer; append(chunk: Buffer): void { this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk; } readMessage(): JSONRPCMessage | null { if (!this._buffer) { return null; } const index = this._buffer.indexOf("\n"); if (index === -1) { return null; } const line = this._buffer.toString("utf8", 0, index).replace(/\r$/, ""); this._buffer = this._buffer.subarray(index + 1); return deserializeMessage(line); } clear(): void { this._buffer = undefined; } } export function deserializeMessage(line: string): JSONRPCMessage { return JSONRPCMessageSchema.parse(JSON.parse(line)); } export function serializeMessage(message: JSONRPCMessage): string { return `${JSON.stringify(message)}\n`; } export interface StdioServerParameters { /** * The executable to run to start the server. */ command: string; /** * Command line arguments to pass to the executable. */ args?: string[]; /** * The environment to use when spawning the process. * * If not specified, the result of getDefaultEnvironment() will be used. */ env?: Record; /** * How to handle stderr of the child process. This matches the semantics of Node's `child_process.spawn`. * * The default is "inherit", meaning messages to stderr will be printed to the parent process's stderr. */ stderr?: IOType | Stream | number; /** * The working directory to use when spawning the process. * * If not specified, the current working directory will be inherited. */ cwd?: string; } /** * Environment variables to inherit by default, if an environment is not explicitly given. */ export const DEFAULT_INHERITED_ENV_VARS = process.platform === "win32" ? [ "APPDATA", "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA", "PATH", "PROCESSOR_ARCHITECTURE", "SYSTEMDRIVE", "SYSTEMROOT", "TEMP", "USERNAME", "USERPROFILE", ] : /* list inspired by the default env inheritance of sudo */ ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]; /** * Returns a default environment object including only environment variables deemed safe to inherit. */ export function getDefaultEnvironment(): Record { const env: Record = {}; for (const key of DEFAULT_INHERITED_ENV_VARS) { const value = process.env[key]; if (value === undefined) { continue; } if (value.startsWith("()")) { // Skip functions, which are a security risk. continue; } env[key] = value; } return env; } /** * Client transport for stdio: this will connect to a server by spawning a process and communicating with it over stdin/stdout. * * This transport is only available in Node.js environments. */ export class StdioClientTransport implements Transport { private _process?: ChildProcess; private _abortController: AbortController = new AbortController(); private _readBuffer: ReadBuffer = new ReadBuffer(); private _serverParams: StdioServerParameters; onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage) => void; constructor(server: StdioServerParameters) { this._serverParams = server; } /** * Starts the server process and prepares to communicate with it. */ async start(): Promise { if (this._process) { throw new Error( "StdioClientTransport already started! If using Client class, note that connect() calls start() automatically.", ); } return new Promise((resolve, reject) => { this._process = execa( this._serverParams.command, this._serverParams.args ?? [], { env: this._serverParams.env ?? getDefaultEnvironment(), stderr: "inherit", shell: "/bin/sh", windowsHide: process.platform === "win32" && isElectron(), cwd: this._serverParams.cwd, cancelSignal: this._abortController.signal, stdin: "pipe", stdout: "pipe", }, ); this._process.on("error", (error) => { if (error.name === "AbortError") { // Expected when close() is called. this.onclose?.(); return; } reject(error); this.onerror?.(error); }); this._process.on("spawn", () => { resolve(); }); // eslint-disable-next-line @typescript-eslint/no-unused-vars this._process.on("close", (_code) => { this._process = undefined; this.onclose?.(); }); this._process.stdin?.on("error", (error) => { this.onerror?.(error); }); this._process.stdout?.on("data", (chunk) => { this._readBuffer.append(chunk); this.processReadBuffer(); }); this._process.stdout?.on("error", (error) => { this.onerror?.(error); }); }); } /** * The stderr stream of the child process, if `StdioServerParameters.stderr` was set to "pipe" or "overlapped". * * This is only available after the process has been started. */ get stderr(): Stream | null { return this._process?.stderr ?? null; } private processReadBuffer() { while (true) { try { const message = this._readBuffer.readMessage(); if (message === null) { break; } this.onmessage?.(message); } catch (error) { this.onerror?.(error as Error); } } } async close(): Promise { this._abortController.abort(); this._process = undefined; this._readBuffer.clear(); } send(message: JSONRPCMessage): Promise { return new Promise((resolve) => { if (!this._process?.stdin) { throw new Error("Not connected"); } const json = serializeMessage(message); if (this._process.stdin.write(json)) { resolve(); } else { this._process.stdin.once("drain", resolve); } }); } } function isElectron() { return "type" in process; }