33import * as vscode from 'vscode' ;
44import { z } from 'zod' ;
55import * as http from 'http' ;
6+ import { randomUUID } from 'node:crypto' ;
67import {
78 DebuggingExecutor ,
89 ConfigurationManager ,
@@ -12,6 +13,7 @@ import {
1213import { logger } from './utils/logger' ;
1314import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' ;
1415import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' ;
16+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' ;
1517
1618/**
1719 * Main MCP server class that exposes debugging functionality as tools.
@@ -103,6 +105,10 @@ export class DebugMCPServer {
103105 private hosts : string [ ] ;
104106 private initialized : boolean = false ;
105107 private debuggingHandler : IDebuggingHandler ;
108+ // Active Streamable-HTTP transports keyed by MCP session id. The transport
109+ // is created on `initialize` and reused for that session's subsequent
110+ // POST (requests), GET (server->client SSE stream), and DELETE (teardown).
111+ private transports : Record < string , StreamableHTTPServerTransport > = { } ;
106112
107113 constructor ( port : number , timeoutInSeconds : number , host : string | string [ ] = [ '127.0.0.1' , '::1' ] ) {
108114 // Initialize the debugging components with dependency injection
@@ -116,11 +122,12 @@ export class DebugMCPServer {
116122 /**
117123 * Initialize the MCP server factory.
118124 *
119- * NOTE: We no longer hold a singleton McpServer here. The stateless
120- * StreamableHTTPServerTransport requires a fresh McpServer per request
125+ * NOTE: We no longer hold a singleton McpServer here. In stateful session
126+ * mode each `initialize` request gets its own transport + McpServer pair
121127 * (calling .connect() twice on the same server throws "Already connected
122- * to a transport"). The /mcp handler builds one on demand via
123- * createMcpServer().
128+ * to a transport"). The POST /mcp handler builds one per session via
129+ * createMcpServer() and keeps it in `this.transports` for the session's
130+ * lifetime.
124131 */
125132 async initialize ( ) {
126133 if ( this . initialized ) {
@@ -131,7 +138,7 @@ export class DebugMCPServer {
131138
132139 /**
133140 * Build a fresh McpServer with all tools registered.
134- * Called once per incoming MCP request.
141+ * Called once per session, when an `initialize` request opens it .
135142 */
136143 private createMcpServer ( ) : McpServer {
137144 const server = new McpServer ( {
@@ -383,25 +390,62 @@ export class DebugMCPServer {
383390 } ) ;
384391
385392 // Streamable HTTP endpoint — handles MCP protocol messages.
386- // A fresh McpServer + transport pair is built per request because
387- // StreamableHTTPServerTransport in stateless mode (sessionIdGenerator: undefined)
388- // owns its connection; reusing a single McpServer across requests
389- // throws "Already connected to a transport" on the second call.
393+ // A fresh McpServer + transport pair is built per session (on the
394+ // `initialize` request) and reused for that session's subsequent
395+ // requests; reusing one McpServer across sessions would throw
396+ // "Already connected to a transport" on the second connect().
397+ // POST /mcp — client→server JSON-RPC. An `initialize` request with no
398+ // session id creates a new session (transport + McpServer pair) and is
399+ // remembered by its generated session id; subsequent requests carrying
400+ // that `mcp-session-id` header reuse the same transport.
401+ //
402+ // NOTE: We run in *stateful* (session) mode rather than stateless.
403+ // Stateless mode (sessionIdGenerator: undefined) cannot serve the
404+ // server→client SSE stream that clients open via GET /mcp right after
405+ // initialize. Cursor's MCP client treats a failed stream open as a fatal
406+ // transport error and tombstones the connection after a few attempts,
407+ // leaving the server permanently flagged "errored" even though POST tool
408+ // calls still work. Session mode gives GET a real stream to attach to.
390409 app . post ( '/mcp' , async ( req : any , res : any ) => {
391- logger . info ( 'New MCP request received' ) ;
392-
393- const server = this . createMcpServer ( ) ;
394- const transport = new StreamableHTTPServerTransport ( {
395- sessionIdGenerator : undefined , // Stateless mode - no session management
396- } ) ;
397- res . on ( 'close' , ( ) => {
398- transport . close ( ) ;
399- server . close ( ) ;
400- logger . info ( 'MCP transport closed' ) ;
401- } ) ;
402-
403410 try {
404- await server . connect ( transport ) ;
411+ const sessionId = req . headers [ 'mcp-session-id' ] as string | undefined ;
412+ let transport : StreamableHTTPServerTransport ;
413+
414+ if ( sessionId && this . transports [ sessionId ] ) {
415+ // Reuse the transport for an established session.
416+ transport = this . transports [ sessionId ] ;
417+ } else if ( ! sessionId && isInitializeRequest ( req . body ) ) {
418+ // Brand-new session: build a transport + server and register it
419+ // once the SDK assigns a session id.
420+ transport = new StreamableHTTPServerTransport ( {
421+ sessionIdGenerator : ( ) => randomUUID ( ) ,
422+ onsessioninitialized : ( sid : string ) => {
423+ this . transports [ sid ] = transport ;
424+ logger . info ( `MCP session initialized: ${ sid } ` ) ;
425+ } ,
426+ } ) ;
427+ transport . onclose = ( ) => {
428+ const sid = transport . sessionId ;
429+ if ( sid && this . transports [ sid ] ) {
430+ delete this . transports [ sid ] ;
431+ logger . info ( `MCP session closed: ${ sid } ` ) ;
432+ }
433+ } ;
434+ const server = this . createMcpServer ( ) ;
435+ await server . connect ( transport ) ;
436+ } else {
437+ // No session id and not an initialize request — invalid.
438+ res . status ( 400 ) . json ( {
439+ jsonrpc : '2.0' ,
440+ error : {
441+ code : - 32000 ,
442+ message : 'Bad Request: no valid session ID provided'
443+ } ,
444+ id : null
445+ } ) ;
446+ return ;
447+ }
448+
405449 await transport . handleRequest ( req , res , req . body ) ;
406450 } catch ( error ) {
407451 logger . error ( 'Error while handling MCP request' , error ) ;
@@ -411,34 +455,37 @@ export class DebugMCPServer {
411455 error : {
412456 code : - 32603 ,
413457 message : 'Internal MCP server error'
414- }
415- } ) ;
416-
417- app . get ( '/mcp' , async ( _req : any , res : any ) => {
418- res . status ( 405 ) . json ( {
419- jsonrpc : '2.0' ,
420- error : {
421- code : - 32000 ,
422- message : 'Method not allowed. Use POST /mcp.'
423- } ,
424- id : null
425- } ) ;
426- } ) ;
427-
428- app . delete ( '/mcp' , async ( _req : any , res : any ) => {
429- res . status ( 405 ) . json ( {
430- jsonrpc : '2.0' ,
431- error : {
432- code : - 32000 ,
433- message : 'Method not allowed. Use POST /mcp.'
434- } ,
435- id : null
436- } ) ;
458+ } ,
459+ id : null
437460 } ) ;
438461 }
439462 }
440463 } ) ;
441464
465+ // GET /mcp opens the server→client SSE notification stream for an
466+ // existing session; DELETE /mcp terminates a session. Both require a
467+ // valid mcp-session-id and are delegated to that session's transport.
468+ // These MUST be registered at startup (a prior bug registered them
469+ // lazily inside the POST error handler, so GET /mcp returned a bare 404
470+ // under normal operation and clients reported the server as errored).
471+ const handleSessionRequest = async ( req : any , res : any ) => {
472+ const sessionId = req . headers [ 'mcp-session-id' ] as string | undefined ;
473+ if ( ! sessionId || ! this . transports [ sessionId ] ) {
474+ res . status ( 400 ) . json ( {
475+ jsonrpc : '2.0' ,
476+ error : {
477+ code : - 32000 ,
478+ message : 'Bad Request: invalid or missing session ID'
479+ } ,
480+ id : null
481+ } ) ;
482+ return ;
483+ }
484+ await this . transports [ sessionId ] . handleRequest ( req , res ) ;
485+ } ;
486+ app . get ( '/mcp' , handleSessionRequest ) ;
487+ app . delete ( '/mcp' , handleSessionRequest ) ;
488+
442489 // Legacy SSE endpoint for backward compatibility
443490 // Redirects to the new /mcp endpoint with appropriate headers
444491 app . get ( '/sse' , async ( req : any , res : any ) => {
@@ -484,6 +531,16 @@ export class DebugMCPServer {
484531 * Stop the MCP server
485532 */
486533 async stop ( ) {
534+ // Tear down any open MCP sessions (closes their SSE streams) first.
535+ for ( const sessionId of Object . keys ( this . transports ) ) {
536+ try {
537+ this . transports [ sessionId ] . close ( ) ;
538+ } catch ( error ) {
539+ logger . warn ( `Error closing MCP session ${ sessionId } ` , error ) ;
540+ }
541+ }
542+ this . transports = { } ;
543+
487544 // Close all HTTP servers
488545 if ( this . httpServers . length > 0 ) {
489546 await Promise . all ( this . httpServers . map ( server =>
0 commit comments