UNPKG

@debugmcp/mcp-debugger

Version:

Run-time step-through debugging for LLM agents.

302 lines (272 loc) 9.58 kB
/** * Shared utilities for smoke tests */ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { ServerResult } from '@modelcontextprotocol/sdk/types.js'; import { promisify } from 'util'; import { exec as execCallback } from 'child_process'; const exec = promisify(execCallback); /** * Parse SDK tool results */ export interface ParsedToolResult { sessionId?: string; success?: boolean; state?: string; [key: string]: unknown; } export const parseSdkToolResult = (rawResult: ServerResult): ParsedToolResult => { const contentArray = (rawResult as { content?: Array<{ type: string; text: string }> }).content; if (!contentArray || !Array.isArray(contentArray) || contentArray.length === 0 || contentArray[0].type !== 'text') { console.error("Invalid ServerResult structure received from SDK:", rawResult); throw new Error('Invalid ServerResult structure from SDK or missing text content'); } return JSON.parse(contentArray[0].text); }; /** * Call MCP tool and handle errors gracefully */ export async function callToolSafely( mcpClient: Client, toolName: string, args: Record<string, unknown> ): Promise<ParsedToolResult> { try { const result = await mcpClient.callTool({ name: toolName, arguments: args }); return parseSdkToolResult(result); } catch (error) { // MCP errors have various formats const err = error as Error & { code?: string | number; data?: unknown }; // Handle different error formats if (err.code || err.message?.includes('MCP error') || err.message?.includes('Session') || err.message?.includes('not found') || err.message?.includes('closed')) { return { success: false, message: err.message || 'Unknown MCP error', error: err.code }; } // For any other error, assume it's an MCP-related error console.log('[callToolSafely] Caught error:', err.message); return { success: false, message: err.message || 'Unknown error' }; } } /** * Execute common debug sequence for smoke tests */ export async function executeDebugSequence( mcpSdkClient: Client, fibonacciPath: string, sessionName: string ): Promise<{ sessionId: string; success: boolean }> { let debugSessionId: string | undefined; try { // 1. Create debug session console.log(`[Smoke Test] Creating debug session: ${sessionName}...`); const createCall = await mcpSdkClient.callTool({ name: 'create_debug_session', arguments: { language: 'python', name: sessionName } }); const createToolResponse = parseSdkToolResult(createCall); if (!createToolResponse.sessionId) { throw new Error('Failed to create debug session'); } debugSessionId = createToolResponse.sessionId; console.log(`[Smoke Test] Debug session created: ${debugSessionId}`); // 2. Set breakpoint console.log('[Smoke Test] Setting breakpoint...'); const breakpointCall = await mcpSdkClient.callTool({ name: 'set_breakpoint', arguments: { sessionId: debugSessionId, file: fibonacciPath, line: 32 } }); const breakpointResponse = parseSdkToolResult(breakpointCall); if (!breakpointResponse.success) { throw new Error('Failed to set breakpoint'); } console.log('[Smoke Test] Breakpoint set.'); // 3. Start debugging console.log('[Smoke Test] Starting debugging...'); const debugCall = await mcpSdkClient.callTool({ name: 'start_debugging', arguments: { sessionId: debugSessionId, scriptPath: fibonacciPath, dapLaunchArgs: { stopOnEntry: true } } }); const debugResponse = parseSdkToolResult(debugCall); if (!debugResponse.success) { throw new Error(`Failed to start debugging: ${JSON.stringify(debugResponse)}`); } console.log(`[Smoke Test] Debugging started successfully. State: ${debugResponse.state}`); return { sessionId: debugSessionId, success: true }; } catch (error) { console.error('[Smoke Test] Error during debug sequence:', error); // Clean up on error if (debugSessionId) { try { await mcpSdkClient.callTool({ name: 'close_debug_session', arguments: { sessionId: debugSessionId } }); } catch (e) { console.error(`[Smoke Test] Error closing debug session ${debugSessionId}:`, e); } } throw error; } } /** * Check if Docker is available on the system */ export async function isDockerAvailable(): Promise<boolean> { try { const result = await execWithTimeout('docker --version', 5000); console.log('[Smoke Test] Docker version:', result.stdout.trim()); return true; } catch (error) { console.log('[Smoke Test] Docker not available:', error); return false; } } /** * Execute command with timeout */ export async function execWithTimeout(command: string, timeoutMs: number = 30000): Promise<{ stdout: string; stderr: string }> { return Promise.race([ exec(command), new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`)), timeoutMs) ) ]); } /** * Wait for SSE server to be ready by checking health endpoint */ export async function waitForPort(port: number, timeout: number = 10000): Promise<boolean> { const startTime = Date.now(); const healthUrl = `http://localhost:${port}/health`; console.log(`[Smoke Test] Waiting for SSE server health at ${healthUrl}...`); while (Date.now() - startTime < timeout) { try { const response = await fetch(healthUrl); if (response.ok) { const healthStatus = await response.json(); if (healthStatus.status === 'ok') { console.log('[Smoke Test] SSE server health check passed'); return true; } } } catch { // Connection refused, server not ready yet } await new Promise(resolve => setTimeout(resolve, 500)); } console.error(`[Smoke Test] Timeout waiting for SSE server on port ${port}`); return false; } /** * Clean up Docker containers */ export async function cleanupDocker(containerId?: string): Promise<void> { if (!containerId) return; try { console.log(`[Smoke Test] Stopping Docker container ${containerId}...`); await execWithTimeout(`docker stop ${containerId}`, 10000); console.log(`[Smoke Test] Removing Docker container ${containerId}...`); await execWithTimeout(`docker rm ${containerId}`, 5000); console.log('[Smoke Test] Docker cleanup completed'); } catch (error) { console.error('[Smoke Test] Error during Docker cleanup:', error); // Try force removal as fallback try { await execWithTimeout(`docker rm -f ${containerId}`, 5000); } catch (e) { console.error('[Smoke Test] Force removal also failed:', e); } } } /** * Get cross-platform volume mount string */ export function getVolumeMount(hostPath: string, containerPath: string): string { // On Windows, convert backslashes to forward slashes for Docker const normalizedHostPath = process.platform === 'win32' ? hostPath.replace(/\\/g, '/') : hostPath; return `${normalizedHostPath}:${containerPath}`; } /** * Generate unique container name to avoid conflicts */ export function generateContainerName(prefix: string = 'mcp-debug-test'): string { const timestamp = Date.now(); const randomSuffix = Math.random().toString(36).substr(2, 9); return `${prefix}-${timestamp}-${randomSuffix}`; } /** * Get Docker container logs for debugging */ export async function getContainerLogs(containerId: string): Promise<string> { try { const result = await execWithTimeout(`docker logs ${containerId}`, 5000); return result.stdout + result.stderr; } catch (error) { console.error('[Smoke Test] Failed to get container logs:', error); return 'Failed to retrieve container logs'; } } /** * Extract port from SSE server output */ export function extractPortFromOutput(output: string): number | null { // Look for patterns like: // - "listening on port 3000" // - "Server started on port: 3000" // - "Debug MCP Server (SSE) listening on port 3000" const portMatch = output.match(/listening\s+on\s+port\s+(\d+)/i); if (portMatch && portMatch[1]) { return parseInt(portMatch[1], 10); } return null; } /** * Check if Docker image exists */ export async function dockerImageExists(imageName: string): Promise<boolean> { try { const result = await execWithTimeout(`docker images -q ${imageName}`, 5000); return result.stdout.trim().length > 0; } catch (error) { console.error('[Smoke Test] Error checking Docker image:', error); return false; } } /** * Build Docker image if needed */ export async function ensureDockerImage(imageName: string, forceBuild: boolean = false): Promise<void> { const exists = await dockerImageExists(imageName); if (exists && !forceBuild) { console.log(`[Smoke Test] Docker image ${imageName} already exists, skipping build`); return; } console.log(`[Smoke Test] Building Docker image ${imageName}...`); const buildResult = await execWithTimeout( `docker build -t ${imageName} .`, 120000 // 2 minutes timeout for build ); if (buildResult.stderr && !buildResult.stderr.includes('Successfully built')) { console.warn('[Smoke Test] Docker build warnings:', buildResult.stderr); } console.log(`[Smoke Test] Docker image ${imageName} built successfully`); }