Skip to content

Commit ed145e0

Browse files
committed
refactor(mcp): extract transport abstraction from McpClient
Separate the JSON-RPC protocol layer from the byte transport so the client can drive both local and remote MCP servers. - Add McpTransport interface (start/send/close/isConnected/decorateError). - Move the stdio subprocess + readline + stderr logic into StdioTransport. - McpClient now owns only request/response correlation, the handshake and notification dispatch, delegating I/O to an injected transport. - Add createMcpClient(name, config) factory that picks the transport from the server config. - Add optional type/url/headers fields to McpServerConfig for an upcoming remote transport (no behavior change yet). No behavior change for stdio servers; existing MCP tests pass unchanged.
1 parent 05de64c commit ed145e0

6 files changed

Lines changed: 324 additions & 218 deletions

File tree

Lines changed: 81 additions & 210 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { spawn, type ChildProcess } from "child_process";
2-
import { createInterface, type Interface } from "readline";
3-
import * as path from "path";
4-
import { killProcessTree } from "../common/process-tree";
1+
import type { McpServerConfig } from "../settings";
2+
import type { McpTransport } from "./mcp-transport";
3+
import { StdioTransport, createMcpSpawnSpec } from "./mcp-stdio-transport";
4+
5+
export { createMcpSpawnSpec };
6+
export type { McpSpawnSpec } from "./mcp-stdio-transport";
7+
export type { McpTransport } from "./mcp-transport";
58

