@debugmcp/mcp-debugger
Version:
Run-time step-through debugging for LLM agents.
268 lines (231 loc) • 13 kB
text/typescript
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { DebugSessionInfo, StackFrame, Variable } from '../../../../src/session/models';
import { DebugProtocol } from '@vscode/debugprotocol';
import { spawn, ChildProcess } from 'child_process';
import path from 'path';
import fs from 'node:fs'; // Import the native fs module
import { fileURLToPath } from 'url';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; // Removed StdioClientTransportParameters
import { ServerResult } from '@modelcontextprotocol/sdk/types.js';
// --- SDK-based MCP Client for Testing ---
// let serverProcess: ChildProcess | null = null; // SDK Transport will manage the process
let client: Client | null = null;
async function startTestServer(): Promise<void> {
const currentFileURL = import.meta.url;
const currentFilePath = fileURLToPath(currentFileURL);
const currentDirName = path.dirname(currentFilePath);
// Path to the server's main executable JS file
const serverScriptPath = path.resolve(currentDirName, '../../../../dist/index.js');
console.log(`[Test Setup] Server script path for SDK StdioClientTransport: ${serverScriptPath}`);
client = new Client({
name: "jest-mcp-test-client",
version: "0.1.0",
capabilities: { tools: {} }
});
const filteredEnv: Record<string, string> = {};
for (const key in process.env) {
if (process.env[key] !== undefined) {
filteredEnv[key] = process.env[key] as string;
}
}
const logFilePath = path.resolve(currentDirName, '../../integration_test_server.log'); // Log to project root
console.log(`[Test Setup] Server log file will be at: ${logFilePath}`);
// Ensure log file is clean before test run
try {
if (fs.existsSync(logFilePath)) {
fs.unlinkSync(logFilePath);
}
} catch (e) { console.error(`Error deleting old log file: ${e}`); }
const transport = new StdioClientTransport({
command: 'node',
args: [serverScriptPath, '--log-level', 'debug', '--log-file', logFilePath],
env: filteredEnv, // Pass filtered environment to the server process
// CWD might be needed if serverScriptPath is not absolute or if the server relies on a specific CWD
// cwd: path.resolve(currentDirName, '../../') // Example: project root
});
// StdioClientTransport will log its own stderr/stdout from the child process if configured.
// We don't need to manually attach to serverProcess.stderr anymore if transport handles it.
// The transport also manages the lifecycle of the spawned process.
try {
console.log('[Test Server] Attempting to connect SDK client (which will spawn server)...');
await client.connect(transport); // This spawns the server and handles initialize
console.log('[Test Server] SDK Client connected, server spawned, and initialized successfully.');
} catch (error) {
console.error('[Test Server] SDK Client connection/spawn/initialization failed:', error);
client = null; // Ensure client is null if connect failed
throw error;
}
}
async function stopTestServer(): Promise<void> {
if (client) {
console.log('[Test Server] Closing SDK client connection (should terminate server)...');
try {
await client.close();
console.log('[Test Server] SDK Client closed successfully.');
} catch (e) {
console.error('[Test Server] Error closing SDK client:', e);
}
}
client = null;
// serverProcess is no longer managed directly here; StdioClientTransport handles it.
}
// processBufferedResponses and old callMcpTool are no longer needed.
// The SDK Client's callTool method will be used directly.
// Helper to introduce delay
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
describe('Python Debugging Workflow - Integration Test @requires-python', () => {
let sessionId: string;
const scriptPath = path.resolve('tests/fixtures/python/debug_test_simple.py'); // Absolute path
const breakpointLine = 13; // Line 'c = a + b' in debug_test_simple.py
beforeAll(async () => {
await startTestServer();
});
afterAll(async () => {
await stopTestServer();
});
it('should complete a full debug session and inspect local variables', async () => {
if (!client) { // Check if client was initialized
throw new Error("MCP Client not initialized. Cannot run test.");
}
if (!client) {
throw new Error("MCP Client not initialized. Cannot run test.");
}
// Helper to parse ServerResult
const parseToolResult = (rawResult: any) => { // Accept any directly
const anyResult = rawResult as any;
if (!anyResult || !anyResult.content || !anyResult.content[0] || anyResult.content[0].type !== 'text') {
console.error("Invalid ServerResult structure received:", rawResult);
throw new Error('Invalid ServerResult structure');
}
return JSON.parse(anyResult.content[0].text);
};
// 1. List Sessions (simpler first call)
// Do not type listRawResult as ServerResult if its type is problematic
const listRawResult = await client.callTool({ name: 'list_debug_sessions', arguments: {} });
const listResult = parseToolResult(listRawResult);
expect(listResult.success).toBe(true);
expect(listResult.sessions).toBeInstanceOf(Array);
console.log(`[Test] Listed sessions (count: ${listResult.sessions.length})`);
// 2. Create Session
const createRawResult = await client.callTool({ name: 'create_debug_session', arguments: { language: 'python', name: 'integrationTestSession' } });
const createResult = parseToolResult(createRawResult);
expect(createResult.success).toBe(true);
expect(createResult.sessionId).toBeDefined();
sessionId = createResult.sessionId;
console.log(`[Test] Created session: ${sessionId}`);
// 3. Set Breakpoint
const breakpointRawResult = await client.callTool({ name: 'set_breakpoint', arguments: { sessionId, file: scriptPath, line: breakpointLine } });
const breakpointResult = parseToolResult(breakpointRawResult);
expect(breakpointResult.success).toBe(true);
// Compare absolute paths
expect(breakpointResult.file).toBe(scriptPath);
expect(breakpointResult.line).toBe(breakpointLine);
console.log(`[Test] Set breakpoint at ${scriptPath}:${breakpointLine}`);
// 4. Start Debugging
const startRawResult = await client.callTool({ name: 'start_debugging', arguments: { sessionId, scriptPath } });
const startResult = parseToolResult(startRawResult);
console.log('[Test] Start debugging result:', JSON.stringify(startResult, null, 2));
expect(startResult.success).toBe(true);
expect(startResult.state).toBe('paused');
console.log('[Test] Started debugging, initially paused (stopOnEntry).');
// 5. Continue to Breakpoint
const continueRawResult = await client.callTool({ name: 'continue_execution', arguments: { sessionId } });
const continueResult = parseToolResult(continueRawResult);
expect(continueResult.success).toBe(true);
console.log('[Test] Continued execution. Waiting for breakpoint...');
await delay(3000);
// 6. Get Stack Trace (at breakpoint)
const stackTraceRawResult = await client.callTool({ name: 'get_stack_trace', arguments: { sessionId } });
const stackTraceResult = parseToolResult(stackTraceRawResult);
expect(stackTraceResult.success).toBe(true);
expect(stackTraceResult.stackFrames).toBeInstanceOf(Array);
expect(stackTraceResult.stackFrames.length).toBeGreaterThanOrEqual(1);
const topFrame = stackTraceResult.stackFrames[0] as StackFrame;
// Use toContain for file path as debugpy returns absolute paths
expect(topFrame.file).toContain('debug_test_simple.py');
expect(topFrame.name).toBe('sample_function');
expect(topFrame.line).toBe(breakpointLine);
const frameId = topFrame.id;
console.log(`[Test] Paused at stack frame: ${topFrame.name} (ID: ${frameId}) line ${topFrame.line}`);
// 7. Get Scopes
const scopesRawResult = await client.callTool({ name: 'get_scopes', arguments: { sessionId, frameId } });
const scopesResult = parseToolResult(scopesRawResult);
expect(scopesResult.success).toBe(true);
expect(scopesResult.scopes).toBeInstanceOf(Array);
const localsScope = scopesResult.scopes.find((s: DebugProtocol.Scope) => s.name === 'Locals');
expect(localsScope).toBeDefined();
if (!localsScope) throw new Error("Locals scope not found in get_scopes response");
const localsVariablesRef = localsScope.variablesReference;
console.log(`[Test] Got scopes. Locals ref: ${localsVariablesRef}`);
// 8. Get Variables (Locals of sample_function)
const variablesRawResult = await client.callTool({ name: 'get_variables', arguments: { sessionId, scope: localsVariablesRef } });
const variablesResult = parseToolResult(variablesRawResult);
expect(variablesResult.success).toBe(true);
expect(variablesResult.variables).toBeInstanceOf(Array);
const varA = variablesResult.variables.find((v: Variable) => v.name === 'a');
expect(varA).toBeDefined();
if (!varA) throw new Error("Variable 'a' not found");
expect(varA.value).toBe('5');
expect(varA.type).toBe('int');
const varB = variablesResult.variables.find((v: Variable) => v.name === 'b');
expect(varB).toBeDefined();
if (!varB) throw new Error("Variable 'b' not found");
expect(varB.value).toBe('10');
expect(varB.type).toBe('int');
console.log('[Test] Verified local variables a and b.');
// 9. Close Session
const closeRawResult = await client.callTool({ name: 'close_debug_session', arguments: { sessionId } });
const closeResult = parseToolResult(closeRawResult);
expect(closeResult.success).toBe(true);
console.log(`[Test] Closed session: ${sessionId}`);
});
it('should perform a dry run for start_debugging and log the command', async () => {
if (!client) {
throw new Error("MCP Client not initialized. Cannot run test.");
}
const parseToolResult = (rawResult: any) => {
const anyResult = rawResult as any;
if (!anyResult || !anyResult.content || !anyResult.content[0] || anyResult.content[0].type !== 'text') {
console.error("Invalid ServerResult structure received:", rawResult);
throw new Error('Invalid ServerResult structure');
}
return JSON.parse(anyResult.content[0].text);
};
console.log('[Test] === Test: Dry Run for start_debugging ===');
const scriptToDryRun = path.resolve('tests/fixtures/python/debug_test_simple.py'); // Absolute path
// Create a new session for the dry run test
const createDryRunRawResult = await client.callTool({ name: 'create_debug_session', arguments: { language: 'python', name: 'DryRunTestSession' } });
const createDryRunResult = parseToolResult(createDryRunRawResult);
expect(createDryRunResult.success).toBe(true);
const dryRunSessionId = createDryRunResult.sessionId;
console.log(`[Test] Created session for dry run: ${dryRunSessionId}`);
// Call start_debugging with dryRunSpawn: true
console.log(`[Test] Calling start_debugging with dryRunSpawn: true for session ${dryRunSessionId}`);
const startDryRunRawResult = await client.callTool({
name: 'start_debugging',
arguments: {
sessionId: dryRunSessionId,
scriptPath: scriptToDryRun,
dryRunSpawn: true,
},
});
const parsedDryRunResult = parseToolResult(startDryRunRawResult);
console.log('[Test] Dry run start_debugging result:', JSON.stringify(parsedDryRunResult, null, 2));
if (!parsedDryRunResult.success) {
console.error('[Test] Dry run failed with error:', parsedDryRunResult.error);
}
expect(parsedDryRunResult.success).toBe(true);
// SessionManager's startDebugging for a successful dry run returns:
// { success: true, state: session.state (STOPPED), data: { dryRun: true, message: "Dry run spawn command logged by proxy." } };
expect(parsedDryRunResult.state).toBe('stopped'); // SessionManager sets state to STOPPED after dry run
expect(parsedDryRunResult.data?.dryRun).toBe(true);
expect(parsedDryRunResult.data?.message).toContain("Dry run spawn command logged by proxy.");
console.log('[Test] Dry run test completed. Manual log inspection needed for dap-proxy command.');
// Add a small delay if needed for logs to flush, though the proxy should exit quickly.
await delay(1000);
// Clean up the dry run session
await client.callTool({ name: 'close_debug_session', arguments: { sessionId: dryRunSessionId } });
console.log(`[Test] Closed dry run session: ${dryRunSessionId}`);
});
}, 60000); // 60 seconds timeout for the entire suite