diff --git a/README.md b/README.md index 310fca9..99f985d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Let AI agents debug your code inside VS Code - set breakpoints, step through exe [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![VS Code](https://img.shields.io/badge/VS%20Code-1.104.0+-blue.svg)](https://code.visualstudio.com/) -[![Version](https://img.shields.io/badge/version-2.2.3-green.svg)](https://github.com/microsoft/DebugMCP) +[![Version](https://img.shields.io/badge/version-2.2.4-green.svg)](https://github.com/microsoft/DebugMCP) [![VS Marketplace](https://img.shields.io/badge/VS%20Marketplace-Install-blue.svg)](https://marketplace.visualstudio.com/items?itemName=ozzafar.debugmcpextension) > ⭐ **If you find DebugMCP useful, please [star the repo on GitHub](https://github.com/microsoft/DebugMCP)!** It helps others discover the project and motivates continued development. @@ -61,6 +61,7 @@ DebugMCP is an MCP server that gives AI coding agents full control over the VS C | **pause_execution** | Interrupt a freely-running program and stop at its current location (no breakpoint needed) | None | | **restart_debugging** | Restart the current debug session | None | | **add_breakpoint** | Add a breakpoint at a specific line (optionally conditional) | `fileFullPath` (required)
`line` (required, 1-based)
`condition` (optional) | +| **add_logpoint** | Add a logpoint that logs a message (instead of pausing) when a line is reached | `fileFullPath` (required)
`line` (required, 1-based)
`logMessage` (required, `{expr}` interpolated)
`condition` (optional) | | **remove_breakpoint** | Remove a breakpoint from a specific line | `fileFullPath` (required)
`line` (required) | | **clear_all_breakpoints** | Remove all breakpoints at once | None | | **list_breakpoints** | List all active breakpoints | None | diff --git a/package-lock.json b/package-lock.json index feb66e0..0f89920 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "debugmcpextension", - "version": "2.2.3", + "version": "2.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "debugmcpextension", - "version": "2.2.3", + "version": "2.2.4", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/package.json b/package.json index fc6b537..fcdb404 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "debugmcpextension", "displayName": "DebugMCP — Agentic Debugging for VS Code, Cursor & More", "description": "Your AI agent debugs for you — right inside VS Code, Cursor & other VS Code-based editors. Let Copilot, Cline, Cursor, Codex & any MCP agent set breakpoints, step through code, and inspect variables live instead of guessing from logs.", - "version": "2.2.3", + "version": "2.2.4", "publisher": "ozzafar", "author": { "name": "Oz Zafar", diff --git a/skills/debug-live/SKILL.md b/skills/debug-live/SKILL.md index 5431e45..b752b5c 100644 --- a/skills/debug-live/SKILL.md +++ b/skills/debug-live/SKILL.md @@ -4,6 +4,7 @@ description: Drive an interactive VS Code debugger to investigate bugs, failing license: MIT allowed-tools: - add_breakpoint + - add_logpoint - remove_breakpoint - clear_all_breakpoints - list_breakpoints @@ -169,6 +170,10 @@ Before ending the debug session, confirm you can answer: you isolate the issue, add tighter breakpoints around the problematic region. - **Use line numbers.** `add_breakpoint` takes a 1-based `line`; re-check the line after edits since numbers shift when code changes. +- **Prefer logpoints for loops/hot paths.** When you want to observe how a value evolves + across many iterations without stopping, use `add_logpoint` with `{expression}` + interpolation (e.g. `iter {i}: total={total}`) instead of repeatedly continuing from a + breakpoint. Logpoints also avoid distorting timing-sensitive code. - **Don't overuse breakpoints.** A handful of well-placed pauses beats dozens of noisy ones. After each session, `clear_all_breakpoints` to start fresh. - **For test debugging,** pass `testName` to `start_debugging`. The server routes through diff --git a/src/controlServer.ts b/src/controlServer.ts index 6a21512..215938f 100644 --- a/src/controlServer.ts +++ b/src/controlServer.ts @@ -97,6 +97,8 @@ export class ControlServer { return this.handler.handleRestart(); case 'handleAddBreakpoint': return this.handler.handleAddBreakpoint(args); + case 'handleAddLogpoint': + return this.handler.handleAddLogpoint(args); case 'handleRemoveBreakpoint': return this.handler.handleRemoveBreakpoint(args); case 'handleClearAllBreakpoints': diff --git a/src/debugMCPServer.ts b/src/debugMCPServer.ts index 7f4156f..4de9287 100644 --- a/src/debugMCPServer.ts +++ b/src/debugMCPServer.ts @@ -270,6 +270,18 @@ export class DebugMCPServer { }, async (args: { fileFullPath: string; line: number; condition?: string }) => this.runTool('add_breakpoint', () => debuggingHandler.handleAddBreakpoint(args))); + // Add logpoint tool + server.registerTool('add_logpoint', { + description: 'Add a logpoint: a breakpoint that logs a message instead of pausing execution. Ideal for tracing values across many iterations or hot paths without stopping, or where a hard pause would distort timing. Embed expressions in curly braces to interpolate runtime values, e.g. "user id={user.id}, count={items.length}". Provide an optional condition to only log when it evaluates to true.', + inputSchema: { + fileFullPath: z.string().describe('Full path to the file'), + line: z.number().int().describe('Line number (1-based) where the logpoint should be set'), + logMessage: z.string().describe('Message to log when the line is reached. Wrap expressions in {curly braces} to interpolate runtime values.'), + condition: z.string().optional().describe('Optional condition expression. When provided, the message is only logged if this expression evaluates to true.'), + }, + }, async (args: { fileFullPath: string; line: number; logMessage: string; condition?: string }) => + this.runTool('add_logpoint', () => debuggingHandler.handleAddLogpoint(args))); + // Remove breakpoint tool server.registerTool('remove_breakpoint', { description: 'Remove a breakpoint that is no longer needed.', diff --git a/src/debuggingExecutor.ts b/src/debuggingExecutor.ts index 023088d..15821c0 100644 --- a/src/debuggingExecutor.ts +++ b/src/debuggingExecutor.ts @@ -33,7 +33,7 @@ export interface IDebuggingExecutor { continue(): Promise; pause(): Promise; restart(): Promise; - addBreakpoint(uri: vscode.Uri, line: number, condition?: string): Promise; + addBreakpoint(uri: vscode.Uri, line: number, condition?: string, logMessage?: string): Promise; removeBreakpoint(uri: vscode.Uri, line: number): Promise; getCurrentDebugState(numNextLines: number): Promise; getVariables(frameId: number, scope?: 'local' | 'global' | 'all'): Promise; @@ -326,14 +326,17 @@ export class DebuggingExecutor implements IDebuggingExecutor { /** * Add a breakpoint at specified location. An optional condition makes it a * conditional breakpoint that only pauses execution when the expression - * evaluates to true. + * evaluates to true. An optional logMessage makes it a logpoint that logs + * the message (with {expressions} interpolated) instead of pausing. */ - public async addBreakpoint(uri: vscode.Uri, line: number, condition?: string): Promise { + public async addBreakpoint(uri: vscode.Uri, line: number, condition?: string, logMessage?: string): Promise { try { const breakpoint = new vscode.SourceBreakpoint( new vscode.Location(uri, new vscode.Position(line - 1, 0)), true, - condition + condition, + undefined, + logMessage ); vscode.debug.addBreakpoints([breakpoint]); } catch (error) { diff --git a/src/debuggingHandler.ts b/src/debuggingHandler.ts index d068386..08551b1 100644 --- a/src/debuggingHandler.ts +++ b/src/debuggingHandler.ts @@ -19,6 +19,7 @@ export interface IDebuggingHandler { handlePause(): Promise; handleRestart(): Promise; handleAddBreakpoint(args: { fileFullPath: string; line: number; condition?: string }): Promise; + handleAddLogpoint(args: { fileFullPath: string; line: number; logMessage: string; condition?: string }): Promise; handleRemoveBreakpoint(args: { fileFullPath: string; line: number }): Promise; handleClearAllBreakpoints(): Promise; handleListBreakpoints(): Promise; @@ -325,6 +326,39 @@ export class DebuggingHandler implements IDebuggingHandler { } } + /** + * Add a logpoint: a breakpoint that logs a message (with {expressions} + * interpolated by the debug adapter) instead of pausing execution. An + * optional condition only logs when the expression is true. + */ + public async handleAddLogpoint(args: { fileFullPath: string; line: number; logMessage: string; condition?: string }): Promise { + const { fileFullPath, line, logMessage, condition } = args; + + try { + if (!Number.isInteger(line) || line < 1) { + throw new Error(`Invalid line number: ${line}. Provide a 1-based line number.`); + } + if (!logMessage) { + throw new Error('A non-empty logMessage is required for a logpoint.'); + } + + // Validate the line exists so we fail clearly instead of setting an + // unbound logpoint past the end of the file. + const document = await vscode.workspace.openTextDocument(vscode.Uri.file(fileFullPath)); + if (line > document.lineCount) { + throw new Error(`Line ${line} is out of range: ${fileFullPath} has ${document.lineCount} lines.`); + } + + const uri = vscode.Uri.file(fileFullPath); + await this.executor.addBreakpoint(uri, line, condition, logMessage); + + const conditionInfo = condition ? ` (condition: ${condition})` : ''; + return `Logpoint added at ${fileFullPath}:${line}${conditionInfo}`; + } catch (error) { + throw new Error(`Error adding logpoint: ${error}`); + } + } + /** * Remove a breakpoint from specified location */ @@ -372,7 +406,9 @@ export class DebuggingHandler implements IDebuggingHandler { const fileName = bp.location.uri.fsPath.split(/[/\\]/).pop(); const line = bp.location.range.start.line + 1; const conditionInfo = bp.condition ? ` (condition: ${bp.condition})` : ''; - breakpointList += `${index + 1}. ${fileName}:${line}${conditionInfo}\n`; + const kind = bp.logMessage ? `Logpoint` : `Breakpoint`; + const logInfo = bp.logMessage ? ` (log: ${bp.logMessage})` : ''; + breakpointList += `${index + 1}. ${kind} ${fileName}:${line}${conditionInfo}${logInfo}\n`; } else if (bp instanceof vscode.FunctionBreakpoint) { breakpointList += `${index + 1}. Function: ${bp.functionName}\n`; } diff --git a/src/routingDebuggingHandler.ts b/src/routingDebuggingHandler.ts index f6489e9..507fb44 100644 --- a/src/routingDebuggingHandler.ts +++ b/src/routingDebuggingHandler.ts @@ -204,6 +204,10 @@ export class RoutingDebuggingHandler implements IDebuggingHandler { return this.forward('handleAddBreakpoint', args, args.fileFullPath); } + public handleAddLogpoint(args: { fileFullPath: string; line: number; logMessage: string; condition?: string }): Promise { + return this.forward('handleAddLogpoint', args, args.fileFullPath); + } + public handleRemoveBreakpoint(args: { fileFullPath: string; line: number }): Promise { return this.forward('handleRemoveBreakpoint', args, args.fileFullPath); } diff --git a/src/test/routing.test.ts b/src/test/routing.test.ts index 2fe6334..2f34343 100644 --- a/src/test/routing.test.ts +++ b/src/test/routing.test.ts @@ -28,6 +28,7 @@ class RecordingHandler implements IDebuggingHandler { handlePause() { return this.record('pause', {}); } handleRestart() { return this.record('restart', {}); } handleAddBreakpoint(args: any) { return this.record('addBp', args); } + handleAddLogpoint(args: any) { return this.record('addLp', args); } handleRemoveBreakpoint(args: any) { return this.record('removeBp', args); } handleClearAllBreakpoints() { return this.record('clearBp', {}); } handleListBreakpoints() { return this.record('listBp', {}); } @@ -125,6 +126,25 @@ suite('Multi-window routing', () => { assert.strictEqual(handlerB.calls.length, 0); }); + test('add_logpoint routes by fileFullPath before any start_debugging', async () => { + const repoA = path.join(dir, 'repoA'); + const repoB = path.join(dir, 'repoB'); + const handlerA = new RecordingHandler('A'); + const handlerB = new RecordingHandler('B'); + await startWindow('a.json', [repoA], handlerA); + await startWindow('b.json', [repoB], handlerB); + + const routing = new RoutingDebuggingHandler(new WorkspaceRegistry(process.pid, dir)); + const result = await routing.handleAddLogpoint({ + fileFullPath: path.join(repoA, 'src', 'y.py'), + line: 1, + logMessage: 'i={i}' + }); + + assert.strictEqual(result, 'A:addLp'); + assert.strictEqual(handlerB.calls.length, 0); + }); + test('throws a helpful error when no window owns the path', async () => { const repoA = path.join(dir, 'repoA'); const repoB = path.join(dir, 'repoB');