Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@
"type": "number",
"default": 3001,
"description": "Port number for the DebugMCP server"
},
"debugmcp.defaultConfigurationName": {
"type": "string",
"default": "",
"description": "Name of the launch.json configuration to use by default. When set, skips the configuration picker. Leave empty to prompt each time."
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/debugMCPServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ export class DebugMCPServer {
private debuggingHandler: IDebuggingHandler;
private transports: Map<string, StreamableHTTPServerTransport> = new Map();

constructor(port: number, timeoutInSeconds: number) {
constructor(port: number, timeoutInSeconds: number, defaultConfigurationName?: string) {
// Initialize the debugging components with dependency injection
const executor = new DebuggingExecutor();
const configManager = new ConfigurationManager();
this.debuggingHandler = new DebuggingHandler(executor, configManager, timeoutInSeconds);
this.debuggingHandler = new DebuggingHandler(executor, configManager, timeoutInSeconds, defaultConfigurationName);
this.port = port;
}

Expand Down
117 changes: 95 additions & 22 deletions src/debuggingExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface IDebuggingExecutor {
clearAllBreakpoints(): void;
hasActiveSession(): Promise<boolean>;
getActiveSession(): vscode.DebugSession | undefined;
resolveActiveFrameId(): Promise<number | null>;
}

/**
Expand All @@ -38,7 +39,7 @@ export class DebuggingExecutor implements IDebuggingExecutor {
config: vscode.DebugConfiguration
): Promise<boolean> {
try {
if (config.type === 'coreclr') {
if (config.type === 'coreclr' && config.request !== 'attach') {
// Open the specific test file instead of the workspace folder
const testFileUri = vscode.Uri.file(config.program);
await vscode.commands.executeCommand('vscode.open', testFileUri);
Expand Down Expand Up @@ -168,12 +169,13 @@ export class DebuggingExecutor implements IDebuggingExecutor {
state.sessionActive = true;
state.updateConfigurationName(activeSession.configuration.name ?? null);

const activeStackItem = vscode.debug.activeStackItem;
if (activeStackItem && 'frameId' in activeStackItem) {
state.updateContext(activeStackItem.frameId, activeStackItem.threadId);

// Resolve frame context — handles DebugStackFrame, DebugThread, and undefined activeStackItem
const frameContext = await this.resolveFrameContext(activeSession);
if (frameContext) {
state.updateContext(frameContext.frameId, frameContext.threadId);

// Extract frame name from stack frame
await this.extractFrameName(activeSession, activeStackItem.frameId, state);
await this.extractFrameName(activeSession, frameContext.frameId, state);

// Get the active editor
const activeEditor = vscode.window.activeTextEditor;
Expand Down Expand Up @@ -341,27 +343,98 @@ export class DebuggingExecutor implements IDebuggingExecutor {
}

/**
* Check if there's an active debug session that is ready for debugging operations
* Resolve the active frame context.
* Tries three strategies in order:
* 1. DebugStackFrame from activeStackItem (has frameId directly)
* 2. DebugThread from activeStackItem (resolve top frame via DAP stackTrace)
* 3. DAP threads request fallback (when activeStackItem is undefined)
* Returns null if no paused frame can be found.
*/
public async hasActiveSession(): Promise<boolean> {
// Quick check first - no session at all
if (!vscode.debug.activeDebugSession) {
return false;
private async resolveFrameContext(session: vscode.DebugSession): Promise<{ frameId: number; threadId: number } | null> {
const activeStackItem = vscode.debug.activeStackItem;

// Strategy 1: DebugStackFrame — frameId is directly available
if (activeStackItem && 'frameId' in activeStackItem) {
return { frameId: activeStackItem.frameId, threadId: activeStackItem.threadId };
}

// Strategy 2: DebugThread — resolve top frame via DAP stackTrace
if (activeStackItem && 'threadId' in activeStackItem) {
const frame = await this.getTopFrameForThread(session, activeStackItem.threadId);
if (frame) {
return frame;
}
}

// Strategy 3: activeStackItem is undefined — query DAP for threads directly
// and find the first thread that has a stack (i.e., is paused).
return await this.resolveFrameFromDAPThreads(session);
}

/**
* Get the top stack frame for a specific thread via DAP stackTrace request.
* Returns null if the thread is running (not paused).
*/
private async getTopFrameForThread(session: vscode.DebugSession, threadId: number): Promise<{ frameId: number; threadId: number } | null> {
try {
// Get the current debug state and check if it has location information
// This is the most reliable way to determine if the debugger is truly ready
const debugState = await this.getCurrentDebugState();

// A session is ready when it has location info (file name and line number)
// This means the debugger has attached and we can see where we are in the code
return debugState.sessionActive && debugState.hasLocationInfo();
} catch (error) {
// Any error means session isn't ready (e.g., Python still initializing)
console.log('Session readiness check failed:', error);
return false;
const response = await session.customRequest('stackTrace', {
threadId,
startFrame: 0,
levels: 1
});
if (response?.stackFrames?.length > 0) {
return { frameId: response.stackFrames[0].id, threadId };
}
} catch {
// Thread is running or stackTrace not supported
}
return null;
}

/**
* Fallback: query DAP for all threads and find the first one with a valid stack frame.
* Used when activeStackItem is undefined (VS Code hasn't selected a thread).
*/
private async resolveFrameFromDAPThreads(session: vscode.DebugSession): Promise<{ frameId: number; threadId: number } | null> {
try {
const threadsResponse = await session.customRequest('threads');
if (!threadsResponse?.threads?.length) {
return null;
}

for (const thread of threadsResponse.threads) {
const frame = await this.getTopFrameForThread(session, thread.id);
if (frame) {
return frame;
}
}
} catch {
// threads request not supported or session not ready
}
return null;
}

/**
* Resolve the active frame ID, handling DebugStackFrame, DebugThread, and
* undefined activeStackItem. Returns null if no paused frame is available.
*/
public async resolveActiveFrameId(): Promise<number | null> {
const session = vscode.debug.activeDebugSession;
if (!session) {
return null;
}
const context = await this.resolveFrameContext(session);
return context?.frameId ?? null;
}

/**
* Check if there's an active debug session.
* This only checks session existence (debug adapter is attached), NOT whether
* execution is paused at a frame. Use resolveActiveFrameId() to check for a
* paused frame when needed (e.g., before variable inspection or expression eval).
*/
public async hasActiveSession(): Promise<boolean> {
return !!vscode.debug.activeDebugSession;
}

/**
Expand Down
21 changes: 13 additions & 8 deletions src/debuggingHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,16 @@ export class DebuggingHandler implements IDebuggingHandler {
private readonly numNextLines: number = 3;
private readonly executionDelay: number = 300; // ms to wait for debugger updates
private readonly timeoutInSeconds: number;
private readonly defaultConfigurationName?: string;

constructor(
private readonly executor: IDebuggingExecutor,
private readonly configManager: IDebugConfigurationManager,
timeoutInSeconds: number
timeoutInSeconds: number,
defaultConfigurationName?: string
) {
this.timeoutInSeconds = timeoutInSeconds;
this.defaultConfigurationName = defaultConfigurationName;
}

/**
Expand All @@ -53,7 +56,9 @@ export class DebuggingHandler implements IDebuggingHandler {
const { fileFullPath, workingDirectory, testName, configurationName } = args;

try {
let selectedConfigName = configurationName ?? await this.configManager.promptForConfiguration(workingDirectory);
let selectedConfigName = configurationName
?? this.defaultConfigurationName
?? await this.configManager.promptForConfiguration(workingDirectory);

// Get debug configuration from launch.json or create default
const debugConfig = await this.configManager.getDebugConfig(
Expand Down Expand Up @@ -343,12 +348,12 @@ export class DebuggingHandler implements IDebuggingHandler {
throw new Error('Debug session is not ready. Start debugging first and ensure execution is paused.');
}

const activeStackItem = vscode.debug.activeStackItem;
if (!activeStackItem || !('frameId' in activeStackItem)) {
const frameId = await this.executor.resolveActiveFrameId();
if (frameId === null) {
throw new Error('No active stack frame. Make sure execution is paused at a breakpoint.');
}

const variablesData = await this.executor.getVariables(activeStackItem.frameId, scope);
const variablesData = await this.executor.getVariables(frameId, scope);

if (!variablesData.scopes || variablesData.scopes.length === 0) {
return 'No variable scopes available at current execution point.';
Expand Down Expand Up @@ -393,12 +398,12 @@ export class DebuggingHandler implements IDebuggingHandler {
throw new Error('Debug session is not ready. Start debugging first and ensure execution is paused.');
}

const activeStackItem = vscode.debug.activeStackItem;
if (!activeStackItem || !('frameId' in activeStackItem)) {
const frameId = await this.executor.resolveActiveFrameId();
if (frameId === null) {
throw new Error('No active stack frame. Make sure execution is paused at a breakpoint.');
}

const response = await this.executor.evaluateExpression(expression, activeStackItem.frameId);
const response = await this.executor.evaluateExpression(expression, frameId);

if (response && response.result !== undefined) {
let resultText = `Expression: ${expression}\n`;
Expand Down
6 changes: 5 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ export async function activate(context: vscode.ExtensionContext) {
const config = vscode.workspace.getConfiguration('debugmcp');
const timeoutInSeconds = config.get<number>('timeoutInSeconds', 180);
const serverPort = config.get<number>('serverPort', 3001);
const defaultConfigurationName = config.get<string>('defaultConfigurationName', '') || undefined;

logger.info(`Using timeoutInSeconds: ${timeoutInSeconds} seconds`);
logger.info(`Using serverPort: ${serverPort}`);
if (defaultConfigurationName) {
logger.info(`Using default configuration: ${defaultConfigurationName}`);
}

// Initialize Agent Configuration Manager
agentConfigManager = new AgentConfigurationManager(context, timeoutInSeconds, serverPort);
Expand All @@ -35,7 +39,7 @@ export async function activate(context: vscode.ExtensionContext) {
try {
logger.info('Starting MCP server initialization...');

mcpServer = new DebugMCPServer(serverPort, timeoutInSeconds);
mcpServer = new DebugMCPServer(serverPort, timeoutInSeconds, defaultConfigurationName);
await mcpServer.initialize();
await mcpServer.start();

Expand Down