| 32 | * child process. |
| 33 | */ |
| 34 | export class StdioMcpClient implements MCPClient { |
| 35 | private readonly client: Client; |
| 36 | private readonly transport: StdioClientTransport; |
| 37 | private readonly toolCallTimeoutMs?: number; |
| 38 | private readonly stderrBuffer = new BoundedTail(STDERR_BUFFER_CAPACITY); |
| 39 | private started = false; |
| 40 | private closed = false; |
| 41 | // Flips to true only after `client.connect()` resolves AND the caller has |
| 42 | // not torn things down mid-startup. The `onclose` hook uses this to |
| 43 | // distinguish "transport died after the handshake" (→ unexpected close) |
| 44 | // from "transport died during the handshake" (→ `connect()` throws; the |
| 45 | // manager surfaces the failure via `formatStartupError`). |
| 46 | private ready = false; |
| 47 | private hooksInstalled = false; |
| 48 | private unexpectedCloseListener: UnexpectedCloseListener | undefined; |
| 49 | private lastTransportError: Error | undefined; |
| 50 | // Buffered when the transport closes before a listener is installed (e.g. |
| 51 | // a server that exits seconds after answering `tools/list`). Replayed when |
| 52 | // `onUnexpectedClose` registers so the close is never silently dropped. |
| 53 | private pendingUnexpectedClose: UnexpectedCloseReason | undefined; |
| 54 | |
| 55 | /** Capacity (in characters) of the stderr tail captured for diagnostics. */ |
| 56 | static readonly stderrBufferCapacity = STDERR_BUFFER_CAPACITY; |
| 57 | |
| 58 | constructor(config: McpServerStdioConfig, options: StdioMcpClientOptions = {}) { |
| 59 | if (config.executor !== undefined && config.executor !== 'local') { |
| 60 | throw new KimiError(ErrorCodes.NOT_IMPLEMENTED, `MCP stdio executor '${config.executor}' is not yet implemented`); |
| 61 | } |
| 62 | this.transport = new StdioClientTransport({ |
| 63 | command: config.command, |
| 64 | args: config.args, |
| 65 | env: mergeStdioEnv(config.env), |
| 66 | cwd: resolveStdioCwd(config.cwd, options.defaultCwd), |
| 67 | stderr: 'pipe', |
| 68 | }); |
| 69 | // `stderr: 'pipe'` means we MUST drain the stream — otherwise the child |
| 70 | // can block on a full pipe. We also keep the last few KB around so the |
| 71 | // connection manager can attach it to user-facing failure messages |
| 72 | // (`Timed out after 30000ms` on its own tells the user nothing). |
| 73 | this.transport.stderr?.on('data', (chunk: Buffer | string) => { |
| 74 | this.stderrBuffer.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8')); |
| 75 | }); |
| 76 | this.client = new Client({ |
| 77 | name: options.clientName ?? KIMI_MCP_CLIENT_NAME, |
| 78 | version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION, |
| 79 | }); |
| 80 | this.toolCallTimeoutMs = options.toolCallTimeoutMs; |
| 81 | } |
| 82 | |
| 83 | async connect(): Promise<void> { |
| 84 | if (this.closed) { |
| 85 | throw new Error('MCP stdio client is closed'); |
| 86 | } |
| 87 | if (this.started) return; |
| 88 | this.started = true; |
| 89 | // Install transport hooks BEFORE the SDK handshake so we never lose an |
| 90 | // onclose that fires between handshake completion and our wiring. The |
| 91 | // hooks themselves gate on `this.ready`, so a close that happens DURING |
nothing calls this directly
no outgoing calls
no test coverage detected