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
69type JsonRpcRequest = {
710 jsonrpc : "2.0" ;
@@ -96,31 +99,27 @@ type ReadResourceResult = {
9699
97100export 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+ */
106110export 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 ( / \. c m d $ / , "" ) ;
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