@debugmcp/mcp-debugger
Version:
Run-time step-through debugging for LLM agents.
568 lines (494 loc) • 24.6 kB
text/typescript
/**
* Debug operations for session management including starting, stepping,
* continuing, and breakpoint management.
*/
import { v4 as uuidv4 } from 'uuid';
import { Breakpoint, SessionState, SessionLifecycleState } from './models.js';
import { ManagedSession } from './session-store.js';
import { DebugProtocol } from '@vscode/debugprotocol';
import path from 'path';
import { fileURLToPath } from 'url';
import { ProxyConfig } from '../proxy/proxy-config.js';
import { ErrorMessages } from '../utils/error-messages.js';
import { findPythonExecutable } from '../utils/python-utils.js';
import { SessionManagerData } from './session-manager-data.js';
import { CustomLaunchRequestArguments, DebugResult } from './session-manager-core.js';
import { AdapterConfig } from '../adapters/debug-adapter-interface.js';
/**
* Debug operations functionality for session management
*/
export class SessionManagerOperations extends SessionManagerData {
protected async startProxyManager(
session: ManagedSession,
scriptPath: string,
scriptArgs?: string[],
dapLaunchArgs?: Partial<CustomLaunchRequestArguments>,
dryRunSpawn?: boolean
): Promise<void> {
const sessionId = session.id;
// Create session log directory
const sessionLogDir = path.join(this.logDirBase, sessionId, `run-${Date.now()}`);
this.logger.info(`[SessionManager] Ensuring session log directory: ${sessionLogDir}`);
try {
await this.fileSystem.ensureDir(sessionLogDir);
const dirExists = await this.fileSystem.pathExists(sessionLogDir);
if (!dirExists) {
throw new Error(`Log directory ${sessionLogDir} could not be created`);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`[SessionManager] Failed to create log directory:`, err);
throw new Error(`Failed to create session log directory: ${message}`);
}
// Get free port for adapter
const adapterPort = await this.findFreePort();
// Resolve paths
const projectRoot = path.resolve(fileURLToPath(import.meta.url), '../../../'); // Path to the MCP debugger server's root
const initialBreakpoints = Array.from(session.breakpoints.values()).map(bp => {
// Breakpoint file path is already translated by server.ts before reaching here
return {
file: bp.file, // Use the already translated path
line: bp.line,
condition: bp.condition
};
});
// scriptPath is already translated by server.ts before reaching here
const translatedScriptPath = scriptPath;
this.logger.info(`[SessionManager] Using translated script path: ${translatedScriptPath}`);
// Resolve executable path based on language
let resolvedExecutablePath: string;
if (session.language === 'python') {
// Python-specific path resolution
const executablePathFromSession = session.executablePath!;
if (path.isAbsolute(executablePathFromSession)) {
// Absolute path provided - use as-is
resolvedExecutablePath = executablePathFromSession;
} else if (['python', 'python3', 'py'].includes(executablePathFromSession.toLowerCase())) {
// Common Python commands - use auto-detection without preferredPath
try {
resolvedExecutablePath = await findPythonExecutable(undefined, this.logger);
this.logger.info(`[SessionManager] Auto-detected Python executable: ${resolvedExecutablePath}`);
} catch (error) {
this.logger.error(`[SessionManager] Failed to find Python executable:`, error);
throw error;
}
} else {
// Relative path - resolve from project root (MCP server's root)
resolvedExecutablePath = path.resolve(projectRoot, executablePathFromSession);
}
this.logger.info(`[SessionManager] Using Python path: ${resolvedExecutablePath}`);
} else {
// For non-Python languages (like mock), use a generic executable path
resolvedExecutablePath = session.executablePath || process.execPath; // Use node as fallback for mock
this.logger.info(`[SessionManager] Using ${session.language} executable: ${resolvedExecutablePath}`);
}
// Merge launch args
const effectiveLaunchArgs = {
...this.defaultDapLaunchArgs,
...(dapLaunchArgs || {}),
};
// Create the adapter for this language
const adapterConfig: AdapterConfig = {
sessionId,
executablePath: resolvedExecutablePath,
adapterHost: '127.0.0.1',
adapterPort,
logDir: sessionLogDir,
scriptPath: translatedScriptPath,
scriptArgs,
launchConfig: effectiveLaunchArgs
};
const adapter = await this.adapterRegistry.create(session.language, adapterConfig);
// Build adapter command using the adapter
const adapterCommand = adapter.buildAdapterCommand(adapterConfig);
// Create ProxyConfig
const proxyConfig: ProxyConfig = {
sessionId,
language: session.language, // Add language from session
executablePath: resolvedExecutablePath,
adapterHost: '127.0.0.1',
adapterPort,
logDir: sessionLogDir,
scriptPath: translatedScriptPath, // Use the already translated script path
scriptArgs,
stopOnEntry: effectiveLaunchArgs.stopOnEntry,
justMyCode: effectiveLaunchArgs.justMyCode,
initialBreakpoints,
dryRunSpawn: dryRunSpawn === true,
adapterCommand // Pass the adapter command
};
// Create and start ProxyManager with the adapter
const proxyManager = this.proxyManagerFactory.create(adapter);
session.proxyManager = proxyManager;
// Set up event handlers
this.setupProxyEventHandlers(session, proxyManager, effectiveLaunchArgs);
// Start the proxy
await proxyManager.start(proxyConfig);
}
/**
* Helper method to wait for dry run completion with timeout
*/
private async waitForDryRunCompletion(
session: ManagedSession,
timeoutMs: number
): Promise<boolean> {
let handler: (() => void) | null = null;
let timeoutId: NodeJS.Timeout | null = null;
try {
return await Promise.race([
new Promise<boolean>((resolve) => {
handler = () => {
this.logger.info(`[SessionManager] Dry run completion event received for session ${session.id}`);
resolve(true);
};
this.logger.info(`[SessionManager] Setting up dry-run-complete listener for session ${session.id}`);
session.proxyManager?.once('dry-run-complete', handler);
}),
new Promise<boolean>((resolve) => {
timeoutId = setTimeout(() => {
this.logger.warn(`[SessionManager] Dry run timeout after ${timeoutMs}ms for session ${session.id}`);
resolve(false);
}, timeoutMs);
})
]);
} finally {
// Clean up immediately
if (handler && session.proxyManager) {
this.logger.info(`[SessionManager] Removing dry-run-complete listener for session ${session.id}`);
session.proxyManager.removeListener('dry-run-complete', handler);
}
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
async startDebugging(
sessionId: string,
scriptPath: string,
scriptArgs?: string[],
dapLaunchArgs?: Partial<CustomLaunchRequestArguments>,
dryRunSpawn?: boolean
): Promise<DebugResult> {
const session = this._getSessionById(sessionId);
this.logger.info(`Attempting to start debugging for session ${sessionId}, script: ${scriptPath}, dryRunSpawn: ${dryRunSpawn}, dapLaunchArgs:`, dapLaunchArgs);
if (session.proxyManager) {
this.logger.warn(`[SessionManager] Session ${sessionId} already has an active proxy. Terminating before starting new.`);
await this.closeSession(sessionId);
}
// Update to INITIALIZING state and set lifecycle to ACTIVE
this._updateSessionState(session, SessionState.INITIALIZING);
// Explicitly set lifecycle state to ACTIVE when starting debugging
this.sessionStore.update(sessionId, {
sessionLifecycle: SessionLifecycleState.ACTIVE
});
this.logger.info(`[SessionManager] Session ${sessionId} lifecycle state set to ACTIVE`);
try {
// For dry run, start the proxy and wait for completion
if (dryRunSpawn) {
// Mark that we're setting up a dry run handler
const sessionWithSetup = session as ManagedSession & { _dryRunHandlerSetup?: boolean };
sessionWithSetup._dryRunHandlerSetup = true;
// Start the proxy manager
await this.startProxyManager(session, scriptPath, scriptArgs, dapLaunchArgs, dryRunSpawn);
this.logger.info(`[SessionManager] ProxyManager started for session ${sessionId}`);
// Check if already completed before waiting
const refreshedSession = this._getSessionById(sessionId);
this.logger.info(`[SessionManager] Checking state after start: ${refreshedSession.state}`);
if (refreshedSession.state === SessionState.STOPPED) {
this.logger.info(`[SessionManager] Dry run already completed for session ${sessionId}`);
delete sessionWithSetup._dryRunHandlerSetup;
return {
success: true,
state: SessionState.STOPPED,
data: { dryRun: true, message: "Dry run spawn command logged by proxy." }
};
}
// Wait for completion with timeout
this.logger.info(`[SessionManager] Waiting for dry run completion with timeout ${this.dryRunTimeoutMs}ms`);
const dryRunCompleted = await this.waitForDryRunCompletion(refreshedSession, this.dryRunTimeoutMs);
delete sessionWithSetup._dryRunHandlerSetup;
if (dryRunCompleted) {
this.logger.info(`[SessionManager] Dry run completed for session ${sessionId}, final state: ${refreshedSession.state}`);
return {
success: true,
state: SessionState.STOPPED,
data: { dryRun: true, message: "Dry run spawn command logged by proxy." }
};
} else {
// Timeout occurred
const finalSession = this._getSessionById(sessionId);
this.logger.error(
`[SessionManager] Dry run timeout for session ${sessionId}. ` +
`State: ${finalSession.state}, ProxyManager active: ${!!finalSession.proxyManager}`
);
return {
success: false,
error: `Dry run timed out after ${this.dryRunTimeoutMs}ms. Current state: ${finalSession.state}`,
state: finalSession.state
};
}
}
// Normal (non-dry-run) flow
// Start the proxy manager
await this.startProxyManager(session, scriptPath, scriptArgs, dapLaunchArgs, dryRunSpawn);
this.logger.info(`[SessionManager] ProxyManager started for session ${sessionId}`);
// Wait for adapter to be configured or first stop event
const waitForReady = new Promise<void>((resolve) => {
let resolved = false;
const handleStopped = () => {
if (!resolved) {
resolved = true;
this.logger.info(`[SessionManager] Session ${sessionId} stopped on entry`);
resolve();
}
};
const handleConfigured = () => {
if (!resolved && !dapLaunchArgs?.stopOnEntry) {
resolved = true;
this.logger.info(`[SessionManager] Session ${sessionId} running (stopOnEntry=false)`);
resolve();
}
};
session.proxyManager?.once('stopped', handleStopped);
session.proxyManager?.once('adapter-configured', handleConfigured);
// Timeout after 30 seconds
setTimeout(() => {
if (!resolved) {
resolved = true;
session.proxyManager?.removeListener('stopped', handleStopped);
session.proxyManager?.removeListener('adapter-configured', handleConfigured);
this.logger.warn(ErrorMessages.adapterReadyTimeout(30));
resolve();
}
}, 30000);
});
await waitForReady;
// Re-fetch session to get the most up-to-date state
const finalSession = this._getSessionById(sessionId);
const finalState = finalSession.state;
this.logger.info(`[SessionManager] Debugging started for session ${sessionId}. State: ${finalState}`);
return {
success: true,
state: finalState,
data: {
message: `Debugging started for ${scriptPath}. Current state: ${finalState}`,
reason: finalState === SessionState.PAUSED ? (dapLaunchArgs?.stopOnEntry ? 'entry' : 'breakpoint') : undefined,
stopOnEntrySuccessful: dapLaunchArgs?.stopOnEntry && finalState === SessionState.PAUSED,
}
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : 'No stack available';
this.logger.error(`[SessionManager] Error during startDebugging for session ${sessionId}: ${errorMessage}. Stack: ${errorStack}`);
this._updateSessionState(session, SessionState.ERROR);
if (session.proxyManager) {
await session.proxyManager.stop();
session.proxyManager = undefined;
}
return { success: false, error: errorMessage, state: session.state };
}
}
async setBreakpoint(sessionId: string, file: string, line: number, condition?: string): Promise<Breakpoint> {
const session = this._getSessionById(sessionId);
const bpId = uuidv4();
// The file path is already translated by server.ts before reaching here
// No need for projectRoot resolution here.
const translatedFilePath = file;
this.logger.info(`[SessionManager setBreakpoint] Using translated file path "${translatedFilePath}" for session ${sessionId}`);
const newBreakpoint: Breakpoint = { id: bpId, file: translatedFilePath, line, condition, verified: false };
if (!session.breakpoints) session.breakpoints = new Map();
session.breakpoints.set(bpId, newBreakpoint);
this.logger.info(`[SessionManager] Breakpoint ${bpId} queued for ${file}:${line} in session ${sessionId}.`);
if (session.proxyManager && session.proxyManager.isRunning() && (session.state === SessionState.RUNNING || session.state === SessionState.PAUSED)) {
try {
this.logger.info(`[SessionManager] Active proxy for session ${sessionId}, sending breakpoint ${bpId}.`);
const response = await session.proxyManager.sendDapRequest<DebugProtocol.SetBreakpointsResponse>('setBreakpoints', {
source: { path: newBreakpoint.file },
breakpoints: [{ line: newBreakpoint.line, condition: newBreakpoint.condition }]
});
if (response && response.body && response.body.breakpoints && response.body.breakpoints.length > 0) {
const bpInfo = response.body.breakpoints[0];
newBreakpoint.verified = bpInfo.verified;
newBreakpoint.line = bpInfo.line || newBreakpoint.line;
this.logger.info(`[SessionManager] Breakpoint ${bpId} sent and response received. Verified: ${newBreakpoint.verified}`);
// Log breakpoint verification with structured logging
if (newBreakpoint.verified) {
this.logger.info('debug:breakpoint', {
event: 'verified',
sessionId: sessionId,
sessionName: session.name,
breakpointId: bpId,
file: newBreakpoint.file,
line: newBreakpoint.line,
verified: true,
timestamp: Date.now()
});
}
}
} catch (error) {
this.logger.error(`[SessionManager] Error sending setBreakpoint to proxy for session ${sessionId}:`, error);
}
}
return newBreakpoint;
}
async stepOver(sessionId: string): Promise<DebugResult> {
const session = this._getSessionById(sessionId);
const threadId = session.proxyManager?.getCurrentThreadId();
this.logger.info(`[SM stepOver ${sessionId}] Entered. Current state: ${session.state}, ThreadID: ${threadId}`);
if (!session.proxyManager || !session.proxyManager.isRunning()) {
return { success: false, error: 'No active debug run', state: session.state };
}
if (session.state !== SessionState.PAUSED) {
this.logger.warn(`[SM stepOver ${sessionId}] Not paused. State: ${session.state}`);
return { success: false, error: 'Not paused', state: session.state };
}
if (!threadId) {
this.logger.warn(`[SM stepOver ${sessionId}] No current thread ID.`);
return { success: false, error: 'No current thread ID', state: session.state };
}
this.logger.info(`[SM stepOver ${sessionId}] Sending DAP 'next' for threadId ${threadId}`);
try {
// Send step request
await session.proxyManager.sendDapRequest('next', { threadId });
// Update state to running
this._updateSessionState(session, SessionState.RUNNING);
// Wait for stopped event
return new Promise((resolve) => {
const timeout = setTimeout(() => {
this.logger.warn(`[SM stepOver ${sessionId}] Timeout waiting for stopped event`);
resolve({
success: false,
error: ErrorMessages.stepTimeout(5),
state: session.state
});
}, 5000);
session.proxyManager?.once('stopped', () => {
clearTimeout(timeout);
this.logger.info(`[SM stepOver ${sessionId}] Step completed. Current state: ${session.state}`);
resolve({ success: true, state: session.state, data: { message: "Step over completed." } });
});
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`[SM stepOver ${sessionId}] Error during step:`, error);
this._updateSessionState(session, SessionState.ERROR);
return { success: false, error: errorMessage, state: session.state };
}
}
async stepInto(sessionId: string): Promise<DebugResult> {
const session = this._getSessionById(sessionId);
const threadId = session.proxyManager?.getCurrentThreadId();
this.logger.info(`[SM stepInto ${sessionId}] Entered. Current state: ${session.state}, ThreadID: ${threadId}`);
if (!session.proxyManager || !session.proxyManager.isRunning()) {
return { success: false, error: 'No active debug run', state: session.state };
}
if (session.state !== SessionState.PAUSED) {
this.logger.warn(`[SM stepInto ${sessionId}] Not paused. State: ${session.state}`);
return { success: false, error: 'Not paused', state: session.state };
}
if (!threadId) {
this.logger.warn(`[SM stepInto ${sessionId}] No current thread ID.`);
return { success: false, error: 'No current thread ID', state: session.state };
}
this.logger.info(`[SM stepInto ${sessionId}] Sending DAP 'stepIn' for threadId ${threadId}`);
try {
// Send step request
await session.proxyManager.sendDapRequest('stepIn', { threadId });
// Update state to running
this._updateSessionState(session, SessionState.RUNNING);
// Wait for stopped event
return new Promise((resolve) => {
const timeout = setTimeout(() => {
this.logger.warn(`[SM stepInto ${sessionId}] Timeout waiting for stopped event`);
resolve({
success: false,
error: ErrorMessages.stepTimeout(5),
state: session.state
});
}, 5000);
session.proxyManager?.once('stopped', () => {
clearTimeout(timeout);
this.logger.info(`[SM stepInto ${sessionId}] Step completed. Current state: ${session.state}`);
resolve({ success: true, state: session.state, data: { message: "Step into completed." } });
});
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`[SM stepInto ${sessionId}] Error during step:`, error);
this._updateSessionState(session, SessionState.ERROR);
return { success: false, error: errorMessage, state: session.state };
}
}
async stepOut(sessionId: string): Promise<DebugResult> {
const session = this._getSessionById(sessionId);
const threadId = session.proxyManager?.getCurrentThreadId();
this.logger.info(`[SM stepOut ${sessionId}] Entered. Current state: ${session.state}, ThreadID: ${threadId}`);
if (!session.proxyManager || !session.proxyManager.isRunning()) {
return { success: false, error: 'No active debug run', state: session.state };
}
if (session.state !== SessionState.PAUSED) {
this.logger.warn(`[SM stepOut ${sessionId}] Not paused. State: ${session.state}`);
return { success: false, error: 'Not paused', state: session.state };
}
if (!threadId) {
this.logger.warn(`[SM stepOut ${sessionId}] No current thread ID.`);
return { success: false, error: 'No current thread ID', state: session.state };
}
this.logger.info(`[SM stepOut ${sessionId}] Sending DAP 'stepOut' for threadId ${threadId}`);
try {
// Send step request
await session.proxyManager.sendDapRequest('stepOut', { threadId });
// Update state to running
this._updateSessionState(session, SessionState.RUNNING);
// Wait for stopped event
return new Promise((resolve) => {
const timeout = setTimeout(() => {
this.logger.warn(`[SM stepOut ${sessionId}] Timeout waiting for stopped event`);
resolve({
success: false,
error: ErrorMessages.stepTimeout(5),
state: session.state
});
}, 5000);
session.proxyManager?.once('stopped', () => {
clearTimeout(timeout);
this.logger.info(`[SM stepOut ${sessionId}] Step completed. Current state: ${session.state}`);
resolve({ success: true, state: session.state, data: { message: "Step out completed." } });
});
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`[SM stepOut ${sessionId}] Error during step:`, error);
this._updateSessionState(session, SessionState.ERROR);
return { success: false, error: errorMessage, state: session.state };
}
}
async continue(sessionId: string): Promise<DebugResult> {
const session = this._getSessionById(sessionId);
const threadId = session.proxyManager?.getCurrentThreadId();
this.logger.info(`[SessionManager continue] Called for session ${sessionId}. Current state: ${session.state}, ThreadID: ${threadId}`);
if (!session.proxyManager || !session.proxyManager.isRunning()) {
this.logger.warn(`[SessionManager continue] No active debug run for session ${sessionId}.`);
return { success: false, error: 'No active debug run', state: session.state };
}
if (session.state !== SessionState.PAUSED) {
this.logger.warn(`[SessionManager continue] Session ${sessionId} not paused. State: ${session.state}.`);
return { success: false, error: 'Not paused', state: session.state };
}
if (!threadId) {
this.logger.warn(`[SessionManager continue] No current thread ID for session ${sessionId}.`);
return { success: false, error: 'No current thread ID', state: session.state };
}
try {
this.logger.info(`[SessionManager continue] Sending DAP 'continue' for session ${sessionId}, threadId ${threadId}.`);
await session.proxyManager.sendDapRequest('continue', { threadId });
this._updateSessionState(session, SessionState.RUNNING);
this.logger.info(`[SessionManager continue] DAP 'continue' sent, session ${sessionId} state updated to RUNNING.`);
return { success: true, state: session.state };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`[SessionManager continue] Error sending 'continue' to proxy for session ${sessionId}: ${errorMessage}`);
throw error;
}
}
}