From 71e8878cb386796b4d833d87e199e4d6e3ec2a7c Mon Sep 17 00:00:00 2001 From: ozzafar <48795672+ozzafar@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:57:24 +0300 Subject: [PATCH 1/2] fix: reset CLI breakpoint lifecycle and improve npm documentation Reset adapter initialization on shutdown and failure so breakpoint edits remain usable between sessions. Add lifecycle regression coverage and expand the standalone npm README. Fixes #161 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e8c76249-e4b9-47a4-adee-0ef91ec3f978 --- CHANGELOG.md | 3 + docs/architecture/debuggingExecutor.md | 4 + npm/cli/README.md | 224 +++++++++++++++++++++++-- src/cli/cliDebuggingExecutor.ts | 5 + src/test/cliDebuggingExecutor.test.ts | 170 +++++++++++++++++++ 5 files changed, 389 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37881aa..6c4c6ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ## [Unreleased] +### Fixed +- Standalone CLI breakpoint tools remain usable between debug sessions. Adding, removing, and clearing breakpoints after termination, stopping, disposal, or failed startup no longer try to synchronize with an inactive adapter; saved changes are applied on the next launch (#161). + ### Added - **Claude Code auto-registration** - Claude Code is now offered in the agent selection popup and configured via `~/.claude.json`'s user-scope `mcpServers` field. Claude Desktop connects via its Custom Connector UI instead of a static config file; the README's manual configuration section covers both. diff --git a/docs/architecture/debuggingExecutor.md b/docs/architecture/debuggingExecutor.md index 8ae5076..cb4adc7 100644 --- a/docs/architecture/debuggingExecutor.md +++ b/docs/architecture/debuggingExecutor.md @@ -49,6 +49,10 @@ short stop-event grace period so immediately reached breakpoints are reported, while still returning promptly for long-running programs. Closing an MCP session disposes its standalone executor, adapter process, and any debuggee processes started through reverse `runInTerminal` requests. +Adapter initialization is session-scoped and is cleared on termination, shutdown, +and failed startup. Breakpoints remain executor-scoped: edits between sessions +are stored locally and synchronized after the next adapter initializes, while +edits during an initialized session are synchronized immediately. `src/cli/adapterConfig.ts` loads project and user registrations. No adapter is registered, discovered, selected, installed, or upgraded implicitly. diff --git a/npm/cli/README.md b/npm/cli/README.md index 6451c99..d4fb1a2 100644 --- a/npm/cli/README.md +++ b/npm/cli/README.md @@ -1,40 +1,230 @@ # DebugMCP CLI -DebugMCP CLI lets MCP-capable coding agents control language debuggers without -running VS Code. It communicates with explicitly configured Debug Adapter -Protocol (DAP) adapters over stdio, supporting any language for which such an -adapter is available. +**Give your AI coding agent a debugger, without requiring any IDE.** -## Install +DebugMCP CLI is a standalone package that lets coding agents set breakpoints, step through code, inspect +variables, and evaluate expressions in a live debug session via [Model Context Protocol (MCP)](https://modelcontextprotocol.io/). Instead of guessing +from source code alone, your agent can observe what the program actually does. + +Works with GitHub Copilot CLI, Claude Code, Codex, Cursor and other MCP clients that can +launch a stdio server. DebugMCP starts the configured debug adapter as a background +process and communicates with it using the Debug Adapter Protocol (DAP). + +[Report an issue](https://github.com/microsoft/DebugMCP/issues) | +[VS Code extension](https://marketplace.visualstudio.com/items?itemName=ozzafar.debugmcpextension) + +## Requirements + +- **Node.js 20 or newer** and npm. +- An MCP-capable coding agent. +- A separately installed **DAP adapter that communicates over stdio**, plus the + runtime or compiled program you want to debug. + +DebugMCP does **not** discover, download, install, or choose debugger installations +for you. Language support and individual debugging features depend on the adapter +you register. Adapters that only expose a TCP socket are not supported directly. + +## Quick start + +### 1. Install DebugMCP ```console npm install --global debugmcp ``` -## Configure an agent - -Configure GitHub Copilot CLI once: +### 2. Connect your agent ```console debugmcp configure --agent copilot-cli ``` -This also installs the bundled `debug-live` skill into the standard personal -skills directories. Restart the agent after configuration so it discovers the -skill. +For Claude Code or Codex, use `--agent claude-code` or `--agent codex`. +Run `debugmcp configure` without flags for an interactive agent picker, or +configure several agents at once: + +```console +debugmcp configure --agent copilot-cli --agent claude-code --agent codex +``` -## Configure a project +This registers the standalone stdio server and installs the bundled +**`debug-live` skill**, which guides agents through breakpoint-driven root-cause +investigation. Restart the configured agent to load the server and skill. -DebugMCP does not discover, install, or choose debugger installations. Register -the adapter provided by the project's environment: +> Configuration uses one canonical `debugmcp` entry. If that agent already uses +> the DebugMCP VS Code extension, this command replaces its connection with the +> standalone CLI connection. + +### 3. Register your project's debugger + +For a Python project, use the interpreter from your intended environment. +Install [debugpy](https://github.com/microsoft/debugpy) there if it is not already +available: ```console cd path\to\python-project +python -m pip install debugpy debugmcp adapter add python --command "python -m debugpy.adapter" debugmcp adapter validate python ``` -The project registration is stored in `.debugmcp.json`. Start the configured -agent from that project and ask it to use the `debug-live` skill. +`adapter add` saves the registration in `.debugmcp.json` in the current directory. +`adapter validate` starts the adapter and performs a DAP initialization handshake; +it does not launch or debug your application. + +If your agent does not inherit the same environment, register an absolute +interpreter path rather than relying on `python` being on its `PATH`. Paths in +these examples use Windows syntax; substitute paths appropriate to your system. + +### 4. Ask your agent to debug + +Start the agent from your project directory and give it a concrete symptom: + +> Use the debug-live skill to investigate why the total in app.py is incorrect. +> Set a breakpoint before the calculation, run the debugger, inspect the relevant +> inputs, and trace the cause before changing the code. + +The agent invokes the debugger tools through MCP. You do not need to run +`debugmcp serve` separately when the agent is configured to launch it over stdio. + +## What your agent can do + +| Task | MCP tools | +| --- | --- | +| Start and stop a session | `start_debugging`, `stop_debugging` | +| Check whether execution is paused, running, or inactive | `get_debug_status` | +| Step over, into, or out of code | `step_over`, `step_into`, `step_out` | +| Resume, interrupt, or restart execution | `continue_execution`, `pause_execution`, `restart_debugging` | +| Manage breakpoints and logpoints | `add_breakpoint`, `add_logpoint`, `remove_breakpoint`, `list_breakpoints`, `clear_all_breakpoints` | +| Discover variables, then read specific values | `list_variable_names`, `get_variables_values` | +| Evaluate an expression in the paused program | `evaluate_expression` | + +Conditional breakpoints, logpoints, expression syntax, and restart support depend +on your adapter. `get_variables_values` requires explicit variable names; use +`list_variable_names` for discovery instead of dumping the entire scope. + +## Adapter configuration + +### Language shorthands and explicit registration + +Shorthands provide a default DAP type and file extensions for `python`, `csharp`, +`dotnet`, `cpp`, `c`, `javascript`, `typescript`, `node`, `java`, `go`, `rust`, +`ruby`, `php`, `swift`, and `dart`. They are **configuration conveniences, not +bundled debuggers or a guarantee that every adapter for that language will work**. + +For a custom registration, provide both `--type` and `--extensions`. Keep the +executable in `--command` and its arguments in `--args`. For example, using a +specific Python environment: + +```console +debugmcp adapter add project-python --command "C:\projects\demo\.venv\Scripts\python.exe" --type python --extensions .py --args -m debugpy.adapter +``` + +Use the DAP type and startup arguments required by your adapter. For adapter +arguments that conflict with DebugMCP options, put them after `--args --`. + +### Launch settings + +You can edit `.debugmcp.json` to supply adapter-specific launch properties: + +```json +{ + "version": 1, + "adapters": { + "python": { + "command": "python", + "args": ["-m", "debugpy.adapter"], + "type": "python", + "extensions": [".py"], + "transport": "stdio", + "launch": { + "program": "${file}", + "cwd": "${workspaceFolder}", + "justMyCode": true + } + } + } +} +``` + +Alternatively, `adapter add` accepts `--launch` followed by a JSON object; quote +it according to your shell. Launch values support `${workspaceFolder}`, `${file}`, +`${fileDirname}`, and `${fileBasenameNoExtension}`. + +By default, the requested source file is the program and the requested working +directory is its `cwd`. For compiled languages, build the program separately and +set `launch.program` to the executable. For attach workflows, set +`launch.request` to `"attach"` and provide the connection properties required by +your adapter. + +The CLI does not use VS Code's test-discovery API or automatically load +`launch.json`. To debug tests, configure your adapter's launch properties to run +the test runner rather than relying on the `testName` tool parameter. + +### Project and user scope + +Registrations are project-local by default. Add `--user` to `adapter add` or +`adapter remove` to change user-level registrations. Project registrations +override user registrations with the same name. + +```console +debugmcp adapter list +debugmcp adapter list --user +debugmcp adapter remove python +``` + +`adapter list` shows the effective project-plus-user registrations. +If several adapters match a source file's extension, the agent must pass the +desired registration name as `start_debugging.configurationName`. + +## Other MCP clients + +Add a stdio server using your client's configuration format. For clients with +an `mcpServers` object, the entry typically looks like this: + +```json +{ + "mcpServers": { + "debugmcp": { + "command": "debugmcp", + "args": ["serve", "--stdio"] + } + } +} +``` + +Make sure `debugmcp` is on the client's `PATH`, or use its absolute executable +path. Manual registration does not install the companion skill; its source and +workflow are available in +[`skills/debug-live`](https://github.com/microsoft/DebugMCP/tree/main/skills/debug-live). + +## Troubleshooting + +| Symptom | What to check | +| --- | --- | +| No debug adapter is configured | Run `adapter add` in the project directory used by `start_debugging.workingDirectory`, or create a user-level registration. | +| Adapter fails to start or initialize | Run `debugmcp adapter validate `. Check the executable path, arguments, dependencies, and stdio DAP support. | +| Tools or the skill are missing | Restart your agent after `configure`. `debugmcp status` inspects the **GitHub Copilot CLI registration only**, not debugger health or other agents. | +| Several adapters match a file | Pass the registration name as `configurationName`. | +| Program launches but a breakpoint is not hit | Check the source path, executable, debug symbols/source maps, and adapter-specific launch settings. | + +When filing an issue, include the DebugMCP, Node.js, adapter, and OS versions; +your MCP client; the exact tool sequence; and a minimal, sanitized adapter +configuration. Remove secrets from logs and configuration before sharing them. + +## Security + +Debugger access can execute code and read program state with your user's +permissions. Only connect trusted agents and adapters, review tool approvals, +and avoid exposing production credentials in debug sessions. Although the CLI +and adapter run locally, debugger results are returned to your AI client and may +be sent to its model provider. + +## CLI or VS Code extension? + +Use this npm package for a standalone debugger host with no IDE dependency. +Use the [DebugMCP extension](https://marketplace.visualstudio.com/items?itemName=ozzafar.debugmcpextension) +to control VS Code's debugger, launch configurations, and test integration. +The npm package and extension have separate release versions. -Use `debugmcp help` for all commands. +Run `debugmcp help` for the command summary. +Licensed under [MIT](https://github.com/microsoft/DebugMCP/blob/main/LICENSE.txt). diff --git a/src/cli/cliDebuggingExecutor.ts b/src/cli/cliDebuggingExecutor.ts index 3c38ee7..dbc6602 100644 --- a/src/cli/cliDebuggingExecutor.ts +++ b/src/cli/cliDebuggingExecutor.ts @@ -48,6 +48,7 @@ export class CliDebuggingExecutor implements IDebuggingExecutor { throw new Error('A debug session is already active. Stop it before starting another.'); } if (this.client) { + this.initialized = false; await this.client.close(); this.client = undefined; } @@ -105,6 +106,7 @@ export class CliDebuggingExecutor implements IDebuggingExecutor { } return true; } catch (error) { + this.initialized = false; await client.close(); this.client = undefined; this.state = 'terminated'; @@ -128,6 +130,7 @@ export class CliDebuggingExecutor implements IDebuggingExecutor { terminateDebuggee: true }); } finally { + this.initialized = false; await client.close(); this.client = undefined; this.state = 'terminated'; @@ -139,6 +142,7 @@ export class CliDebuggingExecutor implements IDebuggingExecutor { public async dispose(): Promise { const client = this.client; + this.initialized = false; this.client = undefined; if (client) { await client.close(); @@ -383,6 +387,7 @@ export class CliDebuggingExecutor implements IDebuggingExecutor { this.emitState(); }); const terminated = () => { + this.initialized = false; this.state = 'terminated'; this.threadId = undefined; this.frameId = undefined; diff --git a/src/test/cliDebuggingExecutor.test.ts b/src/test/cliDebuggingExecutor.test.ts index 997cbbe..e8e0cc8 100644 --- a/src/test/cliDebuggingExecutor.test.ts +++ b/src/test/cliDebuggingExecutor.test.ts @@ -16,6 +16,176 @@ suite('CLI debugging executor', () => { directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'debugmcp-executor-')); }); + suite('CLI breakpoint lifecycle (#161)', () => { + let sourcePath: string; + let config: CliDebugConfiguration; + let executor: CliDebuggingExecutor; + + setup(async () => { + sourcePath = path.join(directory, 'app.fake'); + const adapterPath = path.join(directory, 'adapter.cjs'); + await fs.promises.writeFile(adapterPath, ` + let buffer = Buffer.alloc(0); + let sequence = 1; + let failConfiguration = false; + let failDisconnect = false; + const updates = []; + function send(message) { + const payload = Buffer.from(JSON.stringify({ seq: sequence++, ...message })); + process.stdout.write('Content-Length: ' + payload.length + '\\r\\n\\r\\n'); + process.stdout.write(payload); + } + function respond(request, body = {}) { + send({ type: 'response', request_seq: request.seq, command: request.command, success: true, body }); + } + function handle(request) { + switch (request.command) { + case 'initialize': + respond(request, { supportsConfigurationDoneRequest: true }); + break; + case 'launch': + failConfiguration = request.arguments.failConfiguration === true; + failDisconnect = request.arguments.failDisconnect === true; + respond(request); + send({ type: 'event', event: 'initialized' }); + break; + case 'configurationDone': + case 'disconnect': + if (request.command === 'configurationDone' ? failConfiguration : failDisconnect) { + send({ type: 'response', request_seq: request.seq, command: request.command, + success: false, message: request.command + ' rejected' }); + } else { + respond(request); + } + break; + case 'setBreakpoints': + updates.push(request.arguments); + respond(request, { breakpoints: request.arguments.breakpoints.map(bp => ({ + verified: true, line: bp.line + })) }); + break; + case 'recordedUpdates': + respond(request, { updates }); + break; + case 'finish': + respond(request); + if (request.arguments.event === 'exit') { + setTimeout(() => process.exit(0), 10); + } else { + send({ type: 'event', event: request.arguments.event, body: { exitCode: 0 } }); + } + break; + default: + throw new Error('Unexpected request: ' + request.command); + } + } + process.stdin.on('data', chunk => { + buffer = Buffer.concat([buffer, chunk]); + while (true) { + const headerEnd = buffer.indexOf('\\r\\n\\r\\n'); + if (headerEnd < 0) return; + const length = Number(/Content-Length:\\s*(\\d+)/i.exec(buffer.subarray(0, headerEnd).toString('ascii'))[1]); + const start = headerEnd + 4; + if (buffer.length < start + length) return; + const request = JSON.parse(buffer.subarray(start, start + length).toString('utf8')); + buffer = buffer.subarray(start + length); + handle(request); + } + }); + `, 'utf8'); + config = { + name: 'breakpoint lifecycle', + type: 'fake', + request: 'launch', + adapterName: 'fake', + adapter: { command: process.execPath, args: [adapterPath], type: 'fake', extensions: ['.fake'] } + }; + executor = new CliDebuggingExecutor(); + }); + + teardown(async () => { + await executor.dispose(); + }); + + async function recordedUpdates(): Promise { + const client = executor['client']; + assert.ok(client); + return (await client.request('recordedUpdates')).updates; + } + + function update(lines: number[]): object { + return { + source: { path: sourcePath, name: 'app.fake' }, + breakpoints: lines.map(line => ({ line })), + sourceModified: false + }; + } + + for (const ending of ['stop', 'terminated', 'exited', 'exit', 'dispose', 'failed launch', 'failed disconnect']) { + for (const operation of ['add', 'remove', 'clear']) { + test(`${operation} breakpoints after ${ending} stays local and syncs on the next launch`, async () => { + await executor.addBreakpoint(sourcePath, 1); + await executor.addBreakpoint(sourcePath, 2); + if (ending === 'failed launch') { + await assert.rejects( + () => executor.startDebugging(directory, { ...config, failConfiguration: true }), + /configurationDone rejected/ + ); + } else { + await executor.startDebugging(directory, { ...config, failDisconnect: ending === 'failed disconnect' }); + assert.deepStrictEqual(await recordedUpdates(), [update([1, 2])]); + if (ending === 'stop') { + await executor.stopDebugging(); + } else if (ending === 'failed disconnect') { + await assert.rejects(() => executor.stopDebugging(), /disconnect rejected/); + } else if (ending === 'dispose') { + await executor.dispose(); + } else { + const client = executor['client']; + assert.ok(client); + const ended = client.waitForEvent(ending, 2_000); + await client.request('finish', { event: ending }); + await ended; + } + } + + assert.strictEqual((await executor.getCurrentDebugState()).sessionActive, false); + if (operation === 'add') { + await executor.addBreakpoint(sourcePath, 3); + } else if (operation === 'remove') { + await executor.removeBreakpoint(sourcePath, 1); + } else { + await executor.clearAllBreakpoints(); + } + assert.strictEqual(executor['initialized'], false); + const lines = operation === 'add' ? [1, 2, 3] : operation === 'remove' ? [2] : []; + assert.deepStrictEqual(executor.getBreakpoints().map(bp => bp.line), lines); + if (ending === 'terminated' || ending === 'exited') { + assert.deepStrictEqual(await recordedUpdates(), [update([1, 2])], 'no offline DAP synchronization'); + } + + await executor.startDebugging(directory, config); + assert.deepStrictEqual(await recordedUpdates(), lines.length ? [update(lines)] : []); + await executor.addBreakpoint(sourcePath, 4); + assert.deepStrictEqual(await recordedUpdates(), + [...(lines.length ? [update(lines)] : []), update([...lines, 4])]); + }); + } + } + + test('all breakpoint edits still synchronize immediately in an active session', async () => { + await executor.startDebugging(directory, config); + await executor.addBreakpoint(sourcePath, 1, 'count > 2', 'count={count}'); + await executor.removeBreakpoint(sourcePath, 1); + await executor.addBreakpoint(sourcePath, 2); + await executor.clearAllBreakpoints(); + assert.deepStrictEqual(await recordedUpdates(), [ + { ...update([]), breakpoints: [{ line: 1, condition: 'count > 2', logMessage: 'count={count}' }] }, + update([]), update([2]), update([]) + ]); + }); + }); + teardown(async () => { await fs.promises.rm(directory, { recursive: true, force: true }); }); From 212d21365c7f08061c5e6336e80d6c4f93d66c6d Mon Sep 17 00:00:00 2001 From: ozzafar <48795672+ozzafar@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:59:37 +0300 Subject: [PATCH 2/2] chore: bump standalone npm package to 0.1.2 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e8c76249-e4b9-47a4-adee-0ef91ec3f978 --- npm/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npm/cli/package.json b/npm/cli/package.json index 270b099..6fbbb1f 100644 --- a/npm/cli/package.json +++ b/npm/cli/package.json @@ -1,6 +1,6 @@ { "name": "debugmcp", - "version": "0.1.1", + "version": "0.1.2", "description": "Standalone MCP debugger host for explicitly configured Debug Adapter Protocol adapters.", "license": "MIT", "author": "Microsoft Corporation",