Files
oh-my-pi/packages/coding-agent/src/mcp/transports/stdio.ts
T
can1357 d1e16d2101 refactor: implemented $PWD > cwd to prevent macOS symlink resolution
- Replaced all direct `process.cwd()` calls with `getProjectDir()` utility function across 40+ files to centralize project directory resolution logic.
- Added `getProjectDir()` and `setProjectDir()` functions to `@oh-my-pi/pi-utils/dirs` module to provide abstracted project directory management.
- Made `SessionManager.list()` method asynchronous to support asynchronous session discovery operations.
- Updated default working directory resolution throughout codebase to use `getProjectDir()` instead of `process.cwd()` for improved project directory detection.
2026-02-13 23:40:02 +01:00

282 lines
6.7 KiB
TypeScript

/**
* MCP stdio transport.
*
* Implements JSON-RPC 2.0 over subprocess stdin/stdout.
* Messages are newline-delimited JSON.
*/
import { readJsonl } from "@oh-my-pi/pi-utils";
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { type Subprocess, spawn } from "bun";
import type { JsonRpcResponse, MCPRequestOptions, MCPStdioServerConfig, MCPTransport } from "../../mcp/types";
/** Generate unique request ID */
function generateId(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
/**
* 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;
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 JsonRpcResponse);
} 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: JsonRpcResponse): void {
// Check if it's a response (has id)
if ("id" in message && message.id !== null) {
const pending = this.#pendingRequests.get(message.id);
if (pending) {
this.#pendingRequests.delete(message.id);
if (message.error) {
pending.reject(new Error(`MCP error ${message.error.code}: ${message.error.message}`));
} else {
pending.resolve(message.result);
}
}
} else if ("method" in message) {
// It's a notification from server
const notification = message as { method: string; params?: unknown };
this.onNotification?.(notification.method, notification.params);
}
}
#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 = generateId();
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;
}