├── tests ├── fixtures │ ├── sample-script.js │ └── advanced-script.js ├── mcp-e2e.test.ts ├── node-debug-advanced.test.ts └── node-debug.test.ts ├── tsconfig.json ├── .gitignore ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── package.json ├── README.md └── src └── index.ts /tests/fixtures/sample-script.js: -------------------------------------------------------------------------------- 1 | console.log('start'); 2 | function add(a, b) { 3 | const sum = a + b; 4 | console.log('sum', sum); 5 | return sum; 6 | } 7 | add(2,3); 8 | console.log('end'); 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "outDir": "./dist", 10 | "rootDir": ".", 11 | "declaration": true 12 | }, 13 | "include": ["src/**/*", "tests/**/*"], 14 | "exclude": ["node_modules", "dist"] 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | package-lock.json 4 | 5 | # Build output 6 | dist/ 7 | build/ 8 | *.tsbuildinfo 9 | 10 | # IDE and editor files 11 | .vscode/ 12 | .idea/ 13 | *.swp 14 | *.swo 15 | *~ 16 | 17 | # Logs 18 | *.log 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | # Environment files 24 | .env 25 | .env.local 26 | .env.*.local 27 | 28 | # Operating System 29 | .DS_Store 30 | Thumbs.db 31 | -------------------------------------------------------------------------------- /tests/fixtures/advanced-script.js: -------------------------------------------------------------------------------- 1 | // A richer program to exercise debugger scope/stack inspection 2 | console.log('begin'); 3 | 4 | function makeCounter(start) { 5 | let count = start; 6 | const meta = { tag: 'C', list: [1, 2], nested: { a: 1 } }; 7 | return function inc(step) { 8 | const s = step ?? 1; 9 | const before = count; 10 | count += s; 11 | console.log('inc', { before, s, count, metaTag: meta.tag }); 12 | debugger; // pause inside closure with locals + closure vars 13 | return count; 14 | }; 15 | } 16 | 17 | class Calc { 18 | constructor(mult) { 19 | this.mult = mult; 20 | } 21 | times(n) { 22 | const out = n * this.mult; 23 | debugger; // pause with `this` and locals 24 | return out; 25 | } 26 | } 27 | 28 | const inc1 = makeCounter(10); 29 | const c = new Calc(3); 30 | const a1 = inc1(2); 31 | const a2 = c.times(5); 32 | console.log('done', { a1, a2 }); 33 | 34 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main, master ] 6 | pull_request: 7 | branches: [ main, master ] 8 | 9 | jobs: 10 | build-and-test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | node: [22] 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup Node.js ${{ matrix.node }} 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node }} 26 | cache: 'npm' 27 | 28 | - name: Install dependencies 29 | run: npm ci 30 | 31 | - name: Lint (optional) 32 | if: ${{ always() }} 33 | run: | 34 | npm run lint || echo "Lint warnings/errors (non-blocking)" 35 | 36 | - name: Unit tests 37 | env: 38 | DEBUG_CDP_TEST: 'true' 39 | run: npm test 40 | 41 | - name: E2E (MCP stdio) tests 42 | env: 43 | DEBUG_CDP_TEST: 'true' 44 | run: npm run test:e2e 45 | 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 ScriptedAlchemy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "devtools-debugger-mcp", 3 | "version": "0.0.1", 4 | "description": "MCP server exposing full Chrome DevTools Protocol debugging: breakpoints, stepping, call stacks, eval, and source maps. Also supports tab control, JS execution, screenshots, and network monitoring.", 5 | "main": "dist/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "build": "tsc", 9 | "start": "node dist/index.js", 10 | "dev": "ts-node --esm src/index.ts", 11 | "watch": "tsc --watch", 12 | "prepare": "npm run build", 13 | "test": "DEBUG_CDP_TEST=true node --experimental-strip-types --test \"tests/**/*.ts\"", 14 | "test:e2e": "npm run build && DEBUG_CDP_TEST=true timeout 120 node --experimental-strip-types --test tests/mcp-e2e.test.ts", 15 | "lint": "eslint src --ext .ts", 16 | "format": "prettier --write \"src/**/*.ts\"" 17 | }, 18 | "bin": { 19 | "devtools-debugger-mcp": "./dist/index.js" 20 | }, 21 | "files": [ 22 | "dist", 23 | "README.md", 24 | "LICENSE" 25 | ], 26 | "keywords": [ 27 | "mcp", 28 | "chrome", 29 | "devtools", 30 | "debugger", 31 | "breakpoints", 32 | "stepping", 33 | "call-stacks", 34 | "cdp", 35 | "automation", 36 | "testing", 37 | "screenshots", 38 | "network-monitoring", 39 | "browser-automation", 40 | "chrome-devtools-protocol" 41 | ], 42 | "author": { 43 | "name": "ScriptedAlchemy", 44 | "url": "https://github.com/ScriptedAlchemy" 45 | }, 46 | "repository": { 47 | "type": "git", 48 | "url": "git+https://github.com/ScriptedAlchemy/devtools-debugger-mcp.git" 49 | }, 50 | "bugs": { 51 | "url": "https://github.com/ScriptedAlchemy/devtools-debugger-mcp/issues" 52 | }, 53 | "homepage": "https://github.com/ScriptedAlchemy/devtools-debugger-mcp#readme", 54 | "license": "MIT", 55 | "dependencies": { 56 | "@modelcontextprotocol/sdk": "^1.5.0", 57 | "chrome-remote-interface": "^0.33.2", 58 | "ts-node": "^10.9.2", 59 | "typescript": "^5.7.3", 60 | "zod": "^3.24.2" 61 | }, 62 | "devDependencies": { 63 | "@types/chrome-remote-interface": "^0.31.14", 64 | "@types/node": "^22.7.5" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/mcp-e2e.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import assert from 'node:assert'; 3 | import path from 'node:path'; 4 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 5 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 6 | 7 | function parseTextContent(callToolResult: any) { 8 | const block = (callToolResult.content || []).find((c: any) => c.type === 'text'); 9 | assert.ok(block, 'expected text content block'); 10 | const txt = block.text || ''; 11 | const trimmed = txt.trim(); 12 | return JSON.parse(trimmed); 13 | } 14 | 15 | test('MCP e2e: start, inspect_scopes, get_object_properties (text mode)', { timeout: 110000 }, async () => { 16 | const transport = new StdioClientTransport({ command: 'node', args: ['dist/src/index.js'], stderr: 'pipe' }); 17 | const client = new Client({ name: 'mcp-e2e-test', version: '0.0.0' }); 18 | const reqOpts = { timeout: 100000 } as const; 19 | try { 20 | await client.connect(transport); 21 | if (transport.stderr) { 22 | transport.stderr.on('data', (chunk: Buffer) => { 23 | const s = chunk.toString(); 24 | // eslint-disable-next-line no-console 25 | console.log('[server-stderr]', s.trim()); 26 | }); 27 | } 28 | 29 | await client.ping({ timeout: 5000 }); 30 | 31 | // No global format changes; each call specifies its own format 32 | 33 | const scriptPath = path.resolve('tests/fixtures/advanced-script.js'); 34 | const startRes = await client.callTool({ name: 'start_node_debug', arguments: { scriptPath, format: 'text' } }, undefined, reqOpts); 35 | const startPayload = parseTextContent(startRes); 36 | assert.ok(startPayload.pauseId, 'has pauseId'); 37 | 38 | // Resume from Break on start to the next debugger pause inside inc(step) 39 | const resumeRes = await client.callTool({ name: 'resume_execution', arguments: { includeConsole: true, format: 'text' } }, undefined, reqOpts); 40 | const resumePayload = parseTextContent(resumeRes); 41 | const pauseId = resumePayload.pauseId ?? startPayload.pauseId; 42 | 43 | const scopesRes = await client.callTool({ name: 'inspect_scopes', arguments: { pauseId, format: 'text', maxProps: 20 } }, undefined, reqOpts); 44 | const scopes = parseTextContent(scopesRes); 45 | const closure = (scopes.scopes || []).find((s: any) => s.type === 'closure'); 46 | assert.ok(closure, 'closure scope present'); 47 | const metaVar = (closure.variables || []).find((v: any) => v.name === 'meta'); 48 | assert.ok(metaVar && metaVar.objectId, 'meta objectId available'); 49 | 50 | const propsRes = await client.callTool({ name: 'get_object_properties', arguments: { objectId: metaVar.objectId, format: 'text', maxProps: 50 } }, undefined, reqOpts); 51 | const props = parseTextContent(propsRes).properties || []; 52 | const tag = props.find((p: any) => p.name === 'tag'); 53 | assert.equal(tag && tag.value, 'C'); 54 | const nested = props.find((p: any) => p.name === 'nested'); 55 | assert.ok(nested && nested.objectId, 'nested objectId available'); 56 | 57 | const nestedRes = await client.callTool({ name: 'get_object_properties', arguments: { objectId: nested.objectId, format: 'text' } }, undefined, reqOpts); 58 | const nestedProps = parseTextContent(nestedRes).properties || []; 59 | const aProp = nestedProps.find((p: any) => p.name === 'a'); 60 | assert.equal(aProp && aProp.value, 1); 61 | } finally { 62 | try { await client.close(); } catch {} 63 | try { await transport.close(); } catch {} 64 | } 65 | }); 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node.js Debugger MCP 2 | 3 | An MCP server that provides comprehensive Node.js debugging capabilities using the Chrome DevTools Protocol. This server enables AI assistants to debug Node.js applications with full access to breakpoints, stepping, variable inspection, call stacks, expression evaluation, and source maps. 4 | 5 | ## Why use this MCP server? 6 | This MCP server is useful when you need AI assistance with debugging Node.js applications. It provides programmatic access to all the debugging features you'd find in Chrome DevTools or VS Code, allowing AI assistants to help you set breakpoints, inspect variables, step through code, and analyze runtime behavior. 7 | 8 | ## Features 9 | 10 | - **Full Node.js debugger**: Set breakpoints, conditional breakpoints, logpoints, and pause-on-exceptions 11 | - **Stepping controls**: Step over/into/out, continue to location, restart frame 12 | - **Variable inspection**: Explore locals/closure scopes, `this` preview, and drill down into object properties 13 | - **Expression evaluation**: Evaluate JavaScript expressions in the current call frame with console output capture 14 | - **Call stack analysis**: Inspect call stacks and pause-state information 15 | - **Source map support**: Debug TypeScript and other transpiled code with full source map support 16 | - **Console monitoring**: Capture and review console output during debugging sessions 17 | 18 | ## Installation 19 | 20 | ```bash 21 | npm install devtools-debugger-mcp 22 | ``` 23 | 24 | ## Configuration 25 | 26 | Add the server to your MCP settings configuration: 27 | 28 | ```json 29 | { 30 | "devtools-debugger-mcp": { 31 | "command": "node", 32 | "args": ["path/to/devtools-debugger-mcp/dist/index.js"] 33 | } 34 | } 35 | ``` 36 | 37 | Alternatively, if installed globally, you can use the CLI binary: 38 | 39 | ```json 40 | { 41 | "devtools-debugger-mcp": { 42 | "command": "devtools-debugger-mcp" 43 | } 44 | } 45 | ``` 46 | 47 | ## Node.js Debugging 48 | 49 | This MCP server can debug Node.js programs by launching your script with the built‑in inspector (`--inspect-brk=0`) and speaking the Chrome DevTools Protocol (CDP). 50 | 51 | How it works 52 | - `start_node_debug` spawns `node --inspect-brk=0 your-script.js`, waits for the inspector WebSocket, attaches, and returns the initial pause (first line) with a `pauseId` and top call frame. 53 | - You can then set breakpoints (by file path or URL regex), choose pause-on-exceptions, and resume/step. At each pause, tools can inspect scopes, evaluate expressions, and read console output captured since the last step/resume. 54 | - When the process exits, the server cleans up the CDP session and resets its state. 55 | 56 | Quickstart (from an MCP-enabled client) 57 | 1) Start a debug session 58 | ```json 59 | { "tool": "start_node_debug", "params": { "scriptPath": "/absolute/path/to/app.js" } } 60 | ``` 61 | 2) Set a breakpoint (file path + 1-based line) 62 | ```json 63 | { "tool": "set_breakpoint", "params": { "filePath": "/absolute/path/to/app.js", "line": 42 } } 64 | ``` 65 | 3) Run to next pause (optionally include console/stack) 66 | ```json 67 | { "tool": "resume_execution", "params": { "includeConsole": true, "includeStack": true } } 68 | ``` 69 | 4) Inspect at a pause 70 | ```json 71 | { "tool": "inspect_scopes", "params": { "maxProps": 15 } } 72 | { "tool": "evaluate_expression", "params": { "expr": "user.name" } } 73 | ``` 74 | 5) Step 75 | ```json 76 | { "tool": "step_over" } 77 | { "tool": "step_into" } 78 | { "tool": "step_out" } 79 | ``` 80 | 6) Finish 81 | ```json 82 | { "tool": "stop_debug_session" } 83 | ``` 84 | 85 | Node.js tool reference (summary) 86 | - `start_node_debug({ scriptPath, format? })` — Launches Node with inspector and returns initial pause. 87 | - `set_breakpoint({ filePath, line })` — Breakpoint by file path (1-based line). 88 | - `set_breakpoint_condition({ filePath?, urlRegex?, line, column?, condition, format? })` — Conditional breakpoint or by URL regex. 89 | - `add_logpoint({ filePath?, urlRegex?, line, column?, message, format? })` — Logpoint via conditional breakpoint that logs and returns `false`. 90 | - `set_exception_breakpoints({ state })` — `none | uncaught | all`. 91 | - `blackbox_scripts({ patterns })` — Ignore frames from matching script URLs. 92 | - `list_scripts()` / `get_script_source({ scriptId? | url? })` — Discover and fetch script sources. 93 | - `continue_to_location({ filePath, line, column? })` — Run until a specific source location. 94 | - `restart_frame({ frameIndex, pauseId?, format? })` — Re-run the selected frame. 95 | - `resume_execution({ includeScopes?, includeStack?, includeConsole?, format? })` — Continue to next pause or exit. 96 | - `step_over|step_into|step_out({ includeScopes?, includeStack?, includeConsole?, format? })` — Stepping with optional context in the result. 97 | - `evaluate_expression({ expr, pauseId?, frameIndex?, returnByValue?, format? })` — Evaluate in a paused frame; defaults to top frame. 98 | - `inspect_scopes({ maxProps?, pauseId?, frameIndex?, includeThisPreview?, format? })` — Locals/closures and `this` summary. 99 | - `get_object_properties({ objectId, maxProps?, format? })` — Drill into object previews. 100 | - `list_call_stack({ depth?, pauseId?, includeThis?, format? })` — Top N frames summary. 101 | - `get_pause_info({ pauseId?, format? })` — Pause reason/location summary. 102 | - `read_console({ format? })` — Console messages since the last step/resume. 103 | - `stop_debug_session()` — Kill process and detach. 104 | 105 | Notes 106 | - File paths are converted to `file://` URLs internally for CDP compatibility. 107 | - `line` is 1-based; CDP is 0-based internally. 108 | - The server buffers console output between pauses; fetch via `includeConsole` on step/resume or with `read_console`. 109 | - Use `set_output_format({ format: 'text' | 'json' | 'both' })` to set default response formatting. 110 | 111 | 112 | 113 | ## Available Tools 114 | 115 | This MCP server provides the following Node.js debugging tools. All tools support optional `format` parameter (`'text'` or `'json'`) to control response formatting. 116 | 117 | ### Session Management 118 | - **`start_node_debug`** - Launch a Node.js script with debugging enabled 119 | - **`stop_debug_session`** - Terminate the debugging session and clean up 120 | 121 | ### Breakpoint Management 122 | - **`set_breakpoint`** - Set a breakpoint at a specific file and line 123 | - **`set_breakpoint_condition`** - Set a conditional breakpoint or breakpoint by URL regex 124 | - **`add_logpoint`** - Add a logpoint that logs messages when hit 125 | - **`set_exception_breakpoints`** - Configure pause-on-exception behavior 126 | 127 | ### Execution Control 128 | - **`resume_execution`** - Continue execution to the next breakpoint or completion 129 | - **`step_over`** - Step over the current line 130 | - **`step_into`** - Step into function calls 131 | - **`step_out`** - Step out of the current function 132 | - **`continue_to_location`** - Run until reaching a specific location 133 | - **`restart_frame`** - Restart execution from a specific call frame 134 | 135 | ### Inspection and Analysis 136 | - **`inspect_scopes`** - Examine local variables, closures, and `this` context 137 | - **`evaluate_expression`** - Evaluate JavaScript expressions in the current context 138 | - **`get_object_properties`** - Drill down into object properties 139 | - **`list_call_stack`** - View the current call stack 140 | - **`get_pause_info`** - Get information about the current pause state 141 | 142 | ### Utilities 143 | - **`list_scripts`** - List all loaded scripts 144 | - **`get_script_source`** - Retrieve source code for scripts 145 | - **`blackbox_scripts`** - Configure scripts to skip during debugging 146 | - **`read_console`** - Read console output captured during debugging 147 | 148 | For detailed usage examples and parameter descriptions, see the "Node.js Debugging" section above. 149 | 150 | ## License 151 | 152 | MIT 153 | -------------------------------------------------------------------------------- /tests/node-debug-advanced.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import assert from 'node:assert'; 3 | import { spawn, ChildProcess } from 'node:child_process'; 4 | import CDP from 'chrome-remote-interface'; 5 | type Client = any; 6 | import path from 'node:path'; 7 | 8 | const scriptPath = path.resolve('tests/fixtures/advanced-script.js'); 9 | const debug = !!process.env.DEBUG_CDP_TEST; 10 | const log = (...args: unknown[]) => { if (debug) console.log(...args); }; 11 | 12 | function mcpCall(tool: string, params: unknown) { 13 | if (!debug) return; 14 | console.log(JSON.stringify({ event: 'mcp.tool_call', tool, params }, null, 2)); 15 | } 16 | function mcpResult(tool: string, payloadObj: unknown) { 17 | if (!debug) return; 18 | const response = { content: [ 19 | { type: 'text', text: JSON.stringify(payloadObj, null, 2) }, 20 | { type: 'json', json: payloadObj } 21 | ] }; 22 | console.log(JSON.stringify({ event: 'mcp.tool_result', tool, response }, null, 2)); 23 | } 24 | 25 | async function startDebuggedProcess(file: string): Promise<{ proc: ChildProcess, port: number }>{ 26 | const proc = spawn('node', ['--inspect-brk=0', file], { 27 | stdio: ['ignore', 'pipe', 'pipe'], 28 | env: { ...process.env, NODE_OPTIONS: '' } 29 | }); 30 | const port = await new Promise((resolve, reject) => { 31 | let resolved = false; 32 | proc.stderr?.on('data', (data: Buffer) => { 33 | const msg = data.toString(); 34 | const match = msg.match(/ws:\/\/127\.0\.0\.1:(\d+)/); 35 | if (match) { resolved = true; resolve(Number(match[1])); } 36 | }); 37 | proc.on('exit', () => { if (!resolved) reject(new Error('process exited early')); }); 38 | }); 39 | return { proc, port }; 40 | } 41 | 42 | function frameUrl(frame: any, parsedScripts: any[]): string { 43 | const sid = frame && frame.location && frame.location.scriptId; 44 | const match = parsedScripts.find((ev) => ev.scriptId === sid && ev.url); 45 | return (frame && frame.url) || (match && match.url) || ''; 46 | } 47 | 48 | async function getProps(Runtime: Client['Runtime'], objectId: string, limit = 20) { 49 | if (!objectId) return []; 50 | const { result } = await Runtime.getProperties({ objectId, ownProperties: true, generatePreview: true }); 51 | return (result || []).slice(0, limit).map((p: any) => { 52 | const v = p.value || {}; 53 | const val = Object.prototype.hasOwnProperty.call(v, 'value') ? v.value : (v.description || v.type); 54 | return { name: p.name, type: v.type, value: val, objectId: v.objectId }; 55 | }); 56 | } 57 | 58 | async function collectScopes(Runtime: Client['Runtime'], frame: any, parsedScripts: any[]) { 59 | const scopes: any[] = []; 60 | for (const s of frame.scopeChain || []) { 61 | const type = s.type; 62 | if (!s.object || !s.object.objectId) continue; 63 | const variables = await getProps(Runtime, s.object.objectId, type === 'global' ? 5 : 20); 64 | scopes.push({ type, variables, truncated: type === 'global' }); 65 | } 66 | let thisSummary: any = null; 67 | if (frame.this) { 68 | const t = frame.this; 69 | thisSummary = { type: t.type, description: t.description, className: t.className }; 70 | if (t.objectId) thisSummary.preview = await getProps(Runtime, t.objectId, 8); 71 | } 72 | return { 73 | frame: { 74 | functionName: frame.functionName || null, 75 | url: frameUrl(frame, parsedScripts), 76 | line: frame.location.lineNumber + 1, 77 | column: (frame.location.columnNumber ?? 0) + 1 78 | }, 79 | this: thisSummary, 80 | scopes 81 | }; 82 | } 83 | 84 | test('advanced: scopes, this, arguments, and call stack across steps', { timeout: 30000 }, async () => { 85 | const { proc, port } = await startDebuggedProcess(scriptPath); 86 | const client: Client = await CDP({ host: '127.0.0.1', port }); 87 | const { Debugger, Runtime } = client; 88 | 89 | const parsedScripts: any[] = []; 90 | const unScript = Debugger.scriptParsed((ev: any) => parsedScripts.push(ev)); 91 | const consoleBuf: string[] = []; 92 | const unConsole = Runtime.consoleAPICalled(({ type, args }: any) => { 93 | const text = (args || []).map((a: any) => (Object.prototype.hasOwnProperty.call(a, 'value') ? a.value : a.description)).join(' '); 94 | consoleBuf.push(`[${type}] ${text}`); 95 | }); 96 | 97 | try { 98 | await Debugger.enable(); 99 | await Runtime.enable(); 100 | 101 | const onStart = Debugger.paused(); 102 | await Runtime.runIfWaitingForDebugger(); 103 | await onStart; 104 | const pause1P = Debugger.paused(); 105 | await Debugger.resume(); 106 | const pause1 = await pause1P; 107 | const f1 = pause1.callFrames[0]; 108 | assert.equal(f1.functionName, 'inc'); 109 | 110 | mcpCall('inspect_scopes', { maxProps: 15 }); 111 | const scope1 = await collectScopes(Runtime, f1, parsedScripts); 112 | mcpResult('inspect_scopes', scope1); 113 | log('pause1', scope1); 114 | 115 | const names1 = scope1.scopes.reduce((acc: string[], s: any) => acc.concat((s.variables || []).map((v: any) => v.name)), []); 116 | assert.ok(names1.includes('before') && names1.includes('s')); 117 | const sVar = scope1.scopes.find((s: any) => s.type === 'local').variables.find((v: any) => v.name === 's'); 118 | const countVar = scope1.scopes.flatMap((s: any) => s.variables || []).find((v: any) => v.name === 'count'); 119 | assert.equal(sVar && sVar.value, 2); 120 | assert.equal(countVar && countVar.value, 12); 121 | 122 | const cfId = f1.callFrameId; 123 | const a0 = await Debugger.evaluateOnCallFrame({ callFrameId: cfId, expression: 'arguments[0]', returnByValue: true }); 124 | const metaNested = await Debugger.evaluateOnCallFrame({ callFrameId: cfId, expression: 'meta.nested.a', returnByValue: true }); 125 | mcpCall('evaluate_expression', { expr: 'arguments[0]' }); 126 | mcpResult('evaluate_expression', { result: a0.result.value, consoleOutput: [] }); 127 | mcpCall('evaluate_expression', { expr: 'meta.nested.a' }); 128 | mcpResult('evaluate_expression', { result: metaNested.result.value, consoleOutput: [] }); 129 | assert.equal(a0.result.value, 2); 130 | assert.equal(metaNested.result.value, 1); 131 | 132 | const onAfterOut = Debugger.paused(); 133 | mcpCall('step_out', {}); 134 | await Debugger.stepOut(); 135 | const pause2 = await onAfterOut; 136 | const f2 = pause2.callFrames[0]; 137 | mcpResult('step_out', { status: `Paused at ${frameUrl(f2, parsedScripts)}:${f2.location.lineNumber + 1} (reason: ${pause2.reason})`, consoleOutput: consoleBuf.splice(0) }); 138 | 139 | const stack2 = (pause2.callFrames || []).slice(0, 5).map((cf: any) => ({ functionName: cf.functionName || null, url: frameUrl(cf, parsedScripts), line: cf.location.lineNumber + 1 })); 140 | mcpCall('list_call_stack', { depth: 5 }); 141 | mcpResult('list_call_stack', { frames: stack2 }); 142 | 143 | const pause3P = Debugger.paused(); 144 | await Debugger.resume(); 145 | const pause3 = await pause3P; 146 | const f3 = pause3.callFrames[0]; 147 | assert.equal(f3.functionName, 'times'); 148 | 149 | mcpCall('inspect_scopes', { maxProps: 10 }); 150 | const scope3 = await collectScopes(Runtime, f3, parsedScripts); 151 | mcpResult('inspect_scopes', scope3); 152 | const local3 = scope3.scopes.find((s: any) => s.type === 'local'); 153 | const nVar = local3 && local3.variables.find((v: any) => v.name === 'n'); 154 | assert.equal(nVar && nVar.value, 5); 155 | const thisPreview = scope3.this && scope3.this.preview; 156 | const multProp = (thisPreview || []).find((p: any) => p.name === 'mult'); 157 | assert.equal(multProp && multProp.value, 3); 158 | 159 | const thisMult = await Debugger.evaluateOnCallFrame({ callFrameId: f3.callFrameId, expression: 'this.mult', returnByValue: true }); 160 | mcpCall('evaluate_expression', { expr: 'this.mult' }); 161 | mcpResult('evaluate_expression', { result: thisMult.result.value, consoleOutput: [] }); 162 | assert.equal(thisMult.result.value, 3); 163 | 164 | const pause4P = Debugger.paused(); 165 | mcpCall('step_over', {}); 166 | await Debugger.stepOver(); 167 | const pause4 = await pause4P; 168 | const f4 = pause4.callFrames[0]; 169 | mcpResult('step_over', { status: `Paused at ${frameUrl(f4, parsedScripts)}:${f4.location.lineNumber + 1} (reason: ${pause4.reason})`, consoleOutput: consoleBuf.splice(0) }); 170 | 171 | await Debugger.resume(); 172 | try { await client.close(); } catch {} 173 | await new Promise((r) => proc.once('exit', r)); 174 | } finally { 175 | try { await client.close(); } catch {} 176 | if (!proc.killed) { try { proc.kill('SIGKILL'); } catch {} } 177 | try { if (typeof unScript === 'function') unScript(); } catch {} 178 | try { if (typeof unConsole === 'function') unConsole(); } catch {} 179 | } 180 | }); 181 | -------------------------------------------------------------------------------- /tests/node-debug.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import assert from 'node:assert'; 3 | import { spawn, ChildProcess } from 'node:child_process'; 4 | import CDP from 'chrome-remote-interface'; 5 | type Client = any; 6 | import path from 'node:path'; 7 | 8 | const scriptPath = path.resolve('tests/fixtures/sample-script.js'); 9 | const debug = !!process.env.DEBUG_CDP_TEST; 10 | const debugLog = (...args: unknown[]) => { if (debug) console.log(...args); }; 11 | 12 | async function startDebuggedProcess(): Promise<{ proc: ChildProcess, port: number }>{ 13 | const proc = spawn('node', ['--inspect-brk=0', scriptPath], { 14 | stdio: ['ignore', 'pipe', 'pipe'], 15 | env: { ...process.env, NODE_OPTIONS: '' } 16 | }); 17 | 18 | const port: number = await new Promise((resolve, reject) => { 19 | let resolved = false; 20 | proc.stderr?.on('data', (data: Buffer) => { 21 | const msg = data.toString(); 22 | const match = msg.match(/ws:\/\/127\.0\.0\.1:(\d+)/); 23 | if (match) { 24 | resolved = true; 25 | resolve(Number(match[1])); 26 | } 27 | }); 28 | proc.on('exit', () => { 29 | if (!resolved) reject(new Error('process exited early')); 30 | }); 31 | }); 32 | 33 | return { proc, port }; 34 | } 35 | 36 | function resolveFrameUrl(frame: any, parsedScripts: any[]): string { 37 | const sid = frame && frame.location && frame.location.scriptId; 38 | const match = parsedScripts.find((ev) => ev.scriptId === sid && ev.url); 39 | return (frame && frame.url) || (match && match.url) || ''; 40 | } 41 | 42 | async function dumpLocalScope(Runtime: Client['Runtime'], callFrame: any, { only }: { only?: string[] } = {}) { 43 | try { 44 | const localScope = (callFrame.scopeChain || []).find((s: any) => s.type === 'local'); 45 | if (!localScope || !localScope.object || !localScope.object.objectId) { 46 | debugLog('locals: '); 47 | return; 48 | } 49 | const { result } = await Runtime.getProperties({ 50 | objectId: localScope.object.objectId, 51 | ownProperties: true, 52 | accessorPropertiesOnly: false, 53 | generatePreview: true 54 | }); 55 | const props = (result || []) 56 | .filter((p: any) => p && p.enumerable && p.name !== 'arguments') 57 | .filter((p: any) => !only || only.includes(p.name)) 58 | .map((p: any) => { 59 | const v: any = p.value || {}; 60 | const val = Object.prototype.hasOwnProperty.call(v, 'value') ? v.value : (v.description || v.type); 61 | return `${p.name}=${JSON.stringify(val)}`; 62 | }); 63 | debugLog('locals:', props.join(', ')); 64 | } catch (e: any) { 65 | debugLog('locals: ', e && e.message ? e.message : e); 66 | } 67 | } 68 | 69 | // MCP-style logging helpers (simulated) 70 | function mcpLogCall(tool: string, params: unknown) { 71 | if (!debug) return; 72 | console.log(JSON.stringify({ event: 'mcp.tool_call', tool, params }, null, 2)); 73 | } 74 | function mcpLogResult(tool: string, payloadObj: unknown) { 75 | if (!debug) return; 76 | const response = { content: [ 77 | { type: 'text', text: JSON.stringify(payloadObj, null, 2) }, 78 | { type: 'json', json: payloadObj } 79 | ] }; 80 | console.log(JSON.stringify({ event: 'mcp.tool_result', tool, response }, null, 2)); 81 | } 82 | 83 | test('debugger hits breakpoint', { timeout: 30000 }, async () => { 84 | const { proc, port } = await startDebuggedProcess(); 85 | const client = await CDP({ host: '127.0.0.1', port }); 86 | if (debug) { 87 | // eslint-disable-next-line no-console 88 | (client as any).on('event', (message: any) => { 89 | const { method, params } = message as any; 90 | const maybeUrl = params && (params.url || params.scriptId || params.reason); 91 | console.log('event', method, maybeUrl || ''); 92 | }); 93 | } 94 | const { Debugger, Runtime } = client; 95 | 96 | let unlistenScriptParsed: any; 97 | try { 98 | debugLog('connected to CDP on port', port); 99 | const parsedScripts: any[] = []; 100 | unlistenScriptParsed = (Debugger as any).scriptParsed((ev: any) => { parsedScripts.push(ev); }); 101 | 102 | await Debugger.enable(); 103 | await Runtime.enable(); 104 | debugLog('Debugger and Runtime enabled'); 105 | 106 | const bpByUrl = await Debugger.setBreakpointByUrl({ urlRegex: 'sample-script\\.js$', lineNumber: 3, columnNumber: 0 }); 107 | debugLog('setBreakpointByUrl(pre-run)', bpByUrl); 108 | 109 | const firstPause = Debugger.paused(); 110 | await Runtime.runIfWaitingForDebugger(); 111 | await firstPause; 112 | debugLog('initial pause observed'); 113 | 114 | const target = parsedScripts.find((ev) => ev.url && ev.url.includes('sample-script.js')); 115 | const targetScriptId = target && target.scriptId; 116 | if (target) debugLog('scriptParsed', target.url); 117 | debugLog('targetScriptId', targetScriptId); 118 | 119 | const bpResult = await Debugger.setBreakpoint({ location: { scriptId: targetScriptId, lineNumber: 3, columnNumber: 0 } }); 120 | debugLog('setBreakpoint', bpResult); 121 | 122 | const pausedAtBreakpoint = Debugger.paused(); 123 | const unlistenPaused = Debugger.paused((ev: any) => { 124 | if (!debug) return; 125 | try { 126 | const fr = ev.callFrames && ev.callFrames[0]; 127 | const loc = fr && fr.location; 128 | debugLog('paused (listener)', ev.reason || '', loc ? `${loc.scriptId}:${loc.lineNumber}:${loc.columnNumber}` : ''); 129 | } catch {} 130 | }); 131 | 132 | await Debugger.resume(); 133 | debugLog('resumed from initial pause, waiting for breakpoint'); 134 | 135 | const bpPause = await pausedAtBreakpoint; 136 | try { if (typeof unlistenPaused === 'function') unlistenPaused(); } catch {} 137 | const topFrame = bpPause.callFrames[0]; 138 | debugLog('paused(bp)', bpPause.reason, topFrame.location); 139 | assert.equal(topFrame.location.lineNumber + 1, 4); 140 | 141 | await dumpLocalScope(Runtime, topFrame, { only: ['a', 'b', 'sum'] }); 142 | 143 | mcpLogCall('get_pause_info', {}); 144 | mcpLogResult('get_pause_info', { 145 | reason: bpPause.reason, 146 | location: { 147 | url: resolveFrameUrl(topFrame, parsedScripts), 148 | line: topFrame.location.lineNumber + 1, 149 | column: (topFrame.location.columnNumber ?? 0) + 1 150 | }, 151 | functionName: topFrame.functionName || null, 152 | scopeTypes: (topFrame.scopeChain || []).map((s: any) => s.type) 153 | }); 154 | mcpLogCall('list_call_stack', { depth: 5 }); 155 | mcpLogResult('list_call_stack', { 156 | frames: (bpPause.callFrames || []).slice(0, 5).map((f: any) => ({ 157 | functionName: f.functionName || null, 158 | url: resolveFrameUrl(f, parsedScripts), 159 | line: f.location.lineNumber + 1, 160 | column: (f.location.columnNumber ?? 0) + 1 161 | })) 162 | }); 163 | 164 | await Debugger.resume(); 165 | try { await client.close(); } catch {} 166 | await new Promise((resolve) => proc.once('exit', resolve)); 167 | } finally { 168 | try { await client.close(); } catch {} 169 | if (!proc.killed) { try { proc.kill('SIGKILL'); } catch {} } 170 | try { if (typeof unlistenScriptParsed === 'function') unlistenScriptParsed(); } catch {} 171 | } 172 | }); 173 | 174 | test('inspect variables at breakpoint and step over', { timeout: 25000 }, async () => { 175 | const { proc, port } = await startDebuggedProcess(); 176 | const client: Client = await CDP({ host: '127.0.0.1', port }); 177 | const { Debugger, Runtime } = client; 178 | 179 | const parsedScripts: any[] = []; 180 | const unlistenScriptParsed = Debugger.scriptParsed((ev: any) => parsedScripts.push(ev)); 181 | 182 | try { 183 | await Debugger.enable(); 184 | await Runtime.enable(); 185 | 186 | const firstPause = Debugger.paused(); 187 | await Runtime.runIfWaitingForDebugger(); 188 | await firstPause; 189 | 190 | const target = parsedScripts.find((ev) => ev.url && ev.url.includes('sample-script.js')); 191 | assert.ok(target, 'target script discovered'); 192 | const targetScriptId = target.scriptId; 193 | 194 | const bp = await Debugger.setBreakpoint({ location: { scriptId: targetScriptId, lineNumber: 3, columnNumber: 0 } }); 195 | debugLog('setBreakpoint(vars)', bp); 196 | 197 | const pausedAtBpP = Debugger.paused(); 198 | await Debugger.resume(); 199 | const pausedAtBp = await pausedAtBpP; 200 | 201 | const top = pausedAtBp.callFrames[0]; 202 | assert.equal(top.location.lineNumber + 1, 4); 203 | 204 | const callFrameId = top.callFrameId; 205 | const evalA = await Debugger.evaluateOnCallFrame({ callFrameId, expression: 'a', returnByValue: true }); 206 | const evalB = await Debugger.evaluateOnCallFrame({ callFrameId, expression: 'b', returnByValue: true }); 207 | const evalSum = await Debugger.evaluateOnCallFrame({ callFrameId, expression: 'sum', returnByValue: true }); 208 | mcpLogCall('evaluate_expression', { expr: 'a' }); 209 | mcpLogResult('evaluate_expression', { result: evalA.result.value, consoleOutput: [] }); 210 | mcpLogCall('evaluate_expression', { expr: 'b' }); 211 | mcpLogResult('evaluate_expression', { result: evalB.result.value, consoleOutput: [] }); 212 | mcpLogCall('evaluate_expression', { expr: 'sum' }); 213 | mcpLogResult('evaluate_expression', { result: evalSum.result.value, consoleOutput: [] }); 214 | debugLog('eval a =', evalA.result?.value); 215 | debugLog('eval b =', evalB.result?.value); 216 | debugLog('eval sum =', evalSum.result?.value); 217 | await dumpLocalScope(Runtime, top, { only: ['a', 'b', 'sum'] }); 218 | assert.equal(evalA.result.value, 2); 219 | assert.equal(evalB.result.value, 3); 220 | assert.equal(evalSum.result.value, 5); 221 | 222 | const stepPauseP = Debugger.paused(); 223 | mcpLogCall('step_over', {}); 224 | await Debugger.stepOver(); 225 | const stepPause = await stepPauseP; 226 | const topAfterStep = stepPause.callFrames[0]; 227 | assert.equal(topAfterStep.location.lineNumber + 1, 5, 'stepped to return line'); 228 | mcpLogResult('step_over', { 229 | status: `Paused at ${resolveFrameUrl(topAfterStep, parsedScripts)}:${topAfterStep.location.lineNumber + 1} (reason: ${stepPause.reason})`, 230 | consoleOutput: [] 231 | }); 232 | const callFrameId2 = topAfterStep.callFrameId; 233 | const evalSum2 = await Debugger.evaluateOnCallFrame({ callFrameId: callFrameId2, expression: 'sum', returnByValue: true }); 234 | debugLog('after stepOver, sum =', evalSum2.result?.value); 235 | await dumpLocalScope(Runtime, topAfterStep, { only: ['sum'] }); 236 | 237 | const stepOutPauseP = Debugger.paused(); 238 | await Debugger.stepOut(); 239 | const stepOutPause = await stepOutPauseP; 240 | const afterOut = stepOutPause.callFrames[0]; 241 | assert.equal(afterOut.location.lineNumber + 1, 8, 'stepped out to next statement after call'); 242 | const callFrameId3 = afterOut.callFrameId; 243 | const ta = await Debugger.evaluateOnCallFrame({ callFrameId: callFrameId3, expression: 'typeof a', returnByValue: true }); 244 | const tb = await Debugger.evaluateOnCallFrame({ callFrameId: callFrameId3, expression: 'typeof b', returnByValue: true }); 245 | const ts = await Debugger.evaluateOnCallFrame({ callFrameId: callFrameId3, expression: 'typeof sum', returnByValue: true }); 246 | debugLog('after stepOut, typeof a =', ta.result?.value); 247 | debugLog('after stepOut, typeof b =', tb.result?.value); 248 | debugLog('after stepOut, typeof sum =', ts.result?.value); 249 | 250 | await Debugger.resume(); 251 | try { await client.close(); } catch {} 252 | await new Promise((resolve) => proc.once('exit', resolve)); 253 | } finally { 254 | try { await client.close(); } catch {} 255 | if (!proc.killed) { try { proc.kill('SIGKILL'); } catch {} } 256 | try { if (typeof unlistenScriptParsed === 'function') unlistenScriptParsed(); } catch {} 257 | } 258 | }); 259 | 260 | test('step into function call and verify parameters', { timeout: 25000 }, async () => { 261 | const { proc, port } = await startDebuggedProcess(); 262 | const client: Client = await CDP({ host: '127.0.0.1', port }); 263 | const { Debugger, Runtime } = client; 264 | 265 | const parsedScripts: any[] = []; 266 | const unlistenScriptParsed = Debugger.scriptParsed((ev: any) => parsedScripts.push(ev)); 267 | 268 | try { 269 | await Debugger.enable(); 270 | await Runtime.enable(); 271 | 272 | const firstPause = Debugger.paused(); 273 | await Runtime.runIfWaitingForDebugger(); 274 | await firstPause; 275 | 276 | const target = parsedScripts.find((ev) => ev.url && ev.url.includes('sample-script.js')); 277 | assert.ok(target, 'target script discovered'); 278 | const targetScriptId = target.scriptId; 279 | 280 | await Debugger.setBreakpoint({ location: { scriptId: targetScriptId, lineNumber: 6, columnNumber: 0 } }); 281 | 282 | const pauseAtCallP = Debugger.paused(); 283 | await Debugger.resume(); 284 | const pauseAtCall = await pauseAtCallP; 285 | const topAtCall = pauseAtCall.callFrames[0]; 286 | assert.equal(topAtCall.location.lineNumber + 1, 7); 287 | 288 | const pauseInsideP = Debugger.paused(); 289 | mcpLogCall('step_into', {}); 290 | await Debugger.stepInto(); 291 | const pauseInside = await pauseInsideP; 292 | const insideTop = pauseInside.callFrames[0]; 293 | assert.equal(insideTop.location.lineNumber + 1, 3); 294 | mcpLogResult('step_into', { status: `Paused at ${resolveFrameUrl(insideTop, parsedScripts)}:${insideTop.location.lineNumber + 1} (reason: ${pauseInside.reason})`, consoleOutput: [] }); 295 | 296 | const callFrameId = insideTop.callFrameId; 297 | const aVal = await Debugger.evaluateOnCallFrame({ callFrameId, expression: 'a', returnByValue: true }); 298 | const bVal = await Debugger.evaluateOnCallFrame({ callFrameId, expression: 'b', returnByValue: true }); 299 | mcpLogCall('evaluate_expression', { expr: 'a' }); 300 | mcpLogResult('evaluate_expression', { result: aVal.result.value, consoleOutput: [] }); 301 | mcpLogCall('evaluate_expression', { expr: 'b' }); 302 | mcpLogResult('evaluate_expression', { result: bVal.result.value, consoleOutput: [] }); 303 | debugLog('inside add(), a =', aVal.result?.value); 304 | debugLog('inside add(), b =', bVal.result?.value); 305 | await dumpLocalScope(Runtime, insideTop, { only: ['a', 'b'] }); 306 | assert.equal(aVal.result.value, 2); 307 | assert.equal(bVal.result.value, 3); 308 | 309 | await Debugger.resume(); 310 | try { await client.close(); } catch {} 311 | await new Promise((resolve) => proc.once('exit', resolve)); 312 | } finally { 313 | try { await client.close(); } catch {} 314 | if (!proc.killed) { try { proc.kill('SIGKILL'); } catch {} } 315 | try { if (typeof unlistenScriptParsed === 'function') unlistenScriptParsed(); } catch {} 316 | } 317 | }); 318 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 3 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 4 | import { z } from 'zod'; 5 | import { spawn } from 'child_process'; 6 | import CDP, { Client } from 'chrome-remote-interface'; 7 | // Note: No debug trace wrappers; using standard MCP server behavior 8 | 9 | 10 | // Create the MCP server 11 | const server = new McpServer({ 12 | name: 'devtools-debugger-mcp', 13 | version: '1.0.0' 14 | }); 15 | 16 | // Node.js debugging state 17 | let nodeDebugClient: Client | null = null; 18 | let nodeProcess: import('child_process').ChildProcess | null = null; 19 | let scriptIdToUrl: Record = {}; 20 | let consoleMessages: string[] = []; 21 | let lastPausedParams: any | null = null; 22 | let pauseCounter = 0; 23 | let pauseMap: Record = {}; 24 | let currentPauseId: string | null = null; 25 | 26 | type OutputFormat = 'text' | 'json'; 27 | 28 | function summarizeFrame(frame: any) { 29 | const fileUrl = frame.url || scriptIdToUrl[frame.location.scriptId] || ''; 30 | return { 31 | functionName: frame.functionName || null, 32 | url: fileUrl, 33 | line: frame.location.lineNumber + 1, 34 | column: (frame.location.columnNumber ?? 0) + 1 35 | }; 36 | } 37 | 38 | function mcpContent(payload: unknown, format?: OutputFormat) { 39 | // MCP spec supports text/image/resource. We encode structured data as a single text block. 40 | // Default is JSON (minified) to reduce tokens; pass format: 'text' for pretty 2-space JSON. 41 | const text = format === 'text' ? JSON.stringify(payload, null, 2) : JSON.stringify(payload); 42 | return [{ type: 'text', text } as any]; 43 | } 44 | 45 | 46 | // Log when server starts 47 | console.error('devtools-debugger-mcp server starting...'); 48 | 49 | // Defer connecting the MCP transport until after tools are registered 50 | const transport = new StdioServerTransport(); 51 | 52 | 53 | // Node.js debugging tools 54 | server.tool( 55 | 'start_node_debug', 56 | { scriptPath: z.string().describe('Path to the Node.js script to debug'), format: z.enum(['text','json']).optional() }, 57 | async (params) => { 58 | if (nodeDebugClient) { 59 | return { 60 | content: [{ type: 'text', text: 'Error: A debug session is already active.' }], 61 | isError: true 62 | }; 63 | } 64 | try { 65 | const scriptPath = params.scriptPath; 66 | nodeProcess = spawn('node', ['--inspect-brk=0', scriptPath], { 67 | stdio: ['ignore', 'pipe', 'pipe'], 68 | env: { ...process.env, NODE_OPTIONS: '' } 69 | }); 70 | 71 | const inspectorPort = await new Promise((resolve, reject) => { 72 | let resolved = false; 73 | nodeProcess?.stderr?.on('data', (data) => { 74 | const msg = data.toString(); 75 | const match = msg.match(/ws:\/\/127\.0\.0\.1:(\d+)/); 76 | if (match) { 77 | resolved = true; 78 | resolve(Number(match[1])); 79 | } 80 | }); 81 | nodeProcess?.on('exit', () => { 82 | if (!resolved) { 83 | reject(new Error('Node process exited before debugger attached')); 84 | } 85 | }); 86 | }); 87 | 88 | nodeDebugClient = await CDP({ host: '127.0.0.1', port: inspectorPort }); 89 | 90 | const pausedPromise = new Promise((resolve) => { 91 | nodeDebugClient!.Debugger.paused((params) => { 92 | lastPausedParams = params; 93 | resolve(params); 94 | }); 95 | }); 96 | 97 | await nodeDebugClient.Debugger.enable(); 98 | await nodeDebugClient.Runtime.enable(); 99 | // Trigger pause delivery if runtime is waiting for debugger 100 | try { await nodeDebugClient.Runtime.runIfWaitingForDebugger(); } catch {} 101 | 102 | scriptIdToUrl = {}; 103 | consoleMessages = []; 104 | 105 | nodeDebugClient.Debugger.scriptParsed(({ scriptId, url }) => { 106 | if (url) scriptIdToUrl[scriptId] = url; 107 | }); 108 | nodeDebugClient.Runtime.consoleAPICalled(({ type, args }) => { 109 | const text = args 110 | .map((arg) => (arg.value !== undefined ? arg.value : arg.description)) 111 | .join(' '); 112 | consoleMessages.push(`[${type}] ${text}`); 113 | }); 114 | 115 | const pausedEvent = await pausedPromise; 116 | 117 | const callFrame = pausedEvent.callFrames[0]; 118 | const scriptId = callFrame.location.scriptId; 119 | const fileUrl = 120 | scriptIdToUrl[scriptId] || callFrame.url || ''; 121 | const line = callFrame.location.lineNumber + 1; 122 | consoleMessages = []; 123 | 124 | // Track pause 125 | lastPausedParams = pausedEvent; 126 | currentPauseId = 'p' + ++pauseCounter; 127 | pauseMap[currentPauseId] = pausedEvent; 128 | 129 | return { 130 | content: mcpContent({ 131 | status: `Debugger attached. Paused at ${fileUrl}:${line} (reason: ${pausedEvent.reason}).`, 132 | pauseId: currentPauseId, 133 | frame: summarizeFrame(callFrame) 134 | }, params.format as any) 135 | }; 136 | } catch (error) { 137 | return { 138 | content: [ 139 | { 140 | type: 'text', 141 | text: `Error: Failed to start debug session - ${ 142 | error instanceof Error ? error.message : error 143 | }` 144 | } 145 | ], 146 | isError: true 147 | }; 148 | } 149 | } 150 | ); 151 | 152 | server.tool( 153 | 'set_breakpoint', 154 | { 155 | filePath: z 156 | .string() 157 | .describe('Path of the script file to break in'), 158 | line: z.number().describe('1-based line number to set breakpoint at') 159 | }, 160 | async (params) => { 161 | if (!nodeDebugClient) { 162 | return { 163 | content: [{ type: 'text', text: 'Error: No active debug session.' }], 164 | isError: true 165 | }; 166 | } 167 | try { 168 | const fileUrl = params.filePath.startsWith('file://') 169 | ? params.filePath 170 | : 'file://' + params.filePath; 171 | const lineNumber = params.line - 1; 172 | const { breakpointId } = 173 | await nodeDebugClient.Debugger.setBreakpointByUrl({ 174 | url: fileUrl, 175 | lineNumber, 176 | columnNumber: 0 177 | }); 178 | return { 179 | content: [ 180 | { 181 | type: 'text', 182 | text: `Breakpoint set at ${params.filePath}:${params.line} (ID: ${breakpointId}).` 183 | } 184 | ] 185 | }; 186 | } catch (error) { 187 | return { 188 | content: [ 189 | { 190 | type: 'text', 191 | text: `Error: Failed to set breakpoint - ${error instanceof Error ? error.message : error}` 192 | } 193 | ], 194 | isError: true 195 | }; 196 | } 197 | } 198 | ); 199 | 200 | server.tool( 201 | 'set_breakpoint_condition', 202 | { 203 | filePath: z.string().optional(), 204 | urlRegex: z.string().optional(), 205 | line: z.number().describe('1-based line number'), 206 | column: z.number().optional(), 207 | condition: z.string().describe('Breakpoint condition, e.g. x > 0 or console.log("msg") || false'), 208 | format: z.enum(['text', 'json']).optional() 209 | }, 210 | async (params) => { 211 | if (!nodeDebugClient) return { content: [{ type: 'text', text: 'Error: No active debug session.' }], isError: true }; 212 | try { 213 | const lineNumber = params.line - 1; 214 | const column = params.column ?? 0; 215 | let result; 216 | if (params.urlRegex) { 217 | result = await nodeDebugClient.Debugger.setBreakpointByUrl({ urlRegex: params.urlRegex, lineNumber, columnNumber: column, condition: params.condition }); 218 | } else if (params.filePath) { 219 | const fileUrl = params.filePath.startsWith('file://') ? params.filePath : 'file://' + params.filePath; 220 | result = await nodeDebugClient.Debugger.setBreakpointByUrl({ url: fileUrl, lineNumber, columnNumber: column, condition: params.condition }); 221 | } else { 222 | return { content: [{ type: 'text', text: 'Error: Provide filePath or urlRegex.' }], isError: true }; 223 | } 224 | return { content: mcpContent({ breakpointId: (result as any).breakpointId, locations: (result as any).locations }, params.format) }; 225 | } catch (error) { 226 | return { content: [{ type: 'text', text: `Error: Failed to set conditional breakpoint - ${error instanceof Error ? error.message : error}` }], isError: true }; 227 | } 228 | } 229 | ); 230 | 231 | server.tool( 232 | 'add_logpoint', 233 | { 234 | filePath: z.string().optional(), 235 | urlRegex: z.string().optional(), 236 | line: z.number(), 237 | column: z.number().optional(), 238 | message: z.string().describe('Log message template; use {expr} to interpolate JS expression'), 239 | format: z.enum(['text', 'json']).optional() 240 | }, 241 | async (params) => { 242 | if (!nodeDebugClient) return { content: [{ type: 'text', text: 'Error: No active debug session.' }], isError: true }; 243 | const toCondition = (msg: string) => { 244 | // Replace {expr} with ${expr} inside a template literal 245 | const tpl = '`' + msg.replace(/`/g, '\\`').replace(/\{([^}]+)\}/g, '${$1}') + '`'; 246 | return `console.log(${tpl}); false`; 247 | }; 248 | try { 249 | const condition = toCondition(params.message); 250 | const lineNumber = params.line - 1; 251 | const column = params.column ?? 0; 252 | let result; 253 | if (params.urlRegex) { 254 | result = await nodeDebugClient.Debugger.setBreakpointByUrl({ urlRegex: params.urlRegex, lineNumber, columnNumber: column, condition }); 255 | } else if (params.filePath) { 256 | const fileUrl = params.filePath.startsWith('file://') ? params.filePath : 'file://' + params.filePath; 257 | result = await nodeDebugClient.Debugger.setBreakpointByUrl({ url: fileUrl, lineNumber, columnNumber: column, condition }); 258 | } else { 259 | return { content: [{ type: 'text', text: 'Error: Provide filePath or urlRegex.' }], isError: true }; 260 | } 261 | return { content: mcpContent({ breakpointId: (result as any).breakpointId, locations: (result as any).locations, kind: 'logpoint' }, params.format) }; 262 | } catch (error) { 263 | return { content: [{ type: 'text', text: `Error: Failed to add logpoint - ${error instanceof Error ? error.message : error}` }], isError: true }; 264 | } 265 | } 266 | ); 267 | 268 | server.tool( 269 | 'set_exception_breakpoints', 270 | { state: z.enum(['none', 'uncaught', 'all']).describe('Pause on exceptions'), format: z.enum(['text','json']).optional() }, 271 | async (params) => { 272 | if (!nodeDebugClient) return { content: [{ type: 'text', text: 'Error: No active debug session.' }], isError: true }; 273 | try { 274 | await nodeDebugClient.Debugger.setPauseOnExceptions({ state: params.state }); 275 | return { content: mcpContent({ ok: true, state: params.state }, params.format) }; 276 | } catch (error) { 277 | return { content: [{ type: 'text', text: `Error: Failed to set exception breakpoints - ${error instanceof Error ? error.message : error}` }], isError: true }; 278 | } 279 | } 280 | ); 281 | 282 | server.tool( 283 | 'blackbox_scripts', 284 | { patterns: z.array(z.string()).describe('Regex patterns for script URLs to blackbox'), format: z.enum(['text','json']).optional() }, 285 | async (params) => { 286 | if (!nodeDebugClient) return { content: [{ type: 'text', text: 'Error: No active debug session.' }], isError: true }; 287 | try { 288 | await nodeDebugClient.Debugger.setBlackboxPatterns({ patterns: params.patterns }); 289 | return { content: mcpContent({ ok: true, patterns: params.patterns }, params.format) }; 290 | } catch (error) { 291 | return { content: [{ type: 'text', text: `Error: Failed to set blackbox patterns - ${error instanceof Error ? error.message : error}` }], isError: true }; 292 | } 293 | } 294 | ); 295 | 296 | server.tool( 297 | 'list_scripts', 298 | { format: z.enum(['text','json']).optional() }, 299 | async (params) => { 300 | const scripts = Object.entries(scriptIdToUrl).map(([scriptId, url]) => ({ scriptId, url })); 301 | return { content: mcpContent({ scripts }, params?.format) }; 302 | } 303 | ); 304 | 305 | server.tool( 306 | 'get_script_source', 307 | { scriptId: z.string().optional(), url: z.string().optional(), format: z.enum(['text','json']).optional() }, 308 | async (params) => { 309 | if (!nodeDebugClient) return { content: [{ type: 'text', text: 'Error: No active debug session.' }], isError: true }; 310 | try { 311 | let sid = params.scriptId; 312 | if (!sid && params.url) { 313 | sid = Object.keys(scriptIdToUrl).find((k) => scriptIdToUrl[k] === params.url); 314 | } 315 | if (!sid) return { content: [{ type: 'text', text: 'Error: Provide scriptId or url.' }], isError: true }; 316 | const { scriptSource } = await nodeDebugClient.Debugger.getScriptSource({ scriptId: sid }); 317 | return { content: mcpContent({ scriptId: sid, url: scriptIdToUrl[sid] || null, source: scriptSource }, params.format) }; 318 | } catch (error) { 319 | return { content: [{ type: 'text', text: `Error: Failed to get script source - ${error instanceof Error ? error.message : error}` }], isError: true }; 320 | } 321 | } 322 | ); 323 | 324 | server.tool( 325 | 'continue_to_location', 326 | { filePath: z.string(), line: z.number(), column: z.number().optional(), format: z.enum(['text','json']).optional() }, 327 | async (params) => { 328 | if (!nodeDebugClient) return { content: [{ type: 'text', text: 'Error: No active debug session.' }], isError: true }; 329 | try { 330 | const url = params.filePath.startsWith('file://') ? params.filePath : 'file://' + params.filePath; 331 | const scriptId = Object.keys(scriptIdToUrl).find((k) => scriptIdToUrl[k] === url); 332 | if (!scriptId) return { content: [{ type: 'text', text: `Error: Script not found for ${url}` }], isError: true }; 333 | const lineNumber = params.line - 1; 334 | const columnNumber = (params.column ?? 1) - 1; 335 | 336 | const pausePromise = new Promise((resolve) => { 337 | nodeDebugClient!.Debugger.paused((p) => { 338 | lastPausedParams = p; 339 | currentPauseId = 'p' + ++pauseCounter; 340 | pauseMap[currentPauseId] = p; 341 | resolve(p); 342 | }); 343 | }); 344 | const exitPromise = new Promise((resolve) => nodeProcess?.once('exit', () => resolve(null))); 345 | await nodeDebugClient.Debugger.continueToLocation({ location: { scriptId, lineNumber, columnNumber } }); 346 | const result = await Promise.race([pausePromise, exitPromise]); 347 | if (result && typeof result === 'object') { 348 | const top = (result as any).callFrames[0]; 349 | return { content: mcpContent({ status: `Paused at ${summarizeFrame(top).url}:${summarizeFrame(top).line}`, pauseId: currentPauseId, frame: summarizeFrame(top) }, params.format) }; 350 | } else { 351 | return { content: mcpContent({ status: 'Execution completed.' }, params.format) }; 352 | } 353 | } catch (error) { 354 | return { content: [{ type: 'text', text: `Error: Failed to continue to location - ${error instanceof Error ? error.message : error}` }], isError: true }; 355 | } 356 | } 357 | ); 358 | 359 | server.tool( 360 | 'restart_frame', 361 | { frameIndex: z.number().min(0).describe('Frame index to restart'), pauseId: z.string().optional(), format: z.enum(['text','json']).optional() }, 362 | async (params) => { 363 | if (!nodeDebugClient || !lastPausedParams) return { content: [{ type: 'text', text: 'Error: No active pause state.' }], isError: true }; 364 | try { 365 | const pause = params.pauseId ? pauseMap[params.pauseId] : lastPausedParams; 366 | if (!pause) return { content: [{ type: 'text', text: 'Error: Invalid pauseId.' }], isError: true }; 367 | const frame = pause.callFrames[params.frameIndex]; 368 | if (!frame) return { content: [{ type: 'text', text: 'Error: Invalid frame index.' }], isError: true }; 369 | const pausePromise = new Promise((resolve) => { 370 | nodeDebugClient!.Debugger.paused((p) => { 371 | lastPausedParams = p; 372 | currentPauseId = 'p' + ++pauseCounter; 373 | pauseMap[currentPauseId] = p; 374 | resolve(p); 375 | }); 376 | }); 377 | await nodeDebugClient.Debugger.restartFrame({ callFrameId: frame.callFrameId }); 378 | const result = await pausePromise; 379 | const top = (result as any).callFrames[0]; 380 | return { content: mcpContent({ status: `Restarted frame; now at ${summarizeFrame(top).url}:${summarizeFrame(top).line}`, pauseId: currentPauseId, frame: summarizeFrame(top) }, params.format) }; 381 | } catch (error) { 382 | return { content: [{ type: 'text', text: `Error: Failed to restart frame - ${error instanceof Error ? error.message : error}` }], isError: true }; 383 | } 384 | } 385 | ); 386 | 387 | server.tool( 388 | 'get_object_properties', 389 | { objectId: z.string(), maxProps: z.number().min(1).max(100).optional(), format: z.enum(['text','json']).optional() }, 390 | async (params) => { 391 | if (!nodeDebugClient) return { content: [{ type: 'text', text: 'Error: No active debug session.' }], isError: true }; 392 | try { 393 | const { result } = await nodeDebugClient.Runtime.getProperties({ objectId: params.objectId, ownProperties: true, generatePreview: true }); 394 | const items = (result || []).slice(0, params.maxProps ?? 50).map((p) => ({ name: p.name, type: p.value?.type, value: p.value?.value ?? p.value?.description, objectId: p.value?.objectId })); 395 | return { content: mcpContent({ properties: items }, params.format) }; 396 | } catch (error) { 397 | return { content: [{ type: 'text', text: `Error: Failed to get object properties - ${error instanceof Error ? error.message : error}` }], isError: true }; 398 | } 399 | } 400 | ); 401 | 402 | server.tool( 403 | 'read_console', 404 | { format: z.enum(['text','json']).optional() }, 405 | async (params) => { 406 | const out = consoleMessages.slice(); 407 | consoleMessages = []; 408 | return { content: mcpContent({ consoleOutput: out }, params.format) }; 409 | } 410 | ); 411 | 412 | // removed global format tool to avoid agent confusion; each call can specify format directly 413 | 414 | server.tool( 415 | 'resume_execution', 416 | { 417 | includeScopes: z.boolean().optional(), 418 | includeStack: z.boolean().optional(), 419 | includeConsole: z.boolean().optional(), 420 | format: z.enum(['text', 'json']).optional() 421 | }, 422 | async (options) => { 423 | if (!nodeDebugClient) { 424 | return { 425 | content: [{ type: 'text', text: 'Error: No active debug session.' }], 426 | isError: true 427 | }; 428 | } 429 | try { 430 | const pausePromise = new Promise((resolve) => { 431 | nodeDebugClient!.Debugger.paused((params) => { 432 | lastPausedParams = params; 433 | currentPauseId = 'p' + ++pauseCounter; 434 | pauseMap[currentPauseId] = params; 435 | resolve(params); 436 | }); 437 | }); 438 | const exitPromise = new Promise((resolve) => { 439 | nodeProcess?.once('exit', () => resolve(null)); 440 | }); 441 | await nodeDebugClient.Debugger.resume(); 442 | const result = await Promise.race([pausePromise, exitPromise]); 443 | if (result && typeof result === 'object') { 444 | const ev = result as any; 445 | const topFrame = ev.callFrames[0]; 446 | const fileUrl = topFrame.url || scriptIdToUrl[topFrame.location.scriptId] || ''; 447 | const line = topFrame.location.lineNumber + 1; 448 | const output = consoleMessages.slice(); 449 | consoleMessages = []; 450 | 451 | const payload: any = { 452 | status: `Paused at ${fileUrl}:${line} (reason: ${ev.reason})`, 453 | pauseId: currentPauseId, 454 | frame: summarizeFrame(topFrame) 455 | }; 456 | if (options?.includeConsole) payload.consoleOutput = output; 457 | if (options?.includeStack) { 458 | payload.stack = (result as any).callFrames.map(summarizeFrame); 459 | } 460 | if (options?.includeScopes) { 461 | // Build scopes snapshot 462 | const scopes: any[] = []; 463 | for (const s of topFrame.scopeChain || []) { 464 | if (!s.object || !s.object.objectId) continue; 465 | const { result: props } = await nodeDebugClient!.Runtime.getProperties({ objectId: s.object.objectId, ownProperties: true }); 466 | const variables = (props || []).slice(0, 15).map((p) => ({ 467 | name: p.name, 468 | type: p.value?.type, 469 | value: p.value?.value ?? p.value?.description, 470 | objectId: p.value?.objectId 471 | })); 472 | scopes.push({ type: s.type, variables }); 473 | } 474 | payload.scopes = scopes; 475 | } 476 | return { content: mcpContent(payload, options?.format as any) }; 477 | } else { 478 | const exitCode = nodeProcess?.exitCode; 479 | await nodeDebugClient.close(); 480 | nodeDebugClient = null; 481 | nodeProcess = null; 482 | scriptIdToUrl = {}; 483 | lastPausedParams = null; 484 | consoleMessages = []; 485 | pauseMap = {}; 486 | currentPauseId = null; 487 | return { content: mcpContent({ status: `Execution resumed until completion. Process exited with code ${exitCode}.` }, options?.format as any) }; 488 | } 489 | } catch (error) { 490 | return { 491 | content: [ 492 | { 493 | type: 'text', 494 | text: `Error: Resume failed - ${error instanceof Error ? error.message : error}` 495 | } 496 | ], 497 | isError: true 498 | }; 499 | } 500 | } 501 | ); 502 | 503 | server.tool( 504 | 'step_over', 505 | { 506 | includeScopes: z.boolean().optional(), 507 | includeStack: z.boolean().optional(), 508 | includeConsole: z.boolean().optional(), 509 | format: z.enum(['text', 'json']).optional() 510 | }, 511 | async (options) => { 512 | if (!nodeDebugClient) { 513 | return { 514 | content: [{ type: 'text', text: 'Error: No active debug session.' }], 515 | isError: true 516 | }; 517 | } 518 | try { 519 | const pausePromise = new Promise((resolve) => { 520 | nodeDebugClient!.Debugger.paused((params) => { 521 | lastPausedParams = params; 522 | currentPauseId = 'p' + ++pauseCounter; 523 | pauseMap[currentPauseId] = params; 524 | resolve(params); 525 | }); 526 | }); 527 | const exitPromise = new Promise((resolve) => { 528 | nodeProcess?.once('exit', () => resolve(null)); 529 | }); 530 | await nodeDebugClient.Debugger.stepOver(); 531 | const result = await Promise.race([pausePromise, exitPromise]); 532 | if (result && typeof result === 'object') { 533 | const params = result as any; 534 | const topFrame = params.callFrames[0]; 535 | const fileUrl = 536 | topFrame.url || 537 | scriptIdToUrl[topFrame.location.scriptId] || 538 | ''; 539 | const line = topFrame.location.lineNumber + 1; 540 | const payload: any = { 541 | status: `Paused at ${fileUrl}:${line} (reason: ${params.reason})`, 542 | pauseId: currentPauseId, 543 | frame: summarizeFrame(topFrame) 544 | }; 545 | if (options?.includeConsole) { 546 | payload.consoleOutput = consoleMessages.slice(); 547 | } 548 | consoleMessages = []; 549 | if (options?.includeStack) { 550 | payload.stack = (result as any).callFrames.map(summarizeFrame); 551 | } 552 | if (options?.includeScopes) { 553 | const scopes: any[] = []; 554 | for (const s of topFrame.scopeChain || []) { 555 | if (!s.object || !s.object.objectId) continue; 556 | const { result: props } = await nodeDebugClient!.Runtime.getProperties({ objectId: s.object.objectId, ownProperties: true }); 557 | const variables = (props || []).slice(0, 15).map((p) => ({ 558 | name: p.name, 559 | type: p.value?.type, 560 | value: p.value?.value ?? p.value?.description, 561 | objectId: p.value?.objectId 562 | })); 563 | scopes.push({ type: s.type, variables }); 564 | } 565 | payload.scopes = scopes; 566 | } 567 | return { content: mcpContent(payload, options?.format as any) }; 568 | } else { 569 | const exitCode = nodeProcess?.exitCode; 570 | await nodeDebugClient.close(); 571 | nodeDebugClient = null; 572 | nodeProcess = null; 573 | scriptIdToUrl = {}; 574 | lastPausedParams = null; 575 | consoleMessages = []; 576 | return { 577 | content: [ 578 | { 579 | type: 'text', 580 | text: `Execution resumed until completion. Process exited with code ${exitCode}.` 581 | } 582 | ] 583 | }; 584 | } 585 | } catch (error) { 586 | return { 587 | content: [ 588 | { 589 | type: 'text', 590 | text: `Error: Step over failed - ${error instanceof Error ? error.message : error}` 591 | } 592 | ], 593 | isError: true 594 | }; 595 | } 596 | } 597 | ); 598 | 599 | server.tool( 600 | 'step_into', 601 | { 602 | includeScopes: z.boolean().optional(), 603 | includeStack: z.boolean().optional(), 604 | includeConsole: z.boolean().optional(), 605 | format: z.enum(['text', 'json']).optional() 606 | }, 607 | async (options) => { 608 | if (!nodeDebugClient) { 609 | return { 610 | content: [{ type: 'text', text: 'Error: No active debug session.' }], 611 | isError: true 612 | }; 613 | } 614 | try { 615 | const pausePromise = new Promise((resolve) => { 616 | nodeDebugClient!.Debugger.paused((params) => { 617 | lastPausedParams = params; 618 | currentPauseId = 'p' + ++pauseCounter; 619 | pauseMap[currentPauseId] = params; 620 | resolve(params); 621 | }); 622 | }); 623 | const exitPromise = new Promise((resolve) => { 624 | nodeProcess?.once('exit', () => resolve(null)); 625 | }); 626 | await nodeDebugClient.Debugger.stepInto(); 627 | const result = await Promise.race([pausePromise, exitPromise]); 628 | if (result && typeof result === 'object') { 629 | const params = result as any; 630 | const topFrame = params.callFrames[0]; 631 | const fileUrl = 632 | topFrame.url || 633 | scriptIdToUrl[topFrame.location.scriptId] || 634 | ''; 635 | const line = topFrame.location.lineNumber + 1; 636 | const payload: any = { 637 | status: `Paused at ${fileUrl}:${line} (reason: ${params.reason})`, 638 | pauseId: currentPauseId, 639 | frame: summarizeFrame(topFrame) 640 | }; 641 | if (options?.includeConsole) payload.consoleOutput = consoleMessages.slice(); 642 | consoleMessages = []; 643 | if (options?.includeStack) payload.stack = (result as any).callFrames.map(summarizeFrame); 644 | if (options?.includeScopes) { 645 | const scopes: any[] = []; 646 | for (const s of topFrame.scopeChain || []) { 647 | if (!s.object || !s.object.objectId) continue; 648 | const { result: props } = await nodeDebugClient!.Runtime.getProperties({ objectId: s.object.objectId, ownProperties: true }); 649 | const variables = (props || []).slice(0, 15).map((p) => ({ 650 | name: p.name, 651 | type: p.value?.type, 652 | value: p.value?.value ?? p.value?.description, 653 | objectId: p.value?.objectId 654 | })); 655 | scopes.push({ type: s.type, variables }); 656 | } 657 | payload.scopes = scopes; 658 | } 659 | return { content: mcpContent(payload, options?.format as any) }; 660 | } else { 661 | const exitCode = nodeProcess?.exitCode; 662 | await nodeDebugClient.close(); 663 | nodeDebugClient = null; 664 | nodeProcess = null; 665 | scriptIdToUrl = {}; 666 | lastPausedParams = null; 667 | consoleMessages = []; 668 | return { 669 | content: [ 670 | { 671 | type: 'text', 672 | text: `Execution resumed until completion. Process exited with code ${exitCode}.` 673 | } 674 | ] 675 | }; 676 | } 677 | } catch (error) { 678 | return { 679 | content: [ 680 | { 681 | type: 'text', 682 | text: `Error: Step into failed - ${error instanceof Error ? error.message : error}` 683 | } 684 | ], 685 | isError: true 686 | }; 687 | } 688 | } 689 | ); 690 | 691 | server.tool( 692 | 'step_out', 693 | { 694 | includeScopes: z.boolean().optional(), 695 | includeStack: z.boolean().optional(), 696 | includeConsole: z.boolean().optional(), 697 | format: z.enum(['text', 'json', 'both']).optional() 698 | }, 699 | async (options) => { 700 | if (!nodeDebugClient) { 701 | return { 702 | content: [{ type: 'text', text: 'Error: No active debug session.' }], 703 | isError: true 704 | }; 705 | } 706 | try { 707 | const pausePromise = new Promise((resolve) => { 708 | nodeDebugClient!.Debugger.paused((params) => { 709 | lastPausedParams = params; 710 | currentPauseId = 'p' + ++pauseCounter; 711 | pauseMap[currentPauseId] = params; 712 | resolve(params); 713 | }); 714 | }); 715 | const exitPromise = new Promise((resolve) => { 716 | nodeProcess?.once('exit', () => resolve(null)); 717 | }); 718 | await nodeDebugClient.Debugger.stepOut(); 719 | const result = await Promise.race([pausePromise, exitPromise]); 720 | if (result && typeof result === 'object') { 721 | const params = result as any; 722 | const topFrame = params.callFrames[0]; 723 | const fileUrl = 724 | topFrame.url || 725 | scriptIdToUrl[topFrame.location.scriptId] || 726 | ''; 727 | const line = topFrame.location.lineNumber + 1; 728 | const payload: any = { 729 | status: `Paused at ${fileUrl}:${line} (reason: ${params.reason})`, 730 | pauseId: currentPauseId, 731 | frame: summarizeFrame(topFrame) 732 | }; 733 | if (options?.includeConsole) payload.consoleOutput = consoleMessages.slice(); 734 | consoleMessages = []; 735 | if (options?.includeStack) payload.stack = (result as any).callFrames.map(summarizeFrame); 736 | if (options?.includeScopes) { 737 | const scopes: any[] = []; 738 | for (const s of topFrame.scopeChain || []) { 739 | if (!s.object || !s.object.objectId) continue; 740 | const { result: props } = await nodeDebugClient!.Runtime.getProperties({ objectId: s.object.objectId, ownProperties: true }); 741 | const variables = (props || []).slice(0, 15).map((p) => ({ 742 | name: p.name, 743 | type: p.value?.type, 744 | value: p.value?.value ?? p.value?.description, 745 | objectId: p.value?.objectId 746 | })); 747 | scopes.push({ type: s.type, variables }); 748 | } 749 | payload.scopes = scopes; 750 | } 751 | return { content: mcpContent(payload, (options as any)?.format) }; 752 | } else { 753 | const exitCode = nodeProcess?.exitCode; 754 | await nodeDebugClient.close(); 755 | nodeDebugClient = null; 756 | nodeProcess = null; 757 | scriptIdToUrl = {}; 758 | lastPausedParams = null; 759 | consoleMessages = []; 760 | return { 761 | content: [ 762 | { 763 | type: 'text', 764 | text: `Execution resumed until completion. Process exited with code ${exitCode}.` 765 | } 766 | ] 767 | }; 768 | } 769 | } catch (error) { 770 | return { 771 | content: [ 772 | { 773 | type: 'text', 774 | text: `Error: Step out failed - ${error instanceof Error ? error.message : error}` 775 | } 776 | ], 777 | isError: true 778 | }; 779 | } 780 | } 781 | ); 782 | 783 | server.tool( 784 | 'evaluate_expression', 785 | { 786 | expr: z.string().describe('JavaScript expression to evaluate'), 787 | pauseId: z.string().optional().describe('Specific pauseId to evaluate in'), 788 | frameIndex: z.number().min(0).optional().describe('Call frame index within the pause (default 0)'), 789 | returnByValue: z.boolean().optional().describe('Return primitives by value (default true)'), 790 | format: z.enum(['text', 'json']).optional() 791 | }, 792 | async (params) => { 793 | if (!nodeDebugClient || !lastPausedParams) { 794 | return { 795 | content: [{ type: 'text', text: 'Error: No active pause state.' }], 796 | isError: true 797 | }; 798 | } 799 | try { 800 | const pause = params.pauseId ? pauseMap[params.pauseId] : lastPausedParams; 801 | if (!pause) { 802 | return { content: [{ type: 'text', text: 'Error: Invalid pauseId.' }], isError: true }; 803 | } 804 | const idx = params.frameIndex ?? 0; 805 | const callFrameId = pause.callFrames[idx]?.callFrameId; 806 | if (!callFrameId) { 807 | return { content: [{ type: 'text', text: 'Error: Invalid frame index.' }], isError: true }; 808 | } 809 | const evalResponse = await nodeDebugClient.Debugger.evaluateOnCallFrame({ 810 | callFrameId, 811 | expression: params.expr, 812 | includeCommandLineAPI: true, 813 | returnByValue: params.returnByValue ?? true 814 | }); 815 | if (evalResponse.exceptionDetails) { 816 | const text = 817 | evalResponse.exceptionDetails.exception?.description || 818 | evalResponse.exceptionDetails.text; 819 | return { content: [{ type: 'text', text: `Error: ${text}` }], isError: true }; 820 | } 821 | const resultObj = evalResponse.result; 822 | let output: unknown; 823 | if (resultObj.value !== undefined) { 824 | output = resultObj.value; 825 | } else { 826 | output = resultObj.description || resultObj.type; 827 | } 828 | const outputLogs = consoleMessages.slice(); 829 | consoleMessages = []; 830 | return { content: mcpContent({ result: output, consoleOutput: outputLogs }, params.format) }; 831 | } catch (error) { 832 | return { 833 | content: [ 834 | { 835 | type: 'text', 836 | text: `Error: Evaluation failed - ${ 837 | error instanceof Error ? error.message : error 838 | }` 839 | } 840 | ], 841 | isError: true 842 | }; 843 | } 844 | } 845 | ); 846 | 847 | // Inspect locals/closure scopes at current pause 848 | server.tool( 849 | 'inspect_scopes', 850 | { 851 | maxProps: z.number().min(1).max(50).optional().describe('Maximum properties per scope to include (default 15)'), 852 | pauseId: z.string().optional().describe('Specific pause to inspect (default current)'), 853 | frameIndex: z.number().min(0).optional().describe('Call frame index (default 0)'), 854 | includeThisPreview: z.boolean().optional().describe('Include shallow preview of this (default true)'), 855 | format: z.enum(['text', 'json']).optional() 856 | }, 857 | async (params) => { 858 | if (!nodeDebugClient || !lastPausedParams) { 859 | return { 860 | content: [{ type: 'text', text: 'Error: No active pause state.' }], 861 | isError: true 862 | }; 863 | } 864 | const maxProps = params?.maxProps ?? 15; 865 | const pause = params.pauseId ? pauseMap[params.pauseId] : lastPausedParams; 866 | if (!pause) { 867 | return { content: [{ type: 'text', text: 'Error: Invalid pauseId.' }], isError: true }; 868 | } 869 | const idx = params.frameIndex ?? 0; 870 | const frame = pause.callFrames[idx]; 871 | const fileUrl = frame.url || scriptIdToUrl[frame.location.scriptId] || ''; 872 | function summarizeValue(v: any) { 873 | if (!v) return { type: 'undefined' }; 874 | if (v.value !== undefined) return { type: typeof v.value, value: v.value }; 875 | return { type: v.type, description: v.description, className: (v as any).className, objectId: (v as any).objectId }; 876 | } 877 | async function listProps(objectId: string) { 878 | const { result } = await nodeDebugClient!.Runtime.getProperties({ objectId, ownProperties: true, generatePreview: false }); 879 | const entries = (result || []).slice(0, maxProps).map((p) => ({ name: p.name, ...summarizeValue(p.value) })); 880 | return entries; 881 | } 882 | const scopes: any[] = []; 883 | for (const s of frame.scopeChain || []) { 884 | if (!s.object || !s.object.objectId) continue; 885 | const props = await listProps(s.object.objectId); 886 | // Skip huge globals; only include a light summary 887 | if (s.type === 'global') { 888 | scopes.push({ type: s.type, variables: props.slice(0, 5), truncated: true }); 889 | } else { 890 | scopes.push({ type: s.type, variables: props }); 891 | } 892 | } 893 | let thisSummary: any = null; 894 | const includeThis = params.includeThisPreview !== false; 895 | if (includeThis && frame.this && (frame.this as any).type) { 896 | const t: any = frame.this; 897 | thisSummary = summarizeValue(t); 898 | if (t.objectId) { 899 | const preview = await listProps(t.objectId); 900 | thisSummary.preview = preview; 901 | } 902 | } 903 | const payload = { 904 | frame: { 905 | functionName: frame.functionName || null, 906 | url: fileUrl, 907 | line: frame.location.lineNumber + 1, 908 | column: (frame.location.columnNumber ?? 0) + 1 909 | }, 910 | this: thisSummary, 911 | scopes 912 | }; 913 | return { content: mcpContent(payload, params.format) }; 914 | } 915 | ); 916 | 917 | // List current call stack (top N frames) 918 | server.tool( 919 | 'list_call_stack', 920 | { 921 | depth: z.number().min(1).max(50).optional().describe('Maximum frames to include (default 10)'), 922 | pauseId: z.string().optional(), 923 | includeThis: z.boolean().optional(), 924 | format: z.enum(['text', 'json', 'both']).optional() 925 | }, 926 | async (params) => { 927 | if (!nodeDebugClient || !lastPausedParams) { 928 | return { 929 | content: [{ type: 'text', text: 'Error: No active pause state.' }], 930 | isError: true 931 | }; 932 | } 933 | const pause = params.pauseId ? pauseMap[params.pauseId] : lastPausedParams; 934 | if (!pause) return { content: [{ type: 'text', text: 'Error: Invalid pauseId.' }], isError: true }; 935 | const depth = params?.depth ?? 10; 936 | const frames = (pause.callFrames || []).slice(0, depth).map((f: any) => { 937 | const base: any = summarizeFrame(f); 938 | if (params?.includeThis && f.this) base.thisType = f.this.type; 939 | return base; 940 | }); 941 | return { content: mcpContent({ frames, pauseId: params.pauseId || currentPauseId }, params.format as any) }; 942 | } 943 | ); 944 | 945 | // Provide minimal pause inspection to aid debugging/tests 946 | server.tool( 947 | 'get_pause_info', 948 | { 949 | pauseId: z.string().optional().describe('Specific pause to describe (default current)'), 950 | format: z.enum(['text', 'json']).optional() 951 | }, 952 | async (args) => { 953 | if (!lastPausedParams) { 954 | return { 955 | content: [{ type: 'text', text: 'Error: No active pause state.' }], 956 | isError: true 957 | }; 958 | } 959 | try { 960 | const pause = args?.pauseId ? pauseMap[args.pauseId] : lastPausedParams; 961 | const top = pause.callFrames[0]; 962 | const fileUrl = 963 | top.url || scriptIdToUrl[top.location.scriptId] || ''; 964 | const info = { 965 | reason: pause.reason, 966 | pauseId: args?.pauseId || currentPauseId, 967 | location: { 968 | url: fileUrl, 969 | line: top.location.lineNumber + 1, 970 | column: (top.location.columnNumber ?? 0) + 1 971 | }, 972 | functionName: top.functionName || null, 973 | scopeTypes: (top.scopeChain || []).map((s: any) => s.type) 974 | }; 975 | return { content: mcpContent(info, args?.format) }; 976 | } catch (error) { 977 | return { 978 | content: [ 979 | { 980 | type: 'text', 981 | text: `Error: Failed to get pause info - ${ 982 | error instanceof Error ? error.message : error 983 | }` 984 | } 985 | ], 986 | isError: true 987 | }; 988 | } 989 | } 990 | ); 991 | 992 | server.tool( 993 | 'stop_debug_session', 994 | {}, 995 | async () => { 996 | try { 997 | if (nodeProcess) { 998 | nodeProcess.kill(); 999 | nodeProcess = null; 1000 | } 1001 | if (nodeDebugClient) { 1002 | await nodeDebugClient.close(); 1003 | nodeDebugClient = null; 1004 | } 1005 | scriptIdToUrl = {}; 1006 | consoleMessages = []; 1007 | lastPausedParams = null; 1008 | pauseMap = {}; 1009 | currentPauseId = null; 1010 | pauseCounter = 0; 1011 | return { 1012 | content: [{ type: 'text', text: 'Debug session terminated.' }] 1013 | }; 1014 | } catch (error) { 1015 | return { 1016 | content: [ 1017 | { 1018 | type: 'text', 1019 | text: `Error: Failed to stop debug session - ${ 1020 | error instanceof Error ? error.message : error 1021 | }` 1022 | } 1023 | ], 1024 | isError: true 1025 | }; 1026 | } 1027 | } 1028 | ); 1029 | 1030 | // Handle process termination 1031 | process.on('SIGINT', () => { 1032 | server.close().catch(console.error); 1033 | process.exit(0); 1034 | }); 1035 | process.on('uncaughtException', (err) => { 1036 | console.error('Uncaught exception in MCP server:', err); 1037 | }); 1038 | process.on('unhandledRejection', (reason) => { 1039 | console.error('Unhandled rejection in MCP server:', reason); 1040 | }); 1041 | 1042 | // Now connect MCP transport after all handlers are in place 1043 | await server.connect(transport); 1044 | --------------------------------------------------------------------------------