69
type JsonRpcRequest = {
710
jsonrpc: "2.0";
@@ -96,31 +99,27 @@ type ReadResourceResult = {
9699

97100
export type McpNotificationHandler = (method: string, params?: Record<string, unknown>) => void;
98101

99-
export type McpSpawnSpec = {
100-
command: string;
101-
args: string[];
102-
shell: boolean;
103-
windowsHide?: boolean;
104-
};
102+
const PROTOCOL_VERSION = "2025-03-26";
103+
const SUPPORTED_PROTOCOL_VERSIONS = new Set([PROTOCOL_VERSION, "2024-11-05"]);
105104

105+
/**
106+
* MCP JSON-RPC client. Owns request/response correlation, the initialize
107+
* handshake and notification dispatch; the underlying byte transport (stdio
108+
* subprocess or remote HTTP) is injected via {@link McpTransport}.
109+
*/
106110
export class McpClient {
107-
private process: ChildProcess | null = null;
108-
private reader: Interface | null = null;
109111
private nextId = 1;
110112
private pendingRequests = new Map<
111113
number,
112114
{ resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }
113115
>();
114-
private stderrBuffer = "";
115-
private notificationHandler: McpNotificationHandler | null = null;
116-
private disconnectHandler: ((reason: string) => void) | null = null;
117-
private intentionallyDisconnected = false;
116+
private notificationHandler: McpNotificationHandler | null;
117+
private disconnectHandler: ((reason: string) => void) | null;
118+
private connected = false;
118119

119120
constructor(
120121
private readonly serverName: string,
121-
private readonly command: string,
122-
private readonly args: string[] = [],
123-
private readonly env?: Record<string, string>,
122+
private readonly transport: McpTransport,
124123
onNotification?: McpNotificationHandler,
125124
onDisconnect?: (reason: string) => void
126125
) {
@@ -129,93 +128,33 @@ export class McpClient {
129128
}
130129

131130
async connect(timeoutMs: number): Promise<void> {
132-
return new Promise((resolve, reject) => {
133-
this.intentionallyDisconnected = false;
134-
const childEnv = {
135-
...process.env,
136-
...this.env,
137-
};
138-
const args = this.withNpxYesArg(this.command, this.args);
139-
const spawnSpec = createMcpSpawnSpec(this.command, args);
140-
141-
this.process = spawn(spawnSpec.command, spawnSpec.args, {
142-
stdio: ["pipe", "pipe", "pipe"],
143-
env: childEnv,
144-
shell: spawnSpec.shell,
145-
windowsHide: spawnSpec.windowsHide,
146-
});
147-
148-
let resolved = false;
149-
const safeReject = (err: Error) => {
150-
if (!resolved) {
151-
resolved = true;
152-
reject(err);
153-
}
154-
};
155-
156-
this.process.on("error", (err) => {
157-
safeReject(
158-
this.withStderr(`Failed to start MCP server "${this.serverName}" (${this.command}): ${err.message}`)
159-
);
160-
});
161-
162-
this.process.on("close", (code) => {
163-
const reason = `MCP server "${this.serverName}" exited with code ${code}`;
164-
const error = this.withStderr(reason);
165-
for (const [, pending] of this.pendingRequests) {
166-
clearTimeout(pending.timer);
167-
pending.reject(error);
168-
}
169-
this.pendingRequests.clear();
170-
this.reader?.close();
171-
this.reader = null;
172-
this.process = null;
173-
if (!this.intentionallyDisconnected && this.disconnectHandler) {
174-
this.disconnectHandler(reason);
175-
}
176-
safeReject(error);
177-
});
131+
await this.transport.start({
132+
onMessage: (message) => this.handleSingleMessage(message),
133+
onClose: (reason) => this.handleTransportClose(reason),
134+
});
178135

179-
if (this.process.stderr) {
180-
this.process.stderr.on("data", (data: Buffer) => {
181-
this.appendStderr(data.toString("utf8"));
182-
});
183-
}
136+
// MCP protocol handshake
137+
const result = await this.sendRequest(
138+
"initialize",
139+
{
140+
protocolVersion: PROTOCOL_VERSION,
141+
capabilities: {},
142+
clientInfo: { name: "deepcode-cli", version: "0.1.0" },
143+
},
144+
timeoutMs
145+
);
146+
147+
// Validate protocol version from server response (per MCP spec §4.2.1.2)
148+
const serverVersion = (result as { protocolVersion?: string } | undefined)?.protocolVersion;
149+
if (serverVersion && !SUPPORTED_PROTOCOL_VERSIONS.has(serverVersion)) {
150+
throw new Error(
151+
`Unsupported MCP protocol version "${serverVersion}" from server "${this.serverName}". ` +
152+
`Client supports ${[...SUPPORTED_PROTOCOL_VERSIONS].join(" and ")}.`
153+
);
154+
}
184155

185-
this.reader = createInterface({ input: this.process.stdout! });
186-
this.reader.on("line", (line: string) => {
187-
this.handleLine(line);
188-
});
189-
190-
// Send initialize request (MCP protocol handshake)
191-
this.sendRequest(
192-
"initialize",
193-
{
194-
protocolVersion: "2025-03-26",
195-
capabilities: {},
196-
clientInfo: { name: "deepcode-cli", version: "0.1.0" },
197-
},
198-
timeoutMs
199-
)
200-
.then((result) => {
201-
// Validate protocol version from server response (per MCP spec §4.2.1.2)
202-
const initResult = result as { protocolVersion?: string } | undefined;
203-
const serverVersion = initResult?.protocolVersion;
204-
if (serverVersion && serverVersion !== "2025-03-26" && serverVersion !== "2024-11-05") {
205-
reject(
206-
new Error(
207-
`Unsupported MCP protocol version "${serverVersion}" from server "${this.serverName}". ` +
208-
`Client supports 2025-03-26 and 2024-11-05.`
209-
)
210-
);
211-
return;
212-
}
213-
// Send initialized notification
214-
this.sendNotification("notifications/initialized");
215-
resolve();
216-
})
217-
.catch(reject);
218-
});
156+
this.sendNotification("notifications/initialized");
157+
this.connected = true;
219158
}
220159

221160
async listTools(timeoutMs: number): Promise<McpToolDefinition[]> {
@@ -232,7 +171,7 @@ export class McpClient {
232171
}
233172
}
234173

235-
throw this.withStderr(`MCP server "${this.serverName}" returned too many tools/list pages`);
174+
throw this.transport.decorateError(`MCP server "${this.serverName}" returned too many tools/list pages`);
236175
}
237176

238177
async callTool(name: string, args: Record<string, unknown>, timeoutMs = 60_000): Promise<CallToolResult> {
@@ -253,7 +192,7 @@ export class McpClient {
253192
}
254193
}
255194

256-
throw this.withStderr(`MCP server "${this.serverName}" returned too many prompts/list pages`);
195+
throw this.transport.decorateError(`MCP server "${this.serverName}" returned too many prompts/list pages`);
257196
}
258197

259198
async getPrompt(name: string, args: Record<string, unknown>, timeoutMs = 30_000): Promise<GetPromptResult> {
@@ -274,31 +213,20 @@ export class McpClient {
274213
}
275214
}
276215

277-
throw this.withStderr(`MCP server "${this.serverName}" returned too many resources/list pages`);
216+
throw this.transport.decorateError(`MCP server "${this.serverName}" returned too many resources/list pages`);
278217
}
279218

280219
async readResource(uri: string, timeoutMs = 30_000): Promise<ReadResourceResult> {
281220
return (await this.sendRequest("resources/read", { uri }, timeoutMs)) as ReadResourceResult;
282221
}
283222

284223
disconnect(): void {
285-
this.intentionallyDisconnected = true;
286-
if (this.reader) {
287-
this.reader.close();
288-
this.reader = null;
289-
}
290-
if (this.process) {
291-
if (typeof this.process.pid === "number") {
292-
killProcessTree(this.process.pid, "SIGTERM", { killGroupOnNonWindows: false });
293-
} else {
294-
this.process.kill();
295-
}
296-
this.process = null;
297-
}
224+
this.connected = false;
225+
this.transport.close();
298226
}
299227

300228
isConnected(): boolean {
301-
return this.process !== null && this.process.exitCode === null;
229+
return this.connected && this.transport.isConnected();
302230
}
303231

304232
private sendRequest(method: string, params: Record<string, unknown>, timeoutMs = 30_000): Promise<unknown> {
@@ -313,52 +241,39 @@ export class McpClient {
313241
const timer = setTimeout(() => {
314242
this.pendingRequests.delete(id);
315243
reject(
316-
this.withStderr(
244+
this.transport.decorateError(
317245
`Timed out after ${timeoutMs}ms waiting for MCP server "${this.serverName}" to respond to ${method}`
318246
)
319247
);
320248
}, timeoutMs);
321249
this.pendingRequests.set(id, { resolve, reject, timer });
322-
this.writeLine(JSON.stringify(request));
250+
this.transport.send(request);
323251
});
324252
}
325253

326254
private sendNotification(method: string, params?: Record<string, unknown>): void {
327-
const notification = {
328-
jsonrpc: "2.0" as const,
255+
const notification: JsonRpcNotification = {
256+
jsonrpc: "2.0",
329257
method,
330258
params,
331259
};
332-
this.writeLine(JSON.stringify(notification));
260+
this.transport.send(notification);
333261
}
334262

335-
private writeLine(data: string): void {
336-
if (this.process?.stdin) {
337-
this.process.stdin.write(data + "\n");
263+
private handleTransportClose(reason: string): void {
264+
const error = this.transport.decorateError(reason);
265+
for (const [, pending] of this.pendingRequests) {
266+
clearTimeout(pending.timer);
267+
pending.reject(error);
338268
}
339-
}
340-
341-
private handleLine(line: string): void {
342-
try {
343-
const parsed: unknown = JSON.parse(line);
344-
345-
// Handle JSON-RPC batch (array of requests/notifications/responses)
346-
// Per MCP 2025-03-26 §4.1.1.3: implementations MUST support receiving batches.
347-
if (Array.isArray(parsed)) {
348-
for (const item of parsed) {
349-
if (item && typeof item === "object") {
350-
this.handleSingleMessage(item);
351-
}
352-
}
353-
return;
354-
}
355-
356-
// Handle single message
357-
if (parsed && typeof parsed === "object") {
358-
this.handleSingleMessage(parsed);
359-
}
360-
} catch {
361-
// Ignore unparseable lines
269+
this.pendingRequests.clear();
270+
271+
// Only surface a crash once the server was fully connected; failures during
272+
// the initial handshake are reported via connect() rejecting instead.
273+
const wasConnected = this.connected;
274+
this.connected = false;
275+
if (wasConnected) {
276+
this.disconnectHandler?.(reason);
362277
}
363278
}
364279

@@ -383,69 +298,25 @@ export class McpClient {
383298
this.pendingRequests.delete(message.id);
384299
clearTimeout(pending.timer);
385300
if (message.error) {
386-
pending.reject(this.withStderr(`MCP error: ${message.error.message}`));
301+
pending.reject(this.transport.decorateError(`MCP error: ${message.error.message}`));
387302
} else {
388303
pending.resolve(message.result);
389304
}
390305
}
391306
}
392-
393-
private withNpxYesArg(command: string, args: string[]): string[] {
394-
const executable = path
395-
.basename(command)
396-
.toLowerCase()
397-
.replace(/\.cmd$/, "");
398-
if (executable !== "npx") {
399-
return args;
400-
}
401-
if (args.includes("-y") || args.includes("--yes")) {
402-
return args;
403-
}
404-
return ["-y", ...args];
405-
}
406-
407-
private appendStderr(text: string): void {
408-
this.stderrBuffer = `${this.stderrBuffer}${text}`;
409-
if (this.stderrBuffer.length > 4000) {
410-
this.stderrBuffer = this.stderrBuffer.slice(-4000);
411-
}
412-
}
413-
414-
private withStderr(message: string): Error {
415-
const stderr = this.stderrBuffer.trim();
416-
return new Error(stderr ? `${message}. stderr: ${stderr}` : message);
417-
}
418-
}
419-
420-
export function createMcpSpawnSpec(
421-
command: string,
422-
args: string[],
423-
platform: NodeJS.Platform = process.platform
424-
): McpSpawnSpec {
425-
if (platform === "win32") {
426-
return {
427-
// On Windows, shell: true lets cmd.exe resolve the command via PATHEXT
428-
// (npx -> npx.cmd, etc.). Join command and args into a single string
429-
// with empty spawn args to avoid Node 24 DEP0190.
430-
// Only quote arguments that need protection from cmd.exe to prevent
431-
// double-wrapping by Node.js's own shell quoting.
432-
command: [command, ...args].map(quoteWindowsArgIfNeeded).join(" "),
433-
args: [],
434-
shell: true,
435-
windowsHide: true,
436-
};
437-
}
438-
439-
return {
440-
command,
441-
args,
442-
shell: false,
443-
};
444307
}
445308

446-
function quoteWindowsArgIfNeeded(arg: string): string {
447-
if (/[\s"&|<>^()]/.test(arg)) {
448-
return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/\\+$/g, "$&$&")}"`;
449-
}
450-
return arg;
309+
/**
310+
* Build an {@link McpClient} for a configured server, selecting the transport
311+
* from the config (remote Streamable HTTP when a `url`/`type: "http"` is given,
312+
* otherwise a local stdio subprocess).
313+
*/
314+
export function createMcpClient(
315+
serverName: string,
316+
config: McpServerConfig,
317+
onNotification?: McpNotificationHandler,
318+
onDisconnect?: (reason: string) => void
319+
): McpClient {
320+
const transport: McpTransport = new StdioTransport(serverName, config.command ?? "", config.args ?? [], config.env);
321+
return new McpClient(serverName, transport, onNotification, onDisconnect);
451322
}

0 commit comments

Comments
 (0)