Skip to main content
Glama
qckfx
by qckfx
index.ts21.6 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { spawn, ChildProcess } from "child_process"; import { readFileSync, existsSync } from "fs"; import { join, resolve } from "path"; import { inspect } from "util"; import { createConnection } from "net"; import CDP from "chrome-remote-interface"; interface ManagedProcess { pid: number; port: number; command: string; args: string[]; process: ChildProcess; startTime: Date; scriptPath: string; } interface DebugSession { connected: boolean; processId?: number; port?: number; callStack?: any[]; variables?: Record<string, any>; client?: any; breakpoints?: Map<string, string>; isPaused?: boolean; currentExecutionContext?: number; } class NodeDebuggerServer { private server: Server; private managedProcesses: Map<number, ManagedProcess> = new Map(); private debugSession: DebugSession = { connected: false, breakpoints: new Map(), isPaused: false }; private nextPort = 9229; private usedPorts = new Set<number>(); constructor() { this.server = new Server( { name: "debugger-mcp", version: "1.0.0", }, { capabilities: { resources: {}, tools: {}, }, } ); this.setupHandlers(); } private setupHandlers() { this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [ { uri: "debug://session", mimeType: "application/json", name: "Debug Session State", description: "Current debugging session information", }, { uri: "debug://processes", mimeType: "application/json", name: "Managed Processes", description: "List of managed Node.js processes", }, ], })); this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params; switch (uri) { case "debug://session": const sessionData = { connected: this.debugSession.connected, port: this.debugSession.port, isPaused: this.debugSession.isPaused, currentExecutionContext: this.debugSession.currentExecutionContext, breakpoints: Array.from(this.debugSession.breakpoints?.entries() || []), callStack: this.debugSession.callStack?.map(frame => ({ functionName: frame.functionName, url: frame.url, lineNumber: frame.location.lineNumber + 1, // Convert back to 1-based columnNumber: frame.location.columnNumber, scopeChain: frame.scopeChain?.map((scope: any) => ({ type: scope.type, name: scope.name })) })) }; return { contents: [ { uri, mimeType: "application/json", text: JSON.stringify(sessionData, null, 2), }, ], }; case "debug://processes": const processes = Array.from(this.managedProcesses.values()).map(p => ({ pid: p.pid, port: p.port, command: p.command, args: p.args, startTime: p.startTime, status: p.process.killed ? 'killed' : 'running' })); return { contents: [ { uri, mimeType: "application/json", text: JSON.stringify(processes, null, 2), }, ], }; default: throw new Error(`Unknown resource: ${uri}`); } }); this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "start_node_process", description: "Start a Node.js process with debugging enabled", inputSchema: { type: "object", properties: { script: { type: "string", description: "Path to the Node.js script to run" }, args: { type: "array", items: { type: "string" }, description: "Arguments to pass to the script" }, cwd: { type: "string", description: "Working directory (optional)" } }, required: ["script"], }, }, { name: "kill_process", description: "Kill a managed Node.js process", inputSchema: { type: "object", properties: { pid: { type: "number", description: "Process ID to kill" } }, required: ["pid"], }, }, { name: "list_processes", description: "List all managed Node.js processes", inputSchema: { type: "object", properties: {}, }, }, { name: "attach_debugger", description: "Attach debugger to a running Node.js process", inputSchema: { type: "object", properties: { port: { type: "number", description: "Debug port to connect to" } }, required: ["port"], }, }, { name: "set_breakpoint", description: "Set a breakpoint in the debugged process. Use full file:// URLs for reliable breakpoint hits.", inputSchema: { type: "object", properties: { file: { type: "string", description: "File URL or path (use file:///absolute/path/to/file.js for best results)" }, line: { type: "number", description: "Line number (1-based)" }, condition: { type: "string", description: "Optional condition for the breakpoint" } }, required: ["file", "line"], }, }, { name: "step_debug", description: "Step through code execution", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["next", "step", "continue", "out"], description: "Debug action to perform" } }, required: ["action"], }, }, { name: "pause_execution", description: "Pause execution of the debugged process", inputSchema: { type: "object", properties: {}, }, }, { name: "evaluate_expression", description: "Evaluate an expression in the current debug context", inputSchema: { type: "object", properties: { expression: { type: "string", description: "Expression to evaluate" } }, required: ["expression"], }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; switch (name) { case "start_node_process": return await this.startNodeProcess(args as { script: string; args?: string[]; cwd?: string }); case "kill_process": return await this.killProcess(args as { pid: number }); case "list_processes": return await this.listProcesses(); case "attach_debugger": return await this.attachDebugger(args as { port: number }); case "set_breakpoint": return await this.setBreakpoint(args as { file: string; line: number; condition?: string }); case "step_debug": return await this.stepDebug(args as { action: "next" | "step" | "continue" | "out" }); case "pause_execution": return await this.pauseExecution(); case "evaluate_expression": return await this.evaluateExpression(args as { expression: string }); default: throw new Error(`Unknown tool: ${name}`); } }); } private async startNodeProcess(args: { script: string; args?: string[]; cwd?: string }) { const workingDir = args.cwd || process.cwd(); const scriptPath = resolve(workingDir, args.script); // Validate script exists if (!existsSync(scriptPath)) { return { content: [{ type: "text", text: `Script not found: ${scriptPath}`, }], isError: true, }; } // Find available port const port = await this.findAvailablePort(); const nodeArgs = [`--inspect-brk=${port}`, args.script, ...(args.args || [])]; try { const child = spawn("node", nodeArgs, { cwd: args.cwd || process.cwd(), stdio: ["pipe", "pipe", "pipe"], detached: false, }); if (!child.pid) { throw new Error("Failed to start process"); } const managedProcess: ManagedProcess = { pid: child.pid, port, command: "node", args: nodeArgs, process: child, startTime: new Date(), scriptPath, }; this.usedPorts.add(port); this.managedProcesses.set(child.pid, managedProcess); child.on("exit", (code) => { if (child.pid) { const process = this.managedProcesses.get(child.pid); if (process) { this.usedPorts.delete(process.port); this.managedProcesses.delete(child.pid); // Clean up debug session if it was connected to this process if (this.debugSession.port === process.port) { this.debugSession = { connected: false }; } } } }); return { content: [ { type: "text", text: `Started Node.js process with PID ${child.pid} on debug port ${port}`, }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error starting process: ${error}`, }, ], isError: true, }; } } private async killProcess(args: { pid: number }) { const managedProcess = this.managedProcesses.get(args.pid); if (!managedProcess) { return { content: [ { type: "text", text: `Process ${args.pid} not found in managed processes`, }, ], isError: true, }; } try { managedProcess.process.kill("SIGTERM"); this.managedProcesses.delete(args.pid); return { content: [ { type: "text", text: `Killed process ${args.pid}`, }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error killing process: ${error}`, }, ], isError: true, }; } } private async listProcesses() { const processes = Array.from(this.managedProcesses.values()).map(p => ({ pid: p.pid, port: p.port, command: `${p.command} ${p.args.join(" ")}`, startTime: p.startTime.toISOString(), status: p.process.killed ? 'killed' : 'running' })); return { content: [ { type: "text", text: JSON.stringify(processes, null, 2), }, ], }; } private async attachDebugger(args: { port: number }) { try { // Close existing connection if any if (this.debugSession.client) { await this.debugSession.client.close(); } // Connect to the Node.js inspector using Chrome DevTools Protocol const client = await CDP({ port: args.port }); const { Debugger, Runtime } = client; // Set up event handlers first Debugger.paused((params: any) => { this.debugSession.callStack = params.callFrames; this.debugSession.isPaused = true; }); Debugger.resumed(() => { this.debugSession.isPaused = false; this.debugSession.callStack = []; }); Runtime.executionContextCreated((params: any) => { this.debugSession.currentExecutionContext = params.context.id; }); // Enable debugging domains await Debugger.enable(); await Runtime.enable(); // Initialize session this.debugSession = { connected: true, port: args.port, client, callStack: [], variables: {}, breakpoints: new Map(), isPaused: false, currentExecutionContext: undefined }; // For --inspect-brk, the runtime is waiting for debugger // We need to handle this special case try { // First, let's try to pause to ensure we're in a debuggable state await Debugger.pause(); // Small delay for pause event await new Promise(resolve => setTimeout(resolve, 100)); } catch (error) { // Process might already be paused or not yet ready } // Now handle the waiting for debugger state try { // This will allow execution to continue from the initial --inspect-brk pause // But since we called pause() above, it should remain paused await Runtime.runIfWaitingForDebugger(); } catch (error) { // If this fails, the process might not be waiting } // Small delay to allow all events to fire await new Promise(resolve => setTimeout(resolve, 200)); return { content: [ { type: "text", text: `Successfully attached debugger to port ${args.port}`, }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error attaching debugger: ${error}`, }, ], isError: true, }; } } private async setBreakpoint(args: { file: string; line: number; condition?: string }) { if (!this.debugSession.connected || !this.debugSession.client) { return { content: [ { type: "text", text: "No active debug session. Please attach debugger first.", }, ], isError: true, }; } try { const { Debugger } = this.debugSession.client; // Use CDP to set the breakpoint const result = await Debugger.setBreakpointByUrl({ lineNumber: args.line - 1, // CDP uses 0-based line numbers url: args.file, condition: args.condition }); if (result.breakpointId) { // Store the breakpoint mapping const breakpointKey = `${args.file}:${args.line}`; this.debugSession.breakpoints!.set(breakpointKey, result.breakpointId); return { content: [ { type: "text", text: `Set breakpoint at ${args.file}:${args.line}${args.condition ? ` (condition: ${args.condition})` : ""} - ID: ${result.breakpointId}`, }, ], }; } else { return { content: [ { type: "text", text: `Failed to set breakpoint at ${args.file}:${args.line}`, }, ], isError: true, }; } } catch (error) { return { content: [ { type: "text", text: `Error setting breakpoint: ${error}`, }, ], isError: true, }; } } private async stepDebug(args: { action: "next" | "step" | "continue" | "out" }) { if (!this.debugSession.connected || !this.debugSession.client) { return { content: [ { type: "text", text: "No active debug session. Please attach debugger first.", }, ], isError: true, }; } try { const { Debugger } = this.debugSession.client; // Use CDP to perform the step action switch (args.action) { case "next": await Debugger.stepOver(); break; case "step": await Debugger.stepInto(); break; case "continue": await Debugger.resume(); break; case "out": await Debugger.stepOut(); break; } return { content: [ { type: "text", text: `Performed debug action: ${args.action}`, }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error performing debug action: ${error}`, }, ], isError: true, }; } } private async pauseExecution() { if (!this.debugSession.connected || !this.debugSession.client) { return { content: [ { type: "text", text: "No active debug session. Please attach debugger first.", }, ], isError: true, }; } try { const { Debugger } = this.debugSession.client; await Debugger.pause(); return { content: [ { type: "text", text: "Execution paused successfully", }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error pausing execution: ${error}`, }, ], isError: true, }; } } private async evaluateExpression(args: { expression: string }) { if (!this.debugSession.connected || !this.debugSession.client) { return { content: [ { type: "text", text: "No active debug session. Please attach debugger first.", }, ], isError: true, }; } try { const { Runtime, Debugger } = this.debugSession.client; let result; // If we're paused and have a call stack, evaluate in the current call frame if (this.debugSession.isPaused && this.debugSession.callStack && this.debugSession.callStack.length > 0) { const currentFrame = this.debugSession.callStack[0]; result = await Debugger.evaluateOnCallFrame({ callFrameId: currentFrame.callFrameId, expression: args.expression, returnByValue: true }); } else { // Otherwise, evaluate in the runtime context result = await Runtime.evaluate({ expression: args.expression, contextId: this.debugSession.currentExecutionContext, includeCommandLineAPI: true, returnByValue: true }); } if (result.exceptionDetails) { return { content: [ { type: "text", text: `Exception: ${result.exceptionDetails.exception?.description || 'Unknown error'}`, }, ], isError: true, }; } const value = result.result.value !== undefined ? result.result.value : result.result.description || '[Object]'; return { content: [ { type: "text", text: `${args.expression} = ${JSON.stringify(value, null, 2)}`, }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error evaluating expression: ${error}`, }, ], isError: true, }; } } private async findAvailablePort(): Promise<number> { let port = this.nextPort; while (this.usedPorts.has(port) || !(await this.isPortAvailable(port))) { port++; } this.nextPort = port + 1; return port; } private async isPortAvailable(port: number): Promise<boolean> { return new Promise((resolve) => { const connection = createConnection({ port }, () => { connection.end(); resolve(false); // Port is in use }); connection.on('error', () => { resolve(true); // Port is available }); }); } private cleanup() { // Close debug session if connected if (this.debugSession.client) { try { this.debugSession.client.close(); } catch (error) { console.error('Error closing debug session:', error); } } // Kill all managed processes on shutdown for (const [pid, managedProcess] of this.managedProcesses) { try { managedProcess.process.kill('SIGTERM'); } catch (error) { console.error(`Error killing process ${pid}:`, error); } } this.managedProcesses.clear(); this.usedPorts.clear(); this.debugSession = { connected: false, breakpoints: new Map(), isPaused: false }; } async run() { const transport = new StdioServerTransport(); // Setup cleanup on process exit process.on('SIGINT', () => { this.cleanup(); process.exit(0); }); process.on('SIGTERM', () => { this.cleanup(); process.exit(0); }); await this.server.connect(transport); } } const server = new NodeDebuggerServer(); server.run().catch(console.error);

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/qckfx/node-debugger-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server