Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **BREAKING (behavioral): launch sessions now default to `breakOnExceptions: "uncaught"`** — a crashing script pauses at the uncaught exception (`lastStop.reason: "exception"`, stack/locals inspectable, `exceptionInfo` where supported) instead of running to termination. Applies uniformly to Python, JavaScript, Java, Go (panics), .NET, Rust (panics), and the mock adapter; Ruby keeps the old run-to-termination behavior (rdbg has no uncaught-only filter). Pass `breakOnExceptions: "none"` to restore the old behavior per session. Attach sessions are unchanged — no default is ever applied on attach (fixes #244)

### Added
- **Logpoints** — `set_breakpoint` accepts an optional `logMessage`: instead of pausing, the adapter logs the message (expressions in `{curly braces}` interpolated with live values) as output that lands in `get_output` and the session output resource while the program runs at full speed — the prod-safe just-in-time diagnostics primitive, combinable with `condition`. A single shared `toSourceBreakpoint` mapper now feeds every setBreakpoints construction site (live re-send, launch-time initial breakpoints, js-debug handshake), which also fixes `suspendPolicy` being silently dropped on the launch path. Support is gated per adapter: Python/JavaScript/Go/Rust/mock work; Java/.NET fail fast with a clear error; unknown support (Ruby) is accepted with a warning and re-checked against the adapter's live capabilities at launch. The mock adapter simulates logpoints (logs without stopping) for hermetic e2e coverage (fixes #235)

- **Breakpoint management tools** — new `list_breakpoints` (per-session listing with verified state and adapter-assigned ids, optional file filter), `remove_breakpoint` (by breakpoint id, or by file+line which removes every breakpoint at that location), and `clear_breakpoints` (whole session or one file). Removal and clearing take effect immediately while the debuggee is running or paused (the file's remaining set is re-sent, DAP replace-all) and deliberately keep working after the program exits so breakpoints can be adjusted between launches. DAP `breakpoint` events — previously dropped — are now wired end-to-end, so deferred verifications (debugpy module load, JDI class load, js-debug async binding, netcoredbg pending breakpoints) update the stored state; pre-launch breakpoints are also re-synced once a launch completes so their verified state and adapter ids reach the store on adapters that don't push events (fixes #236)

- **Break-on-exception support** — new `breakOnExceptions` option (`"uncaught"` | `"all"` | `"none"`, default `"none"`) on `start_debugging` and `attach_to_process`: an uncaught exception now pauses at the crash site with the stack and locals inspectable instead of terminating the session. The abstract mode is resolved to per-language debugger filter IDs by the adapter policy (Python `uncaught`/`raised`+`uncaught`, JavaScript `uncaught`/`all` — runtime-verified against js-debug, Java `uncaught`/`caught`+`uncaught`, .NET `user-unhandled`/`all`, Go `fatal`+`panic`, Rust `rust_panic`/`+cpp_throw`, Ruby `all`-only via `any`); an unsupported mode is skipped with a warning and never aborts the launch (fixes #220)
Expand Down
2 changes: 2 additions & 0 deletions docs/javascript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ const child = spawn('node', ['child.js']);

### Log Points

A `logMessage` turns the breakpoint into a logpoint: execution does not pause — the interpolated message (expressions in `{curly braces}`) arrives in the session output, readable via `get_output`.

```json
{
"tool": "set_breakpoint",
Expand Down
2 changes: 1 addition & 1 deletion docs/jit-diagnostics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Total pause time: a few hundred milliseconds around one request. No redeploy, no

- **Never expose a debug port through a Service, Ingress, or LoadBalancer.** debugpy/rdbg listeners are unauthenticated and allow code execution. `kubectl port-forward` keeps the connection inside your kubeconfig's auth.
- Prefer **on-demand listeners** over always-on ones: add the debug flag to a single quarantined pod when diagnosing (e.g. remove the pod from the Service selector, then `kubectl debug`/patch it), rather than baking it into the deployment as this tutorial image does for convenience.
- Target **staging, canaries, or quarantined sick pods** — pausing a pod that's in a live serving rotation stops its traffic for the duration of the pause. Logpoint-style non-breaking inspection is tracked in [#235](https://github.com/debugmcp/mcp-debugger/issues/235).
- Target **staging, canaries, or quarantined sick pods** — pausing a pod that's in a live serving rotation stops its traffic for the duration of the pause. For non-breaking inspection, pass `logMessage` to `set_breakpoint` (a logpoint, [#235](https://github.com/debugmcp/mcp-debugger/issues/235)): the pod keeps serving at full speed while interpolated values stream into `get_output`.
- The same flow works for **Ruby** (`rdbg --open --port`) — see [docs/ruby/README.md](../ruby/README.md) — and **Java** (JDWP agent), covering three of the most common backend runtimes.

## Cleanup
Expand Down
17 changes: 16 additions & 1 deletion docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ Sets a breakpoint in a source file.
- `sessionId` (string, required): The ID of the debug session.
- `file` (string, required): Path to the source file (absolute or relative to project root).
- `line` (number, required): Line number where to set breakpoint (1-indexed).
- `condition` (string, optional): Conditional expression for the breakpoint *(not verified to work)*.
- `condition` (string, optional): Conditional expression — only break (or log) when it evaluates truthy.
- `logMessage` (string, optional): Create a **logpoint** instead of a pausing breakpoint — see [Logpoints](#logpoints) below.
- `suspendPolicy` (string, optional): Suspend policy when the breakpoint is hit — `"all"` suspends all threads (default), `"thread"` suspends only the event thread. Only supported by the Java/JDI adapter.

**Response:**
Expand Down Expand Up @@ -166,6 +167,20 @@ Sets a breakpoint in a source file.
- Setting breakpoints on non-executable lines (comments, blank lines, declarations) may cause unexpected behavior
- Executable lines that work well: assignments, function calls, conditionals, returns

#### Logpoints

Passing `logMessage` turns the breakpoint into a DAP logpoint: when the line is hit the program does **not** pause — the message is logged and execution continues at full speed. Expressions in `{curly braces}` are interpolated with live values (e.g. `"order={orderId} total={total}"`), and the messages arrive in the session output, readable via [`get_output`](#get_output) and the `debug://sessions/{id}/output` resource. `condition` may be combined with `logMessage` — the message is only logged when the condition holds.

This is the prod-safe just-in-time diagnostics primitive: attach to a live process, plant logpoints at suspect lines, read interpolated values from `get_output` — no pauses, no pre-instrumented logging.

Support is adapter-dependent:

| Adapters | Behavior |
|---|---|
| Python, JavaScript/TypeScript, Go, Rust, mock | Supported — logs without pausing |
| Java, .NET | Not supported — `set_breakpoint` with `logMessage` fails fast with a clear error |
| Ruby | Unknown — accepted with a warning; validated against the adapter's capabilities at launch |

---

### list_breakpoints
Expand Down
71 changes: 47 additions & 24 deletions packages/adapter-mock/src/mock-adapter-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ function createConnection(input?: Readable, output?: Writable): DAPConnection {
class MockDebugAdapterProcess {
private connection?: DAPConnection;
private server?: net.Server;
private breakpoints = new Map<string, DebugProtocol.Breakpoint[]>();
// Stored breakpoints carry logMessage so the run simulation can treat
// logpoints as non-stopping (issue #235)
private breakpoints = new Map<string, Array<DebugProtocol.Breakpoint & { logMessage?: string }>>();
private variableHandles = new Map<number, { variables: Array<{ name: string; value: string; type: string }> }>();
private nextVariableReference = 1000;
private currentLine = 1;
Expand Down Expand Up @@ -305,7 +307,7 @@ class MockDebugAdapterProcess {
supportSuspendDebuggee: false,
supportsDelayedStackTraceLoading: false,
supportsLoadedSourcesRequest: false,
supportsLogPoints: false,
supportsLogPoints: true,
supportsTerminateThreadsRequest: false,
supportsSetExpression: false,
supportsTerminateRequest: true,
Expand Down Expand Up @@ -406,6 +408,35 @@ class MockDebugAdapterProcess {
} as DebugProtocol.StoppedEvent);
}

/**
* Walk the sorted breakpoint list from a line: logpoint lines emit an
* output event (naive {expr} interpolation) and do NOT stop; the first
* plain breakpoint after the line is the next stop (issue #235).
*/
private findNextStop(fromLine: number): (DebugProtocol.Breakpoint & { logMessage?: string }) | undefined {
const allBreakpoints = Array.from(this.breakpoints.values())
.flat()
.filter(bp => bp.line !== undefined)
.sort((a, b) => (a.line || 0) - (b.line || 0));

for (const bp of allBreakpoints) {
if ((bp.line || 0) <= fromLine) continue;
if (bp.logMessage !== undefined) {
const interpolated = bp.logMessage.replace(/\{[^}]*\}/g, '42');
this.log(`Logpoint at line ${bp.line}: ${interpolated}`);
this.sendEvent({
seq: 0,
type: 'event',
event: 'output',
body: { category: 'console', output: `${interpolated}\n` }
} as DebugProtocol.OutputEvent);
continue;
}
return bp;
}
return undefined;
}

private handleLaunch(request: DebugProtocol.LaunchRequest): void {
const args = request.arguments as DebugProtocol.LaunchRequestArguments & { stopOnEntry?: boolean; program?: string };
this.log(`Launching with args: ${JSON.stringify(args)}`);
Expand Down Expand Up @@ -437,16 +468,13 @@ class MockDebugAdapterProcess {
}, 100);
} else {
this.log(`Running without stopOnEntry, will hit first breakpoint`);
// Simulate running to first breakpoint
// Simulate running to the first stopping breakpoint (logpoints log
// without stopping — see findNextStop)
setTimeout(() => {
const allBreakpoints = Array.from(this.breakpoints.entries())
.flatMap(([filePath, bps]) => bps.map(bp => ({ filePath, ...bp })))
.filter(bp => bp.line !== undefined)
.sort((a, b) => (a.line || 0) - (b.line || 0));
const firstStop = this.findNextStop(0);

if (allBreakpoints.length > 0) {
const firstBreakpoint = allBreakpoints[0];
this.currentLine = firstBreakpoint.line || 1;
if (firstStop) {
this.currentLine = firstStop.line || 1;
this.log(`Hit first breakpoint at line ${this.currentLine}`);
this.sendEvent({
seq: 0,
Expand Down Expand Up @@ -493,11 +521,13 @@ class MockDebugAdapterProcess {
id: Math.floor(Math.random() * 100000),
verified: true,
line: bp.line,
source: args.source
});
source: args.source,
// Retained for the run simulation: logpoint lines log instead of stopping
...(bp.logMessage !== undefined ? { logMessage: bp.logMessage } : {})
} as DebugProtocol.Breakpoint & { logMessage?: string });
}
}

this.breakpoints.set(args.source?.path || 'unknown', breakpoints);

this.sendResponse({
Expand Down Expand Up @@ -650,20 +680,13 @@ class MockDebugAdapterProcess {
}
});

// Simulate hitting a breakpoint or terminating
// Simulate hitting a breakpoint or terminating (logpoints log without
// stopping — see findNextStop)
setTimeout(() => {
const allBreakpoints = Array.from(this.breakpoints.entries())
.flatMap(([filePath, bps]) => bps.map(bp => ({ filePath, ...bp })))
.filter(bp => bp.line !== undefined)
.sort((a, b) => (a.line || 0) - (b.line || 0));
const nextBreakpoint = this.findNextStop(this.currentLine);

this.log(`Continue from line ${this.currentLine}. All breakpoints: ${allBreakpoints.map(bp => bp.line).join(', ')}`);
this.log(`Next stopping breakpoint after line ${this.currentLine}: ${nextBreakpoint ? nextBreakpoint.line : 'none'}`);

// Find next breakpoint after current line
const nextBreakpoint = allBreakpoints.find(bp => (bp.line || 0) > this.currentLine);

this.log(`Next breakpoint after line ${this.currentLine}: ${nextBreakpoint ? nextBreakpoint.line : 'none'}`);

if (nextBreakpoint && nextBreakpoint.line) {
// Hit the next breakpoint
this.currentLine = nextBreakpoint.line;
Expand Down
4 changes: 3 additions & 1 deletion packages/adapter-mock/src/mock-debug-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ export class MockDebugAdapter extends EventEmitter implements IDebugAdapter {
DebugFeature.CONDITIONAL_BREAKPOINTS,
DebugFeature.FUNCTION_BREAKPOINTS,
DebugFeature.VARIABLE_PAGING,
DebugFeature.SET_VARIABLE
DebugFeature.SET_VARIABLE,
// Matches mock-adapter-process's initialize response (issue #235)
DebugFeature.LOG_POINTS
],
};
}
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,4 @@ export {
sanitizeStderrTail
} from './utils/env-sanitizer.js';
export { LineBuffer } from './utils/line-buffer.js';
export { toSourceBreakpoint, type BreakpointFields } from './utils/to-source-breakpoint.js';
7 changes: 4 additions & 3 deletions packages/shared/src/interfaces/adapter-policy-dotnet.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**
/**
* DotnetAdapterPolicy - DAP proxy policy for the .NET debug adapter (netcoredbg)
*
* This policy encodes all netcoredbg-specific behaviors that the DAP proxy worker
Expand All @@ -13,7 +13,7 @@
* ## DAP sequence
*
* netcoredbg follows the standard DAP sequence:
* initialize response initialized event attach/launch configurationDone
* initialize → response → initialized event → attach/launch → configurationDone
*
* ## Adapter ID
*
Expand Down Expand Up @@ -41,6 +41,7 @@ import type { DapClientBehavior, DapClientContext, ReverseRequestResult } from '

export const DotnetAdapterPolicy: AdapterPolicy = {
name: 'dotnet',
supportsLogPoints: false,
supportsReverseStartDebugging: false,
childSessionStrategy: 'none',
buildChildStartArgs: () => {
Expand Down Expand Up @@ -226,7 +227,7 @@ export const DotnetAdapterPolicy: AdapterPolicy = {
getInitializationBehavior: () => {
return {
// netcoredbg sends the `initialized` event immediately after the
// `initialize` response before any launch/attach request.
// `initialize` response — before any launch/attach request.
// We must defer configurationDone handling and send launch first,
// because netcoredbg requires launch before configurationDone.
sendLaunchBeforeConfig: true,
Expand Down
5 changes: 3 additions & 2 deletions packages/shared/src/interfaces/adapter-policy-go.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**
/**
* GoAdapterPolicy - policy for Go Debug Adapter (Delve/dlv)
*
* Encodes Delve-specific behaviors and variable handling logic.
Expand All @@ -12,6 +12,7 @@ import type { DapClientBehavior, DapClientContext, ReverseRequestResult } from '

export const GoAdapterPolicy: AdapterPolicy = {
name: 'go',
supportsLogPoints: true,
supportsReverseStartDebugging: false,
childSessionStrategy: 'none',
buildChildStartArgs: () => {
Expand Down Expand Up @@ -234,7 +235,7 @@ export const GoAdapterPolicy: AdapterPolicy = {
sendLaunchBeforeConfig: true,
// Delve has no caught/uncaught distinction; both modes arm the same
// filters. IDs are Delve's real ones ('unrecovered-panic',
// 'runtime-fatal-throw', dlv 1.26) the shorthand 'panic'/'fatal'
// 'runtime-fatal-throw', dlv 1.26) — the shorthand 'panic'/'fatal'
// shipped in #220 was silently accepted-and-ignored by Delve, caught
// live by the #243 capability drift warning during #244 validation.
exceptionFilters: {
Expand Down
3 changes: 2 additions & 1 deletion packages/shared/src/interfaces/adapter-policy-java.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**
/**
* JavaAdapterPolicy - policy for Java Debug Adapter (JDI bridge / JdiDapServer)
*
* JdiDapServer speaks DAP over TCP natively using JDI. It uses a non-standard
Expand All @@ -13,6 +13,7 @@ import type { DapClientBehavior, DapClientContext, ReverseRequestResult } from '

export const JavaAdapterPolicy: AdapterPolicy = {
name: 'java',
supportsLogPoints: false,
supportsReverseStartDebugging: false,
childSessionStrategy: 'none',
buildChildStartArgs: () => {
Expand Down
11 changes: 7 additions & 4 deletions packages/shared/src/interfaces/adapter-policy-js.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { AdapterPolicy, AdapterSpecificState, CommandHandling } from './ada
import { resolveExceptionFilters } from './adapter-policy.js';
import { SessionState } from '@debugmcp/shared';
import type { StackFrame, Variable } from '../models/index.js';
import { toSourceBreakpoint, type BreakpointFields } from '../utils/to-source-breakpoint.js';
import type { DapClientBehavior, DapClientContext, ReverseRequestResult } from './dap-client-behavior.js';

/**
Expand All @@ -23,6 +24,7 @@ export interface JsAdapterState extends AdapterSpecificState {

export const JsDebugAdapterPolicy: AdapterPolicy = {
name: 'js-debug',
supportsLogPoints: true,
supportsReverseStartDebugging: true,
childSessionStrategy: 'launchWithPendingTarget',
buildChildStartArgs: (pendingId: string, parentConfig: Record<string, unknown>) => {
Expand Down Expand Up @@ -279,13 +281,14 @@ export const JsDebugAdapterPolicy: AdapterPolicy = {
}

try {
// Group queued breakpoints by file
const grouped: Map<string, Array<{ line: number; condition?: string }>> = new Map();
// Group queued breakpoints by file, mapping via the shared
// toSourceBreakpoint so no per-breakpoint field is dropped (#235)
const grouped: Map<string, DebugProtocol.SourceBreakpoint[]> = new Map();
for (const bp of breakpoints.values()) {
// Type assertion for bp since it's 'unknown' in the interface
const breakpoint = bp as { file: string; line: number; condition?: string };
const breakpoint = bp as { file: string } & BreakpointFields;
const arr = grouped.get(breakpoint.file) || [];
arr.push({ line: breakpoint.line, condition: breakpoint.condition });
arr.push(toSourceBreakpoint(breakpoint));
grouped.set(breakpoint.file, arr);
}
for (const [file, bps] of grouped) {
Expand Down
3 changes: 2 additions & 1 deletion packages/shared/src/interfaces/adapter-policy-mock.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**
/**
* MockAdapterPolicy - policy for Mock Debug Adapter (testing)
*
* Encodes mock adapter behaviors for testing purposes.
Expand All @@ -10,6 +10,7 @@ import type { DapClientBehavior } from './dap-client-behavior.js';

export const MockAdapterPolicy: AdapterPolicy = {
name: 'mock',
supportsLogPoints: true,
supportsReverseStartDebugging: false,
childSessionStrategy: 'none',
buildChildStartArgs: () => {
Expand Down
5 changes: 3 additions & 2 deletions packages/shared/src/interfaces/adapter-policy-python.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**
/**
* PythonAdapterPolicy - policy for Python Debug Adapter (debugpy)
*
* Encodes debugpy specific behaviors and variable handling logic.
Expand All @@ -11,6 +11,7 @@ import type { DapClientBehavior, DapClientContext, ReverseRequestResult } from '

export const PythonAdapterPolicy: AdapterPolicy = {
name: 'python',
supportsLogPoints: true,
supportsReverseStartDebugging: false,
childSessionStrategy: 'none',
buildChildStartArgs: () => {
Expand Down Expand Up @@ -294,7 +295,7 @@ export const PythonAdapterPolicy: AdapterPolicy = {

// Attach: debugpy is already listening as a DAP server next to the target
// (python -m debugpy --listen host:port), so there is no adapter process
// to spawn connect directly.
// to spawn — connect directly.
if (launchConfig.request === 'attach') {
const connect = launchConfig.connect as { host?: string; port?: number } | undefined;
const host = connect?.host
Expand Down
3 changes: 2 additions & 1 deletion packages/shared/src/interfaces/adapter-policy-rust.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**
/**
* RustAdapterPolicy - policy for Rust Debug Adapter (CodeLLDB)
*
* Encodes CodeLLDB specific behaviors and variable handling logic.
Expand All @@ -12,6 +12,7 @@ import type { DapClientBehavior, DapClientContext, ReverseRequestResult } from '

export const RustAdapterPolicy: AdapterPolicy = {
name: 'rust',
supportsLogPoints: true,
supportsReverseStartDebugging: false,
childSessionStrategy: 'none',
buildChildStartArgs: () => {
Expand Down
Loading
Loading