Files
oh-my-pi/packages/coding-agent/src/mcp/transports/stdio.ts
T
21a86a693d feat(mcp): implement roots/list and server-to-client request handling (#474)
* feat(mcp): implement roots/list and server-to-client request handling

Add support for MCP server-to-client JSON-RPC requests across both
stdio and HTTP transports, enabling servers to query client capabilities
such as roots/list during initialization.

Transport layer (types.ts, stdio.ts, http.ts):
- Add onRequest callback to MCPTransport interface for server-initiated
  requests; add toJsonRpcError helper for error code propagation
- Classify incoming messages by checking method+id (request), id-only
  (response), method-only (notification); guard against id:null per
  JSON-RPC 2.0 spec
- StdioTransport: detect server requests in #handleMessage, respond via
  #sendResponse writing JSON-RPC response to subprocess stdin
- HttpTransport: detect server requests via #dispatchSSEMessage across
  all SSE streams (dedicated listener, POST response drain, notify
  piggybacking), respond via #sendServerResponse POST with proper
  Accept header and session ID
- Refactor startSSEListener to resolve once SSE GET connects (not when
  stream ends), enabling await before notifications/initialized; reset
  #sseConnection via .finally() for reconnection after transient failure
- #parseSSEResponse continues reading after capturing the primary
  response to drain piggybacked server requests/notifications; clears
  timeout after capture so drain phase is unbounded
- notify() reads text/event-stream response bodies for piggybacked
  messages; cancels non-SSE response bodies to release connections
- #sendServerResponse includes AbortSignal.timeout and cancels response
  body; fire-and-forget handlers wrapped in try/catch to prevent
  unhandled rejections

Client wiring (client.ts):
- Add onRequest to connectToServer options, wire to transport before
  initialization
- Add awaitable onInitialized hook in initializeConnection, called
  between initialize response (which sets session ID) and initialized
  notification, so SSE stream is open when server sends roots/list
- Pass only signal to transport.request (not full options object)
- Hoist transport ref to outer scope; close on timeout/abort to prevent
  orphaned transports when SSE GET hangs

Manager (manager.ts):
- Wire onRequest handler in connectServers for all MCP connections
- Handle roots/list by returning project CWD as file:// URI via
  pathToFileURL; return -32601 for unsupported methods

Tests (mcp-roots-list.test.ts):
- toJsonRpcError: code extraction, defaults, non-Error values
- Message classification spec tests: request/response/notification/
  unknown dispatch, id:null and id:0 edge cases
- Roots response shape: file:// URI generation, Windows paths, spaces

* fix(mcp): return SSE response immediately instead of blocking on stream drain

The #parseSSEResponse loop continued iterating the SSE stream after
capturing the response for the expected request ID. Since clearTimeout
was called after capture, a server that holds the SSE stream open for
follow-up events (permitted by Streamable HTTP) would block the
request() call indefinitely.

Return the result as soon as it's captured and drain remaining
messages in a detached background task via #readSSEStream, which
already handles dispatch and error swallowing.

* fix(mcp): handle batched JSON-RPC messages in both transports

JSON-RPC 2.0 section 6 allows sending an array of request/notification
objects as a batch. If a server sent a batch, the message classifier
in both transports would fail the 'method in message' check on the
array object and silently drop all contained messages.

Add an Array.isArray guard at the top of #handleMessage (stdio) and
#dispatchSSEMessage (http) that recurses into each element. Defensive
measure — no known MCP server sends batches today, but the guard is
cheap and correct per the JSON-RPC spec.

* fix(mcp): address second Codex review round

- http: break from SSE loop before starting background drain to avoid
  ReadableStream locked error (the for-await iterator still holds the
  reader when #drainSSEBackground was called inline)
- types: toJsonRpcError now accepts plain { code, message } objects,
  not just Error instances, so onRequest handlers can throw structured
  JSON-RPC errors without wrapping in Error
- test: relax Windows path name assertion to toBeTruthy since
  path.basename is platform-dependent for backslash paths; add tests
  for plain-object toJsonRpcError

* fix(mcp): address third Codex review round

- parseSSEResponse: flatten JSON-RPC batch arrays before checking for
  the expected response, so a server that batches the primary response
  with piggybacked requests/notifications in a single SSE event still
  has the response extracted correctly
- sendServerResponse: retry once on 401/403 via onAuthError, matching
  the auth-refresh logic in #executeRequest; prevents server-initiated
  request replies from failing after token expiry on long-lived SSE
  sessions

---------

Co-authored-by: Miroslav Drbal <miroslav.drbal@gendigital.com>
2026-03-18 23:02:22 +01:00

326 lines
7.9 KiB
TypeScript

/**
* MCP stdio transport.
*
* Implements JSON-RPC 2.0 over subprocess stdin/stdout.
* Messages are newline-delimited JSON.
*/
import { getProjectDir, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
import { type Subprocess, spawn } from "bun";
import type {
JsonRpcError,
JsonRpcMessage,
JsonRpcRequest,
JsonRpcResponse,
MCPRequestOptions,
MCPStdioServerConfig,
MCPTransport,
} from "../../mcp/types";
import { toJsonRpcError } from "../../mcp/types";
/**
* Stdio transport for MCP servers.
* Spawns a subprocess and communicates via stdin/stdout.
*/
export class StdioTransport implements MCPTransport {
#process: Subprocess<"pipe", "pipe", "pipe"> | null = null;
#pendingRequests = new Map<
string | number,
{
resolve: (value: unknown) => void;
reject: (error: Error) => void;
}
>();
#connected = false;
#readLoop: Promise<void> | null = null;
onClose?: () => void;
onError?: (error: Error) => void;
onNotification?: (method: string, params: unknown) => void;
onRequest?: (method: string, params: unknown) => Promise<unknown>;
constructor(private config: MCPStdioServerConfig) {}
get connected(): boolean {
return this.#connected;
}
/**
* Start the subprocess and begin reading.
*/
async connect(): Promise<void> {
if (this.#connected) return;
const args = this.config.args ?? [];
const env = {
...Bun.env,
...this.config.env,
};
this.#process = spawn({
cmd: [this.config.command, ...args],
cwd: this.config.cwd ?? getProjectDir(),
env,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
this.#connected = true;
// Start reading stdout
this.#readLoop = this.#startReadLoop();
// Log stderr for debugging
this.#startStderrLoop();
}
async #startReadLoop(): Promise<void> {
if (!this.#process?.stdout) return;
try {
for await (const line of readJsonl(this.#process.stdout)) {
if (!this.#connected) break;
try {
this.#handleMessage(line as JsonRpcMessage);
} catch {
// Skip malformed lines
}
}
} catch (error) {
if (this.#connected) {
this.onError?.(error instanceof Error ? error : new Error(String(error)));
}
} finally {
this.#handleClose();
}
}
async #startStderrLoop(): Promise<void> {
if (!this.#process?.stderr) return;
const reader = this.#process.stderr.getReader();
const decoder = new TextDecoder();
try {
while (this.#connected) {
const { done, value } = await reader.read();
if (done) break;
// Log stderr but don't treat as error - servers use it for logging
const text = decoder.decode(value, { stream: true });
if (text.trim()) {
// Could expose via onStderr callback if needed
// For now, silent - MCP spec says clients MAY capture/ignore
}
}
} catch {
// Ignore stderr read errors
} finally {
reader.releaseLock();
}
}
#handleMessage(message: JsonRpcMessage | JsonRpcMessage[]): void {
if (Array.isArray(message)) {
for (const m of message) this.#handleMessage(m);
return;
}
// Server-to-client request: has both method and id
if ("method" in message && "id" in message && message.id != null) {
void this.#handleServerRequest(message as JsonRpcRequest);
return;
}
// Response to our request: has id
if ("id" in message && message.id != null) {
const response = message as JsonRpcResponse;
const pending = this.#pendingRequests.get(response.id);
if (pending) {
this.#pendingRequests.delete(response.id);
if (response.error) {
pending.reject(new Error(`MCP error ${response.error.code}: ${response.error.message}`));
} else {
pending.resolve(response.result);
}
}
return;
}
// Notification: has method but no id
if ("method" in message) {
const notification = message as { method: string; params?: unknown };
this.onNotification?.(notification.method, notification.params);
}
}
async #handleServerRequest(request: JsonRpcRequest): Promise<void> {
try {
if (!this.onRequest) {
this.#sendResponse(request.id, undefined, { code: -32601, message: "Method not found" });
return;
}
const result = await this.onRequest(request.method, request.params);
this.#sendResponse(request.id, result);
} catch (error) {
try {
this.#sendResponse(request.id, undefined, toJsonRpcError(error));
} catch {
// Best-effort — process may have exited
}
}
}
#sendResponse(id: string | number, result?: unknown, error?: JsonRpcError): void {
if (!this.#connected || !this.#process?.stdin) return;
const response = error
? { jsonrpc: "2.0" as const, id, error }
: { jsonrpc: "2.0" as const, id, result: result ?? {} };
this.#process.stdin.write(`${JSON.stringify(response)}\n`);
this.#process.stdin.flush();
}
#handleClose(): void {
if (!this.#connected) return;
this.#connected = false;
// Reject all pending requests
for (const [, pending] of this.#pendingRequests) {
pending.reject(new Error("Transport closed"));
}
this.#pendingRequests.clear();
this.onClose?.();
}
async request<T = unknown>(
method: string,
params?: Record<string, unknown>,
options?: MCPRequestOptions,
): Promise<T> {
if (!this.#connected || !this.#process?.stdin) {
throw new Error("Transport not connected");
}
const id = Snowflake.next();
const request = {
jsonrpc: "2.0" as const,
id,
method,
params: params ?? {},
};
const timeout = this.config.timeout ?? 30000;
const signal = options?.signal;
if (signal?.aborted) {
const reason = signal.reason instanceof Error ? signal.reason : new Error("Aborted");
return Promise.reject(reason);
}
const { promise, resolve, reject } = Promise.withResolvers<T>();
let timer: NodeJS.Timeout | undefined;
let settled = false;
const cleanup = () => {
if (settled) return;
settled = true;
if (timer) {
clearTimeout(timer);
timer = undefined;
}
if (signal) {
signal.removeEventListener("abort", onAbort);
}
this.#pendingRequests.delete(id);
};
const onAbort = () => {
cleanup();
const reason = signal?.reason instanceof Error ? signal.reason : new Error("Aborted");
reject(reason);
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
this.#pendingRequests.set(id, {
resolve: (value: unknown) => {
cleanup();
resolve(value as T);
},
reject: (error: Error) => {
cleanup();
reject(error);
},
});
timer = setTimeout(() => {
cleanup();
reject(new Error(`Request timeout after ${timeout}ms`));
}, timeout);
const message = `${JSON.stringify(request)}\n`;
try {
// Bun's FileSink has write() method directly
this.#process.stdin.write(message);
this.#process.stdin.flush();
} catch (error: unknown) {
cleanup();
reject(error instanceof Error ? error : new Error(String(error)));
}
return promise;
}
async notify(method: string, params?: Record<string, unknown>): Promise<void> {
if (!this.#connected || !this.#process?.stdin) {
throw new Error("Transport not connected");
}
const notification = {
jsonrpc: "2.0" as const,
method,
params: params ?? {},
};
const message = `${JSON.stringify(notification)}\n`;
// Bun's FileSink has write() method directly
this.#process.stdin.write(message);
this.#process.stdin.flush();
}
async close(): Promise<void> {
if (!this.#connected) return;
this.#connected = false;
// Reject pending requests
for (const [, pending] of this.#pendingRequests) {
pending.reject(new Error("Transport closed"));
}
this.#pendingRequests.clear();
// Kill subprocess
if (this.#process) {
this.#process.kill();
this.#process = null;
}
// Wait for read loop to finish
if (this.#readLoop) {
await this.#readLoop.catch(() => {});
this.#readLoop = null;
}
this.onClose?.();
}
}
/**
* Create and connect a stdio transport.
*/
export async function createStdioTransport(config: MCPStdioServerConfig): Promise<StdioTransport> {
const transport = new StdioTransport(config);
await transport.connect();
return transport;
}