Skip to content

feat: forward debuggee stdout/stderr to the MCP client (#218) - #224

Open
debugmcpdev wants to merge 1 commit into
mainfrom
fix/218-debuggee-output
Open

feat: forward debuggee stdout/stderr to the MCP client (#218)#224
debugmcpdev wants to merge 1 commit into
mainfrom
fix/218-debuggee-output

Conversation

@debugmcpdev

Copy link
Copy Markdown
Collaborator

Fixes #218.

Problem

A debugged program's stdout/stderr never reached the MCP client. DAP output events flowed intact from the adapter → minimal-dap → proxy worker (IPC dapEvent) → ProxyManager.handleDapEvent, which re-emitted them on the generic 'dap-event' channel — and died there: SessionManagerCore.setupProxyEventHandlers registers handlers for 8 events, none of them 'dap-event', so Node's EventEmitter silently discarded every output event. Python has emitted these events all along (redirectOutput: true); they were simply dropped on the last hop.

What this PR does

1. Typed output event (src/proxy/proxy-manager.ts, src/dap-core/handlers.ts)
output gets a first-class entry in ProxyManagerEvents and its own case in both switch statements (ProxyManager and the functional-core mirror), consistent with stopped/continued/terminated/exited. Other unhandled events still ride 'dap-event'.

2. Per-session capture (packages/shared, src/session/)

  • SessionOutputEntry model type (seq, category, output, timestamp, truncated?).
  • OutputRingBuffer (src/session/output-buffer.ts): monotonic seq from 1, cap 1000 entries with eviction + dropped count, 8 KB per-entry truncation, renderText() verbatim transcript.
  • SessionManagerCore (now an EventEmitter) registers a ninth proxy-event handler that pushes output events into session.outputBuffer — created fresh per launch/attach, alongside the lastStop reset — and emits 'output-captured'. telemetry (js-debug noise) is filtered at write time; a missing category defaults to console per DAP. The handler rides the existing handlers map, so teardown is automatic.

3. get_output tool (src/server.ts)
Cursor-based read: sessionId, optional since (pass nextSince from the previous response) and limit (default 100, max 1000). Returns { entries, nextSince, hasMore, dropped }. Deliberately does not validateSession() — reading output after the program finished is the primary use case; the buffer stays readable until close_debug_session. since/limit are registered in the SSE arg-coercion map (and sinceTimestamp, a pre-existing gap, is registered while touching that map).

4. Output resources + subscriptions (src/server.ts)
Each session exposes debug://sessions/{id}/output (text/plain, verbatim interleaved transcript). Server capabilities now declare resources: { subscribe: true, listChanged: true }; resources/list|read|subscribe|unsubscribe are implemented, list_changed fires on session create/close, and resources/updated pings are debounced (~150 ms per URI) so notification volume is independent of how fast the debuggee prints. Timers and the SessionManager listener are torn down in stop() (leak-guard safe). The dev proxy passes resource requests through and relays resource notifications (backend restarts drop subscriptions — documented inline).

Adapter audit (from #218's "worth an audit" note)

Adapter Debuggee output via DAP events Evidence
Python 235 recorded DAP traces; redirectOutput: true
JavaScript 206 traces (outputCapture: 'std'; telemetry filtered)
Java JdiDapServer.java pumps both streams
Go / .NET likely ✅ console: 'internalConsole' path, no traces in-repo
Rust sets console: but CodeLLDB wants terminal:#223
Ruby debuggee stdio goes to the rdbg adapter process → #222
Mock ❌ (by design) never emits output; unit tests drive the mock ProxyManager

Verification

  • Unit: 2529 pass, including new suites for the ring buffer, capture behavior (entry auto-continue window, telemetry filter, re-launch seq reset, post-termination semantics), get_output (incl. TERMINATED-session regression guard), and resources (debounce burst → single ping under fake timers, unsubscribe/stop cleanup).
  • E2E (mcp-server-smoke-python): the script's Factorial of 5 print arrives via get_output with category: stdout; cursor round-trip drains; the subscription produced resources/updated; resources/read returns the transcript. Comprehensive matrix gains a lenient per-language get_output row.
  • Full suite: 2670 passed; one unrelated JS-attach e2e flaked under full-suite load and passes in isolation.
  • Live dogfood through the dev proxy: markers on both streams captured with correct categories and cursor semantics (the exact repro from Debuggee stdout/stderr is never forwarded to the MCP client #218 / the Python interpreter/debugpy probe spawns lack windowsHide: a console window flashes on screen for every session (Windows) #215 comment).

🤖 Generated with Claude Code

DAP 'output' events flowed intact from the adapter through the proxy to
ProxyManager, which re-emitted them on the generic 'dap-event' channel —
where they died, because SessionManager never subscribed. A debugged
program's print() output was invisible to the MCP client.

- Promote 'output' to a first-class typed ProxyManager event (dap-core
  mirror switch updated to match); other unhandled events still ride
  'dap-event'.
- Capture output per session into a bounded OutputRingBuffer (1000
  entries, 8 KB per entry, telemetry filtered at write, fresh buffer per
  launch); SessionManager emits 'output-captured' for observers.
- New get_output tool: cursor-based (since/nextSince), works while
  running and after program exit until the session is closed.
- Expose each session's transcript as an MCP resource
  (debug://sessions/{id}/output) with resources/subscribe support;
  updated-pings are debounced (~150 ms) so notification volume is
  independent of debuggee output volume. Dev proxy passes resource
  requests and notifications through.
- Also register sinceTimestamp in TOOL_ARG_EXPECTED_TYPES (pre-existing
  gap in the SSE arg-coercion map).

Verified end-to-end: Python smoke e2e asserts the printed marker arrives
via get_output (category stdout), the cursor drains, the subscription
ping fires, and the resource read contains the transcript.

Follow-ups filed: #222 (Ruby routes debuggee stdio to the adapter
process), #223 (Rust console/terminal launch-config key).

Fixes #218

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.79832% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/dap-core/handlers.ts 0.00% 3 Missing ⚠️
src/server.ts 97.40% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Debuggee stdout/stderr is never forwarded to the MCP client

2 participants