MCPcopy Create free account
hub / github.com/MoonshotAI/kimi-code / StdioMcpClient

Class StdioMcpClient

packages/agent-core/src/mcp/client-stdio.ts:34–198  ·  view source on GitHub ↗

Source from the content-addressed store, hash-verified

32 * child process.
33 */
34export 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

Callers

nothing calls this directly

Calls

no outgoing calls

Tested by

no test coverage detected