Skip to content

Commit e147d7f

Browse files
committed
Fix MCP connection errors, slow stepping, and stale debug state
Three reliability/perf fixes to the extension: - Server: run StreamableHTTPServerTransport in stateful session mode so GET /mcp opens a real server->client SSE stream. Register GET/DELETE /mcp at startup (previously nested in the POST error handler, so GET returned a bare 404). Cursor's client treated the failed SSE open as a fatal transport error and tombstoned the connection as "errored" even though POST tool calls worked. - Stepping: replace the 1s blind-poll waitForStateChange with an event-driven wait on vscode.debug.onDidChangeActiveStackItem / onDidTerminateDebugSession, cutting per-step latency from ~1s to ms. - State: derive current file/line from the DAP top stack frame instead of scraping the active editor, fixing stale "paused at line N" reports.
1 parent 9126ba7 commit e147d7f

3 files changed

Lines changed: 234 additions & 104 deletions

File tree

src/debugMCPServer.ts

Lines changed: 102 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import * as vscode from 'vscode';
44
import { z } from 'zod';
55
import * as http from 'http';
6+
import { randomUUID } from 'node:crypto';
67
import {
78
DebuggingExecutor,
89
ConfigurationManager,
@@ -12,6 +13,7 @@ import {
1213
import { logger } from './utils/logger';
1314
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1415
import { 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 =>

src/debuggingExecutor.ts

Lines changed: 68 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -335,36 +335,18 @@ export class DebuggingExecutor implements IDebuggingExecutor {
335335
const activeStackItem = vscode.debug.activeStackItem;
336336
if (activeStackItem && 'frameId' in activeStackItem) {
337337
state.updateContext(activeStackItem.frameId, activeStackItem.threadId);
338-
339-
// Extract frame name from stack frame
340-
await this.extractFrameName(activeSession, activeStackItem.frameId, state);
341-
342-
// Get the active editor
343-
const activeEditor = vscode.window.activeTextEditor;
344-
if (activeEditor) {
345-
const fileName = activeEditor.document.fileName.split(/[/\\]/).pop() || '';
346-
const currentLine = activeEditor.selection.active.line + 1; // 1-based line number
347-
const currentLineContent = activeEditor.document.lineAt(activeEditor.selection.active.line).text.trim();
348-
349-
// Get next non-empty lines
350-
const nextLines = [];
351-
let lineOffset = 1;
352-
while (nextLines.length < numNextLines &&
353-
activeEditor.selection.active.line + lineOffset < activeEditor.document.lineCount) {
354-
const lineText = activeEditor.document.lineAt(activeEditor.selection.active.line + lineOffset).text.trim();
355-
if (lineText.length > 0) {
356-
nextLines.push(lineText);
357-
}
358-
lineOffset++;
359-
}
360-
361-
state.updateLocation(
362-
activeEditor.document.fileName,
363-
fileName,
364-
currentLine,
365-
currentLineContent,
366-
nextLines
367-
);
338+
339+
// Pull the current location from the debug adapter's top stack
340+
// frame (via stackTrace) instead of scraping the active text
341+
// editor. VS Code updates the editor cursor/selection
342+
// asynchronously after a stop and only for the focused editor,
343+
// so reading it here was both racy (it lagged the actual stop)
344+
// and wrong when focus was elsewhere — the source of stale
345+
// "current line" reports. The DAP frame is ground truth.
346+
const topFrame = await this.extractFrameName(activeSession, activeStackItem.frameId, state);
347+
348+
if (topFrame?.path && typeof topFrame.line === 'number') {
349+
await this.populateLocationFromFrame(state, topFrame.path, topFrame.line, numNextLines);
368350
}
369351
}
370352
}
@@ -387,9 +369,17 @@ export class DebuggingExecutor implements IDebuggingExecutor {
387369
}
388370

389371
/**
390-
* Extract frame name and stack trace from the current debug session
372+
* Extract frame name and stack trace from the current debug session.
373+
*
374+
* Returns the top frame's source location ({ path, line, column }) so the
375+
* caller can report the authoritative current position without scraping the
376+
* editor. Returns undefined if no stack frame is available.
391377
*/
392-
private async extractFrameName(session: vscode.DebugSession, frameId: number, state: DebugState): Promise<void> {
378+
private async extractFrameName(
379+
session: vscode.DebugSession,
380+
frameId: number,
381+
state: DebugState
382+
): Promise<{ path?: string; line?: number; column?: number } | undefined> {
393383
try {
394384
const stackTraceResponse = await session.customRequest('stackTrace', {
395385
threadId: state.threadId,
@@ -411,13 +401,59 @@ export class DebuggingExecutor implements IDebuggingExecutor {
411401
}));
412402

413403
state.updateStackTrace(stackTrace);
404+
405+
// DAP line/column are 1-based (VS Code's default). Hand the raw
406+
// top-frame location back to the caller for location reporting.
407+
return {
408+
path: currentFrame.source?.path,
409+
line: currentFrame.line,
410+
column: currentFrame.column,
411+
};
414412
}
415413
} catch (error) {
416414
console.log('Unable to extract stack info:', error);
417415
// Set empty values on error
418416
state.updateFrameName(null);
419417
state.updateStackTrace([]);
420418
}
419+
return undefined;
420+
}
421+
422+
/**
423+
* Populate the DebugState location (file, current line + content, and the
424+
* next few non-empty lines) by reading the source document at the debugger's
425+
* current frame line. Uses the DAP-reported path/line rather than the active
426+
* editor, so it's accurate regardless of which editor (if any) has focus.
427+
*/
428+
private async populateLocationFromFrame(
429+
state: DebugState,
430+
filePath: string,
431+
line: number,
432+
numNextLines: number
433+
): Promise<void> {
434+
try {
435+
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath));
436+
const zeroBasedLine = Math.max(0, Math.min(line - 1, doc.lineCount - 1));
437+
const fileName = filePath.split(/[/\\]/).pop() || '';
438+
const currentLineContent = doc.lineAt(zeroBasedLine).text.trim();
439+
440+
// Collect the next non-empty lines for lookahead context.
441+
const nextLines: string[] = [];
442+
let lineOffset = 1;
443+
while (nextLines.length < numNextLines && zeroBasedLine + lineOffset < doc.lineCount) {
444+
const lineText = doc.lineAt(zeroBasedLine + lineOffset).text.trim();
445+
if (lineText.length > 0) {
446+
nextLines.push(lineText);
447+
}
448+
lineOffset++;
449+
}
450+
451+
state.updateLocation(filePath, fileName, line, currentLineContent, nextLines);
452+
} catch (error) {
453+
// Native/library frames or paths VS Code can't open won't resolve to
454+
// a document; degrade gracefully and leave location unset.
455+
console.log('Unable to read frame source document:', error);
456+
}
421457
}
422458

423459
/**

0 commit comments

Comments
 (0)