diff --git a/CLAUDE.md b/CLAUDE.md index f5aa20e7..7284740e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -450,7 +450,7 @@ After adding the MCP server: - **Status Check**: After restart, type `/mcp` in Claude Code to see connected servers ### Available Tools After Integration -Once connected, the following 21 MCP tools become available: +Once connected, the following 22 MCP tools become available: - `create_debug_session` - Start a new debug session - `list_debug_sessions` - List active debug sessions - `list_supported_languages` - Show available language adapters @@ -469,8 +469,11 @@ Once connected, the following 21 MCP tools become available: - `get_scopes` - Get variable scopes for a stack frame - `evaluate_expression` - Evaluate expressions in debug context - `get_source_context` - Get source code around current position +- `get_output` - Read captured debuggee stdout/stderr (buffered per launch, cursor-based) - `redefine_classes` - Hot-swap changed Java classes into a running JVM (Java only) +Each session also exposes its captured output as an MCP resource (`debug://sessions/{id}/output`, plain-text transcript) with `resources/subscribe` support — subscribed clients receive coalesced `resources/updated` notifications as the debuggee prints. + **Dev proxy only** (these 3 tools are injected by the dev proxy process itself, not by the main mcp-debugger server): - `dev_restart_debugger` - Restart the backend (pass `rebuild: true` to build first) - `dev_rebuild_and_restart` - Run `npm run build` then restart the backend diff --git a/README.md b/README.md index dd99fb8e..54302be6 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ mcp-debugger exposes debugging operations as MCP tools that can be called with s | `pause_execution` | Pause running execution | ✅ Implemented | | `evaluate_expression` | Evaluate expressions in debug context | ✅ Implemented | | `get_source_context` | Get source code context | ✅ Implemented | +| `get_output` | Read captured debuggee output (stdout/stderr) | ✅ Implemented | | `close_debug_session` | Close a session | ✅ Implemented | | `redefine_classes` | Hot-swap changed Java classes into a running JVM (Java only) | ✅ Implemented | diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 0d940297..37979c71 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -24,6 +24,7 @@ This document provides a complete reference for all tools available in mcp-debug - [get_local_variables](#get_local_variables) - [evaluate_expression](#evaluate_expression) - [get_source_context](#get_source_context) + - [get_output](#get_output) --- @@ -694,6 +695,49 @@ Gets source code context around a specific line in a file. --- +### get_output + +Gets the debuggee's output (stdout/stderr/console) captured for a session. Output is delivered by the debug adapter as DAP `output` events and buffered per launch (issue #218). + +**Parameters:** +- `sessionId` (string, required): The ID of the debug session. +- `since` (number, optional): Sequence cursor — only entries with `seq` greater than this are returned. Pass `nextSince` from the previous response to fetch only new output. Default: `0` (start of the buffer). +- `limit` (number, optional): Maximum entries to return (default: 100, max: 1000). + +**Response:** +```json +{ + "success": true, + "sessionId": "a4d1acc8-84a8-44fe-a13e-28628c5b33c7", + "entries": [ + { "seq": 1, "category": "stdout", "output": "Factorial of 5: 120\n", "timestamp": 1754140800123 }, + { "seq": 2, "category": "stderr", "output": "warning: deprecated\n", "timestamp": 1754140800345 } + ], + "nextSince": 2, + "hasMore": false, + "dropped": 0 +} +``` + +**Notes:** +- The buffer holds the last 1000 entries per launch; older entries are evicted and counted in `dropped`. Individual entries longer than 8192 characters are cut and flagged `"truncated": true`. +- Adapter-internal `telemetry` events are filtered out at capture time; all other categories (`stdout`, `stderr`, `console`, `important`, ...) are kept. Adapters that omit a category default to `console`. +- Works while the program is running and after it finishes — output stays readable until `close_debug_session`. Re-launching a session starts a fresh buffer (seq restarts at 1). +- `hasMore: true` means more entries matched than `limit` allowed; call again with `since: nextSince`. +- Incremental polling recipe: call once, remember `nextSince`, and pass it as `since` on the next call — you'll only ever see new output. +- Adapter support: Python (`redirectOutput`), JavaScript (`outputCapture: 'std'`), and Java forward debuggee stdio as output events; Go and .NET typically do as well. Ruby currently routes debuggee stdio to the adapter process, so no entries are captured (tracked upstream). + +#### Output resources & subscriptions + +Each session also exposes its captured output as an MCP resource: + +- **URI:** `debug://sessions/{sessionId}/output` (`text/plain`) — the verbatim console transcript (all categories interleaved in arrival order). +- **`resources/list`** enumerates one output resource per session; the list changes on session create/close (`notifications/resources/list_changed`). +- **`resources/subscribe`** to a session's URI to receive `notifications/resources/updated` pings as output arrives. Pings are coalesced (~150 ms), so notification volume is independent of how fast the debuggee prints — on a ping, re-read the resource or call `get_output` with your cursor. +- Subscriptions are tracked per server instance and cleaned up when the session closes. + +--- + ## Additional Tools The following tools are also available but are not fully documented with examples here: diff --git a/docs/usage.md b/docs/usage.md index 5e2f3c72..fe6a7dd9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -381,9 +381,10 @@ You can also evaluate arbitrary expressions in the current debug context: ## Fully Implemented Features -All 21 tools are fully implemented, including: +All 22 tools are fully implemented, including: - **pause_execution**: Sends a DAP pause request and returns immediately; paused state is updated asynchronously. The session normally must be in the `running` state, but calling pause on an already paused session succeeds as a no-op. +- **get_output**: Returns the debuggee's stdout/stderr/console output, buffered per launch from DAP output events. Cursor-based (`since`/`nextSince`) for incremental polling; output stays readable after the program exits until the session is closed. The same data is exposed as a subscribable MCP resource (`debug://sessions/{id}/output`). - **evaluate_expression**: Evaluates arbitrary expressions in the current debug context. When `frameId` is not specified, the server infers it by fetching the stack trace and using the topmost frame -- this works reliably only when a single frame exists or the top frame is the desired context. Callers should provide `frameId` explicitly when debugging code with multiple stack frames. Expressions with side effects are allowed (can modify program state). ## Best Practices @@ -396,4 +397,4 @@ All 21 tools are fully implemented, including: --- -*Last updated: 2026-03-21 - All 21 tools including list_threads, pause_execution, and evaluate_expression are fully implemented (v0.23.0)* +*Last updated: 2026-08-02 - All 22 tools including get_output (debuggee output capture, issue #218) are fully implemented* diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index eddaf8ec..2d95373b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -138,6 +138,7 @@ export type { Breakpoint, DebugSession, DebugSessionInfo, + SessionOutputEntry, // Debug info types Variable, diff --git a/packages/shared/src/models/index.ts b/packages/shared/src/models/index.ts index e7ec99a4..ad343b64 100644 --- a/packages/shared/src/models/index.ts +++ b/packages/shared/src/models/index.ts @@ -266,6 +266,24 @@ export interface SessionStopInfo { timestamp: number; } +/** + * One captured debuggee output event (issue #218). + * Buffered per launch; exposed via the get_output tool and the + * debug://sessions/{id}/output resource. + */ +export interface SessionOutputEntry { + /** Monotonic per-launch sequence number, starting at 1 */ + seq: number; + /** DAP output category: 'stdout', 'stderr', 'console', 'important', ... ('console' when the adapter omits it) */ + category: string; + /** Output text as emitted by the adapter (chunking is adapter-defined; may end with a newline) */ + output: string; + /** Epoch milliseconds when the server received the event */ + timestamp: number; + /** Present and true when the entry exceeded the per-entry size cap and was cut */ + truncated?: boolean; +} + export interface DebugSessionInfo { id: string; language: DebugLanguage; diff --git a/src/dap-core/handlers.ts b/src/dap-core/handlers.ts index 12831eaf..2212d67e 100644 --- a/src/dap-core/handlers.ts +++ b/src/dap-core/handlers.ts @@ -214,7 +214,15 @@ function handleDapEvent( args: [] }); break; - + + case 'output': + commands.push({ + type: 'emitEvent', + event: 'output', + args: [message.body] + }); + break; + default: // Forward unknown events as generic DAP events commands.push({ diff --git a/src/proxy/proxy-manager.ts b/src/proxy/proxy-manager.ts index 116e45ff..3a666612 100644 --- a/src/proxy/proxy-manager.ts +++ b/src/proxy/proxy-manager.ts @@ -45,6 +45,7 @@ export interface ProxyManagerEvents { 'continued': () => void; 'terminated': () => void; 'exited': () => void; + 'output': (body: DebugProtocol.OutputEvent['body']) => void; // Proxy lifecycle events 'initialized': () => void; @@ -1000,7 +1001,11 @@ export class ProxyManager extends EventEmitter implements IProxyManager { case 'exited': this.emit('exited'); break; - + + case 'output': + this.emit('output', message.body as DebugProtocol.OutputEvent['body']); + break; + // Forward other events as generic DAP events default: this.emit('dap-event', message.event, message.body); diff --git a/src/server.ts b/src/server.ts index 6c16cd67..a78b7b9b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,10 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { ListToolsRequestSchema, CallToolRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, + SubscribeRequestSchema, + UnsubscribeRequestSchema, ErrorCode as McpErrorCode, McpError, ServerResult, @@ -104,6 +108,9 @@ interface ToolArguments { // redefine_classes parameters classesDir?: string; sinceTimestamp?: number; + // get_output parameters + since?: number; + limit?: number; } /** @@ -118,7 +125,8 @@ const TOOL_ARG_EXPECTED_TYPES: Record(); + private outputUpdateTimers = new Map(); + private handleOutputCaptured = (sessionId: string): void => { + this.scheduleOutputResourceUpdated(sessionId); + }; + // Get supported languages from adapter registry private async getSupportedLanguagesAsync(): Promise { const disabled = getDisabledLanguages(); @@ -491,7 +509,7 @@ export class DebugMcpServer { this.server = new Server( { name: 'debug-mcp-server', version: '0.1.0' }, - { capabilities: { tools: {} } } + { capabilities: { tools: {}, resources: { subscribe: true, listChanged: true } } } ); const sessionManagerConfig: SessionManagerConfig = { @@ -501,6 +519,8 @@ export class DebugMcpServer { this.sessionManager = new SessionManager(sessionManagerConfig, dependencies); this.registerTools(); + this.registerResources(); + this.sessionManager.on('output-captured', this.handleOutputCaptured); this.server.onerror = (error) => { this.logger.error('Server error', { error }); }; @@ -622,7 +642,7 @@ export class DebugMcpServer { { name: 'step_over', description: 'Step over the current line. Waits briefly for the program to stop; if the step is still executing after ~5s (e.g. stepping over a long-running call), returns success with state "running" and pending:true — the session becomes "paused" when the step completes (check list_debug_sessions, or call pause_execution to interrupt)', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } }, { name: 'step_into', description: 'Step into the current call. Waits briefly for the program to stop; if the step is still executing after ~5s, returns success with state "running" and pending:true — the session becomes "paused" when the step completes', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } }, { name: 'step_out', description: 'Step out of the current function. Waits briefly for the program to stop; if the step is still executing after ~5s (e.g. the rest of the function is long-running), returns success with state "running" and pending:true — the session becomes "paused" when the step completes', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } }, - { name: 'continue_execution', description: 'Continue execution. Returns immediately after the adapter acknowledges; does not wait for the next stop. When the program stops again the session state becomes "paused" — check list_debug_sessions or get_stack_trace, whose lastStop/stopReason tells you why it stopped (e.g. "breakpoint" vs "exception")', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } }, + { name: 'continue_execution', description: 'Continue execution. Returns immediately after the adapter acknowledges; does not wait for the next stop. When the program stops again the session state becomes "paused" — check list_debug_sessions or get_stack_trace, whose lastStop/stopReason tells you why it stopped (e.g. "breakpoint" vs "exception"). Use get_output to read the program\'s stdout/stderr', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } }, { name: 'pause_execution', description: 'Pause a running program. Waits briefly for the stop; if the program cannot stop within ~5s (e.g. blocked in native code), returns success with pending:true and the session reports "paused" once the stop lands', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, threadId: { type: 'number', description: 'Thread ID to pause. If omitted or 0, pauses all threads.' } }, required: ['sessionId'] } }, { name: 'list_threads', description: 'List all threads in the debugged process', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } }, { name: 'get_variables', description: 'Get variables (scope is variablesReference: number)', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, scope: { type: 'number', description: "The variablesReference number from a StackFrame or Variable" } }, required: ['sessionId', 'scope'] } }, @@ -631,6 +651,7 @@ export class DebugMcpServer { { name: 'get_scopes', description: 'Get scopes for a stack frame', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, frameId: { type: 'number', description: "The ID of the stack frame from a stackTrace response" } }, required: ['sessionId', 'frameId'] } }, { name: 'evaluate_expression', description: 'Evaluate expression in the current debug context. Expressions can read and modify program state. Waits up to 30s for the result by default; pass timeout for long-running expressions', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, expression: { type: 'string' }, frameId: { type: 'number', description: 'Optional stack frame ID for evaluation context. Must be a frame ID from a get_stack_trace response. If not provided, uses the current (top) frame automatically' }, timeout: { type: 'number', description: 'Max time (ms) to wait for the evaluation to complete (default: 30000, max: 600000). On expiry the request fails but the expression may keep executing in the debuggee. Note: your MCP client may enforce its own overall request timeout' } }, required: ['sessionId', 'expression'] } }, { name: 'get_source_context', description: 'Get source context around a specific line in a file', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, file: { type: 'string', description: fileDescription }, line: { type: 'number', description: 'Line number to get context for' }, linesContext: { type: 'number', description: 'Number of lines before and after to include (default: 5)' } }, required: ['sessionId', 'file', 'line'] } }, + { name: 'get_output', description: 'Get debuggee output (stdout/stderr/console) captured for a session. Buffered per launch (last 1000 entries; adapter telemetry filtered out). Works while the program is running and after it finishes, until the session is closed. Pass since=nextSince from the previous response to fetch only new output', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, since: { type: 'number', description: 'Only return entries with seq greater than this cursor (use nextSince from the previous response). Default: 0 = from the start of the buffer' }, limit: { type: 'number', description: 'Maximum entries to return (default: 100, max: 1000). hasMore:true in the response means more entries are available' } }, required: ['sessionId'] } }, { name: 'redefine_classes', description: 'Hot-swap changed Java classes into a running JVM. Scans a classes directory for .class files modified after sinceTimestamp, matches them against loaded classes in the target JVM, and redefines them using JDI. Returns which classes were redefined and the newest file timestamp (pass as sinceTimestamp on next call for incremental updates). Only works with Java debug sessions.', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, classesDir: { type: 'string', description: 'Absolute path to compiled classes directory (e.g. build/classes/java/main/)' }, sinceTimestamp: { type: 'number', description: 'Unix timestamp (ms). Only redefine .class files modified after this time. 0 or omitted = all files.' }, timeout: { type: 'number', description: 'Max time (ms) to wait for the redefinition to complete (default: 30000, max: 600000). Increase when hot-swapping many classes at once' } }, required: ['sessionId', 'classesDir'] } }, ], }; @@ -681,6 +702,9 @@ export class DebugMcpServer { timestamp: Date.now() }); + // A new output resource is now listable (issue #218) + this.notifyResourceListChanged(); + // Check if attach mode is requested (host/port provided) const isAttachMode = args.port !== undefined; @@ -984,6 +1008,12 @@ export class DebugMcpServer { sessionName: sessionName, timestamp: Date.now() }); + + // The session's output resource is gone (issue #218) + const outputUri = this.outputResourceUri(args.sessionId); + this.subscribedUris.delete(outputUri); + this.clearOutputUpdateTimer(outputUri); + this.notifyResourceListChanged(); } result = { content: [{ type: 'text', text: JSON.stringify({ success: closed, message: closed ? `Closed debug session: ${args.sessionId}` : `Failed to close debug session: ${args.sessionId}` }) }] }; @@ -1206,6 +1236,10 @@ export class DebugMcpServer { result = await this.handleGetLocalVariables(args as { sessionId: string; includeSpecial?: boolean }); break; } + case 'get_output': { + result = await this.handleGetOutput(args as { sessionId: string; since?: number; limit?: number }); + break; + } case 'list_supported_languages': { result = await this.handleListSupportedLanguages(); break; @@ -1255,6 +1289,109 @@ export class DebugMcpServer { ); } + // ===== Debuggee-output resources (issue #218) ===== + + private outputResourceUri(sessionId: string): string { + return `debug://sessions/${sessionId}/output`; + } + + /** Returns the sessionId encoded in a debug output resource URI, or undefined. */ + private parseOutputResourceUri(uri: string): string | undefined { + const match = /^debug:\/\/sessions\/([^/]+)\/output$/.exec(uri); + return match?.[1]; + } + + /** + * Registers MCP resource handlers. Each debug session exposes its captured + * debuggee output as debug://sessions/{id}/output — a verbatim console + * transcript (all categories interleaved, in arrival order). Clients may + * subscribe to receive coalesced resources/updated pings as output arrives; + * structured/cursor access is available via the get_output tool. + */ + private registerResources(): void { + this.server.setRequestHandler(ListResourcesRequestSchema, async () => { + const sessions = this.sessionManager.getAllSessions(); + return { + resources: sessions.map(session => ({ + uri: this.outputResourceUri(session.id), + name: `Debuggee output — ${session.name}`, + description: `stdout/stderr/console output captured for ${session.language} debug session '${session.name}'`, + mimeType: 'text/plain' + })) + }; + }); + + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const uri = request.params.uri; + const sessionId = this.parseOutputResourceUri(uri); + const session = sessionId ? this.sessionManager.getSession(sessionId) : undefined; + if (!session) { + throw new McpError(McpErrorCode.InvalidParams, `Unknown resource: ${uri}`); + } + return { + contents: [{ + uri, + mimeType: 'text/plain', + // Empty until the first launch creates the buffer + text: session.outputBuffer?.renderText() ?? '' + }] + }; + }); + + this.server.setRequestHandler(SubscribeRequestSchema, async (request) => { + const uri = request.params.uri; + const sessionId = this.parseOutputResourceUri(uri); + if (!sessionId || !this.sessionManager.getSession(sessionId)) { + throw new McpError(McpErrorCode.InvalidParams, `Unknown resource: ${uri}`); + } + this.subscribedUris.add(uri); + return {}; + }); + + this.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => { + const uri = request.params.uri; + this.subscribedUris.delete(uri); + this.clearOutputUpdateTimer(uri); + return {}; + }); + } + + /** + * Throttled resources/updated ping for a session's output resource: the + * first captured event after a quiet period arms a timer; everything that + * arrives inside the window rides the same ping. + */ + private scheduleOutputResourceUpdated(sessionId: string): void { + const uri = this.outputResourceUri(sessionId); + if (!this.subscribedUris.has(uri) || this.outputUpdateTimers.has(uri)) { + return; + } + const timer = setTimeout(() => { + this.outputUpdateTimers.delete(uri); + this.server.sendResourceUpdated({ uri }).catch((error: unknown) => { + // Not connected yet / transport gone — nothing to notify, not an error. + this.logger.debug(`[Server] Failed to send resources/updated for ${uri}`, { error }); + }); + }, DebugMcpServer.OUTPUT_UPDATE_DEBOUNCE_MS); + timer.unref?.(); + this.outputUpdateTimers.set(uri, timer); + } + + private clearOutputUpdateTimer(uri: string): void { + const timer = this.outputUpdateTimers.get(uri); + if (timer) { + clearTimeout(timer); + this.outputUpdateTimers.delete(uri); + } + } + + /** Fire-and-forget resources/list_changed (sessions appeared/disappeared). */ + private notifyResourceListChanged(): void { + this.server.sendResourceListChanged().catch((error: unknown) => { + this.logger.debug('[Server] Failed to send resources/list_changed', { error }); + }); + } + private async handleListDebugSessions(): Promise { try { const sessionsInfo: DebugSessionInfo[] = this.sessionManager.getAllSessions(); @@ -1281,6 +1418,29 @@ export class DebugMcpServer { } } + private async handleGetOutput(args: { sessionId: string; since?: number; limit?: number }): Promise { + // Deliberately no validateSession(): that rejects TERMINATED sessions, but + // reading output after the program finished is the primary use case. + // Output stays readable until close_debug_session removes the session. + const session = this.sessionManager.getSession(args.sessionId); + if (!session) { + return { content: [{ type: 'text', text: JSON.stringify({ success: false, error: `Session not found: ${args.sessionId}` }) }] }; + } + const since = Math.max(0, args.since ?? 0); + const limit = Math.min(Math.max(1, args.limit ?? 100), 1000); + const read = session.outputBuffer + ? session.outputBuffer.read(since, limit) + : { entries: [], nextSince: since, hasMore: false, dropped: 0 }; // session created but never launched + return { content: [{ type: 'text', text: JSON.stringify({ + success: true, + sessionId: args.sessionId, + entries: read.entries, + nextSince: read.nextSince, + hasMore: read.hasMore, + dropped: read.dropped + }) }] }; + } + private async handlePause(args: { sessionId: string; threadId?: number }): Promise { try { this.validateSession(args.sessionId); @@ -1592,6 +1752,14 @@ export class DebugMcpServer { public async stop(): Promise { await this.sessionManager.closeAllSessions(); + // Tear down output-resource bookkeeping (issue #218): pending debounce + // timers and the SessionManager listener must not outlive the server + // (the test suite runs with a strict leak guard). + for (const uri of this.outputUpdateTimers.keys()) { + this.clearOutputUpdateTimer(uri); + } + this.subscribedUris.clear(); + this.sessionManager.removeListener('output-captured', this.handleOutputCaptured); this.logger.info('Debug MCP Server stopped'); } diff --git a/src/session/output-buffer.ts b/src/session/output-buffer.ts new file mode 100644 index 00000000..7dfc6431 --- /dev/null +++ b/src/session/output-buffer.ts @@ -0,0 +1,68 @@ +/** + * Per-session ring buffer for debuggee output (issue #218). + * + * Holds the DAP 'output' events captured for one launch. Bounded in both + * entry count and per-entry size so a chatty debuggee cannot grow server + * memory without limit. + */ +import { SessionOutputEntry } from '@debugmcp/shared'; + +/** Maximum retained entries per launch; oldest are evicted beyond this. */ +export const OUTPUT_BUFFER_CAP = 1000; +/** Per-entry size cap; longer chunks are cut and flagged `truncated`. */ +export const MAX_OUTPUT_ENTRY_CHARS = 8192; + +export interface OutputReadResult { + entries: SessionOutputEntry[]; + /** Cursor for the next read: seq of the last returned entry, or the request's `since` when nothing matched */ + nextSince: number; + /** True when more entries matched than `limit` allowed */ + hasMore: boolean; + /** Total entries evicted from the buffer since launch */ + dropped: number; +} + +export class OutputRingBuffer { + private entries: SessionOutputEntry[] = []; + private nextSeq = 1; + private droppedCount = 0; + + constructor(private readonly cap: number = OUTPUT_BUFFER_CAP) {} + + push(category: string, output: string, timestamp: number = Date.now()): SessionOutputEntry { + const truncated = output.length > MAX_OUTPUT_ENTRY_CHARS; + const entry: SessionOutputEntry = { + seq: this.nextSeq++, + category, + output: truncated ? output.slice(0, MAX_OUTPUT_ENTRY_CHARS) : output, + timestamp, + ...(truncated ? { truncated: true } : {}) + }; + this.entries.push(entry); + if (this.entries.length > this.cap) { + this.entries.shift(); + this.droppedCount++; + } + return entry; + } + + read(since: number, limit: number): OutputReadResult { + const matched = this.entries.filter(e => e.seq > since); + const page = matched.slice(0, limit); + return { + entries: page, + nextSince: page.length > 0 ? page[page.length - 1].seq : since, + hasMore: matched.length > page.length, + dropped: this.droppedCount + }; + } + + /** + * Verbatim concatenation of every retained chunk, in arrival order — + * what a terminal running the program would have shown (all categories + * interleaved). Used by the debug://sessions/{id}/output resource. + */ + renderText(): string { + return this.entries.map(e => e.output).join(''); + } +} diff --git a/src/session/session-manager-core.ts b/src/session/session-manager-core.ts index 0dd0a5e2..c45be04b 100644 --- a/src/session/session-manager-core.ts +++ b/src/session/session-manager-core.ts @@ -2,11 +2,13 @@ * Core session management functionality including lifecycle, state management, * and event handling. */ +import { EventEmitter } from 'events'; import { SessionState, SessionLifecycleState, DebugLanguage, DebugSessionInfo, mapLegacyState, - AdapterPolicy + AdapterPolicy, SessionOutputEntry } from '@debugmcp/shared'; import { SessionStore, ManagedSession } from './session-store.js'; +import { OutputRingBuffer } from './output-buffer.js'; import { DebugProtocol } from '@vscode/debugprotocol'; import path from 'path'; import os from 'os'; @@ -61,9 +63,13 @@ export interface SessionManagerConfig { } /** - * Core session management functionality + * Core session management functionality. + * + * Emits: + * - 'output-captured' (sessionId: string, entry: SessionOutputEntry) — a debuggee + * output event was appended to the session's output buffer (issue #218). */ -export abstract class SessionManagerCore { +export abstract class SessionManagerCore extends EventEmitter { protected sessionStore: SessionStore; protected logDirBase: string; protected logger: ILogger; @@ -87,6 +93,7 @@ export abstract class SessionManagerCore { config: SessionManagerConfig, dependencies: SessionManagerDependencies ) { + super(); this.logger = dependencies.logger; this.fileSystem = dependencies.fileSystem; this.networkManager = dependencies.networkManager; @@ -217,6 +224,8 @@ export abstract class SessionManagerCore { // Reset first-stop tracking for this launch — a session may be re-launched. session.firstStopHandled = false; session.lastStop = undefined; + // Each launch/attach starts with a fresh output buffer (issue #218). + session.outputBuffer = new OutputRingBuffer(); // Adapters whose first stopped event after launch may not carry // reason='entry' (e.g., js-debug emits 'pause'/'breakpoint' from @@ -443,6 +452,29 @@ export abstract class SessionManagerCore { proxyManager.on('exit', handleExit); handlers.set('exit', handleExit); + // Named function for debuggee output events (issue #218). Captures every + // DAP 'output' event into the session's ring buffer so output stays + // queryable (get_output tool / output resource) while running and after + // the program exits, until the session is closed. + const handleOutput = (body: DebugProtocol.OutputEvent['body'] | undefined) => { + if (!body || typeof body.output !== 'string' || body.output.length === 0) { + return; + } + // DAP: category defaults to 'console' when omitted. 'telemetry' is + // adapter-internal noise (js-debug emits it constantly) — never debuggee + // output, so it is dropped at write time. + const category = body.category ?? 'console'; + if (category === 'telemetry') { + return; + } + const entry: SessionOutputEntry | undefined = session.outputBuffer?.push(category, body.output); + if (entry) { + this.emit('output-captured', sessionId, entry); + } + }; + proxyManager.on('output', handleOutput); + handlers.set('output', handleOutput); + // Store handlers in WeakMap this.sessionEventHandlers.set(session, handlers); this.logger.debug(`[SessionManager] Attached ${handlers.size} event handlers for session ${sessionId}`); diff --git a/src/session/session-store.ts b/src/session/session-store.ts index 8779ca67..6cf76b91 100644 --- a/src/session/session-store.ts +++ b/src/session/session-store.ts @@ -28,6 +28,7 @@ export interface CreateSessionParams { } import { IProxyManager } from '../proxy/proxy-manager.js'; +import { OutputRingBuffer } from './output-buffer.js'; export interface ToolchainValidationState { compatible: boolean; @@ -59,6 +60,9 @@ export interface ManagedSession extends DebugSessionInfo { // run on a remote filesystem (container, pod, other machine), so host-side // file existence checks do not apply to their source paths. attachMode?: boolean; + // Debuggee output captured from DAP 'output' events (issue #218). + // Created fresh on each launch/attach; readable until the session is closed. + outputBuffer?: OutputRingBuffer; } /** diff --git a/tests/core/unit/server/dynamic-tool-documentation.test.ts b/tests/core/unit/server/dynamic-tool-documentation.test.ts index dec03b6e..1a97d3c9 100644 --- a/tests/core/unit/server/dynamic-tool-documentation.test.ts +++ b/tests/core/unit/server/dynamic-tool-documentation.test.ts @@ -30,8 +30,10 @@ vi.mock('../../../../src/container/dependencies.js', () => ({ })) })); +// NOTE: implementation is passed directly to vi.fn() (not .mockImplementation) +// so the global afterEach vi.resetAllMocks() restores it instead of wiping it. vi.mock('../../../../src/session/session-manager.js', () => ({ - SessionManager: vi.fn().mockImplementation(function() { return ({ + SessionManager: vi.fn(function() { return ({ createSession: vi.fn(), closeSession: vi.fn(), closeAllSessions: vi.fn(), @@ -45,7 +47,9 @@ vi.mock('../../../../src/session/session-manager.js', () => ({ continue: vi.fn(), stepOver: vi.fn(), stepInto: vi.fn(), - stepOut: vi.fn() + stepOut: vi.fn(), + on: vi.fn(), + removeListener: vi.fn() }); }) })); diff --git a/tests/core/unit/server/server-initialization.test.ts b/tests/core/unit/server/server-initialization.test.ts index d2ac776e..ab74b9df 100644 --- a/tests/core/unit/server/server-initialization.test.ts +++ b/tests/core/unit/server/server-initialization.test.ts @@ -53,7 +53,7 @@ describe('Server Initialization Tests', () => { expect(Server).toHaveBeenCalledWith( { name: 'debug-mcp-server', version: '0.1.0' }, - { capabilities: { tools: {} } } + { capabilities: { tools: {}, resources: { subscribe: true, listChanged: true } } } ); expect(createProductionDependencies).toHaveBeenCalledWith({ @@ -88,11 +88,11 @@ describe('Server Initialization Tests', () => { expect(() => new DebugMcpServer()).toThrow('Failed to create dependencies'); }); - it('should register tool handlers', () => { + it('should register tool and resource handlers', () => { debugServer = new DebugMcpServer(); - - // Should register ListTools and CallTool handlers - expect(mockServer.setRequestHandler).toHaveBeenCalledTimes(2); + + // ListTools + CallTool, plus ListResources/ReadResource/Subscribe/Unsubscribe (issue #218) + expect(mockServer.setRequestHandler).toHaveBeenCalledTimes(6); }); it('should set error handler', () => { @@ -137,6 +137,7 @@ describe('Server Initialization Tests', () => { expect(toolNames).toContain('get_scopes'); expect(toolNames).toContain('evaluate_expression'); expect(toolNames).toContain('get_source_context'); + expect(toolNames).toContain('get_output'); }); it('should handle unknown tool error', async () => { diff --git a/tests/core/unit/server/server-inspection-tools.test.ts b/tests/core/unit/server/server-inspection-tools.test.ts index 3ec903d1..c900bfba 100644 --- a/tests/core/unit/server/server-inspection-tools.test.ts +++ b/tests/core/unit/server/server-inspection-tools.test.ts @@ -15,6 +15,7 @@ import { createMockStdioTransport, getToolHandlers } from './server-test-helpers.js'; +import { OutputRingBuffer } from '../../../../src/session/output-buffer.js'; // Mock dependencies vi.mock('@modelcontextprotocol/sdk/server/index.js'); @@ -340,4 +341,89 @@ describe('Server Inspection Tools Tests', () => { expect(content.error).toContain('Session not found: test-session'); }); }); + + describe('get_output', () => { + function makeBuffer(lines: string[]): OutputRingBuffer { + const buffer = new OutputRingBuffer(); + for (const line of lines) { + buffer.push('stdout', line); + } + return buffer; + } + + async function callGetOutput(args: Record) { + const result = await callToolHandler({ + method: 'tools/call', + params: { name: 'get_output', arguments: args } + }); + return JSON.parse(result.content[0].text); + } + + it('returns buffered entries with cursor metadata', async () => { + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + sessionLifecycle: 'ACTIVE', + outputBuffer: makeBuffer(['one\n', 'two\n']) + }); + + const content = await callGetOutput({ sessionId: 'test-session' }); + expect(content.success).toBe(true); + expect(content.entries.map((e: { output: string }) => e.output)).toEqual(['one\n', 'two\n']); + expect(content.nextSince).toBe(2); + expect(content.hasMore).toBe(false); + expect(content.dropped).toBe(0); + }); + + it('works on TERMINATED sessions (post-exit output is the primary use case)', async () => { + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + sessionLifecycle: 'TERMINATED', + outputBuffer: makeBuffer(['final result\n']) + }); + + const content = await callGetOutput({ sessionId: 'test-session' }); + expect(content.success).toBe(true); + expect(content.entries).toHaveLength(1); + }); + + it('returns success:false for an unknown session', async () => { + mockSessionManager.getSession.mockReturnValue(undefined); + + const content = await callGetOutput({ sessionId: 'nope' }); + expect(content.success).toBe(false); + expect(content.error).toContain('Session not found: nope'); + }); + + it('honours the since cursor and clamps limit', async () => { + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + sessionLifecycle: 'ACTIVE', + outputBuffer: makeBuffer(['a\n', 'b\n', 'c\n']) + }); + + const incremental = await callGetOutput({ sessionId: 'test-session', since: 2 }); + expect(incremental.entries.map((e: { output: string }) => e.output)).toEqual(['c\n']); + expect(incremental.nextSince).toBe(3); + + // limit below 1 clamps to 1; negative since clamps to 0 + const clamped = await callGetOutput({ sessionId: 'test-session', since: -5, limit: 0 }); + expect(clamped.entries).toHaveLength(1); + expect(clamped.entries[0].output).toBe('a\n'); + expect(clamped.hasMore).toBe(true); + }); + + it('returns an empty success for sessions that never launched (no buffer)', async () => { + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + sessionLifecycle: 'CREATED' + }); + + const content = await callGetOutput({ sessionId: 'test-session', since: 7 }); + expect(content.success).toBe(true); + expect(content.entries).toEqual([]); + expect(content.nextSince).toBe(7); + expect(content.hasMore).toBe(false); + expect(content.dropped).toBe(0); + }); + }); }); diff --git a/tests/core/unit/server/server-resources.test.ts b/tests/core/unit/server/server-resources.test.ts new file mode 100644 index 00000000..8920c366 --- /dev/null +++ b/tests/core/unit/server/server-resources.test.ts @@ -0,0 +1,210 @@ +/** + * Debuggee-output resource tests (issue #218): + * resources/list, resources/read, subscribe/unsubscribe bookkeeping, + * and debounced resources/updated pings driven by 'output-captured'. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { McpError } from '@modelcontextprotocol/sdk/types.js'; +import { DebugMcpServer } from '../../../../src/server.js'; +import { SessionManager } from '../../../../src/session/session-manager.js'; +import { createProductionDependencies } from '../../../../src/container/dependencies.js'; +import { OutputRingBuffer } from '../../../../src/session/output-buffer.js'; +import { + createMockDependencies, + createMockServer, + createMockSessionManager, + createMockStdioTransport, + getResourceHandlers +} from './server-test-helpers.js'; + +vi.mock('@modelcontextprotocol/sdk/server/index.js'); +vi.mock('@modelcontextprotocol/sdk/server/stdio.js'); +vi.mock('../../../../src/session/session-manager.js'); +vi.mock('../../../../src/container/dependencies.js'); + +describe('Server Output Resources Tests', () => { + let debugServer: DebugMcpServer; + let mockServer: any; + let mockSessionManager: any; + let mockDependencies: any; + let outputCapturedListener: ((sessionId: string, entry: unknown) => void) | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + + mockDependencies = createMockDependencies(); + vi.mocked(createProductionDependencies).mockReturnValue(mockDependencies); + + mockServer = createMockServer(); + vi.mocked(Server).mockImplementation(function() { return mockServer as any; }); + + const mockStdioTransport = createMockStdioTransport(); + vi.mocked(StdioServerTransport).mockImplementation(function() { return mockStdioTransport as any; }); + + mockSessionManager = createMockSessionManager(mockDependencies.adapterRegistry); + // Capture the server's 'output-captured' subscription so tests can drive it + mockSessionManager.on.mockImplementation((event: string, listener: (...args: any[]) => void) => { + if (event === 'output-captured') { + outputCapturedListener = listener; + } + }); + vi.mocked(SessionManager).mockImplementation(function() { return mockSessionManager as any; }); + + debugServer = new DebugMcpServer(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.clearAllMocks(); + outputCapturedListener = undefined; + }); + + function mockSession(overrides: Record = {}) { + const buffer = new OutputRingBuffer(); + return { + id: 'sess-1', + name: 'test session', + language: 'python', + outputBuffer: buffer, + ...overrides + }; + } + + async function subscribe(uri: string) { + const { subscribeHandler } = getResourceHandlers(mockServer); + return subscribeHandler({ method: 'resources/subscribe', params: { uri } }); + } + + describe('resources/list', () => { + it('lists one output resource per session', async () => { + mockSessionManager.getAllSessions.mockReturnValue([ + { id: 'sess-1', name: 'alpha', language: 'python' }, + { id: 'sess-2', name: 'beta', language: 'mock' } + ]); + + const { listResourcesHandler } = getResourceHandlers(mockServer); + const result = await listResourcesHandler({ method: 'resources/list', params: {} }); + + expect(result.resources).toHaveLength(2); + expect(result.resources[0]).toMatchObject({ + uri: 'debug://sessions/sess-1/output', + mimeType: 'text/plain' + }); + expect(result.resources[0].name).toContain('alpha'); + }); + }); + + describe('resources/read', () => { + it('returns the verbatim transcript', async () => { + const session = mockSession(); + (session.outputBuffer as OutputRingBuffer).push('stdout', 'hello\n'); + (session.outputBuffer as OutputRingBuffer).push('stderr', 'oops\n'); + mockSessionManager.getSession.mockReturnValue(session); + + const { readResourceHandler } = getResourceHandlers(mockServer); + const result = await readResourceHandler({ + method: 'resources/read', + params: { uri: 'debug://sessions/sess-1/output' } + }); + + expect(result.contents).toEqual([{ + uri: 'debug://sessions/sess-1/output', + mimeType: 'text/plain', + text: 'hello\noops\n' + }]); + }); + + it('returns empty text for a session that never launched', async () => { + mockSessionManager.getSession.mockReturnValue(mockSession({ outputBuffer: undefined })); + + const { readResourceHandler } = getResourceHandlers(mockServer); + const result = await readResourceHandler({ + method: 'resources/read', + params: { uri: 'debug://sessions/sess-1/output' } + }); + + expect(result.contents[0].text).toBe(''); + }); + + it('rejects unknown URIs and unknown sessions', async () => { + mockSessionManager.getSession.mockReturnValue(undefined); + const { readResourceHandler } = getResourceHandlers(mockServer); + + await expect(readResourceHandler({ + method: 'resources/read', + params: { uri: 'debug://sessions/ghost/output' } + })).rejects.toBeInstanceOf(McpError); + + await expect(readResourceHandler({ + method: 'resources/read', + params: { uri: 'file:///etc/passwd' } + })).rejects.toBeInstanceOf(McpError); + }); + }); + + describe('subscriptions and updated pings', () => { + it('debounces a burst of output into a single resources/updated ping', async () => { + mockSessionManager.getSession.mockReturnValue(mockSession()); + await subscribe('debug://sessions/sess-1/output'); + expect(outputCapturedListener).toBeDefined(); + + for (let i = 0; i < 50; i++) { + outputCapturedListener!('sess-1', { seq: i + 1, category: 'stdout', output: `${i}\n`, timestamp: 1 }); + } + + expect(mockServer.sendResourceUpdated).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(200); + + expect(mockServer.sendResourceUpdated).toHaveBeenCalledTimes(1); + expect(mockServer.sendResourceUpdated).toHaveBeenCalledWith({ uri: 'debug://sessions/sess-1/output' }); + + // A later burst gets its own ping + outputCapturedListener!('sess-1', { seq: 51, category: 'stdout', output: 'more\n', timestamp: 2 }); + await vi.advanceTimersByTimeAsync(200); + expect(mockServer.sendResourceUpdated).toHaveBeenCalledTimes(2); + }); + + it('does not ping for unsubscribed sessions', async () => { + expect(outputCapturedListener).toBeDefined(); + outputCapturedListener!('sess-1', { seq: 1, category: 'stdout', output: 'x\n', timestamp: 1 }); + await vi.advanceTimersByTimeAsync(500); + + expect(mockServer.sendResourceUpdated).not.toHaveBeenCalled(); + }); + + it('rejects subscribing to an unknown session', async () => { + mockSessionManager.getSession.mockReturnValue(undefined); + await expect(subscribe('debug://sessions/ghost/output')).rejects.toBeInstanceOf(McpError); + }); + + it('stops pinging after unsubscribe, cancelling any pending timer', async () => { + mockSessionManager.getSession.mockReturnValue(mockSession()); + await subscribe('debug://sessions/sess-1/output'); + + outputCapturedListener!('sess-1', { seq: 1, category: 'stdout', output: 'x\n', timestamp: 1 }); + + const { unsubscribeHandler } = getResourceHandlers(mockServer); + await unsubscribeHandler({ method: 'resources/unsubscribe', params: { uri: 'debug://sessions/sess-1/output' } }); + + await vi.advanceTimersByTimeAsync(500); + expect(mockServer.sendResourceUpdated).not.toHaveBeenCalled(); + }); + + it('cleans up pending timers and the session-manager listener on stop()', async () => { + mockSessionManager.getSession.mockReturnValue(mockSession()); + mockSessionManager.closeAllSessions.mockResolvedValue(undefined); + await subscribe('debug://sessions/sess-1/output'); + + outputCapturedListener!('sess-1', { seq: 1, category: 'stdout', output: 'x\n', timestamp: 1 }); + + await debugServer.stop(); + expect(mockSessionManager.removeListener).toHaveBeenCalledWith('output-captured', expect.any(Function)); + + await vi.advanceTimersByTimeAsync(500); + expect(mockServer.sendResourceUpdated).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/core/unit/server/server-test-helpers.ts b/tests/core/unit/server/server-test-helpers.ts index 5e51f182..0afec5f9 100644 --- a/tests/core/unit/server/server-test-helpers.ts +++ b/tests/core/unit/server/server-test-helpers.ts @@ -75,6 +75,8 @@ export function createMockServer() { setRequestHandler: vi.fn(), connect: vi.fn(), close: vi.fn(), + sendResourceUpdated: vi.fn().mockResolvedValue(undefined), + sendResourceListChanged: vi.fn().mockResolvedValue(undefined), onerror: undefined as any }; } @@ -103,7 +105,10 @@ export function createMockSessionManager(mockAdapterRegistry: any) { attachToProcess: vi.fn(), redefineClasses: vi.fn(), getAdapterRegistry: vi.fn().mockReturnValue(mockAdapterRegistry), - adapterRegistry: mockAdapterRegistry + adapterRegistry: mockAdapterRegistry, + // EventEmitter surface used by DebugMcpServer for output-captured (issue #218) + on: vi.fn(), + removeListener: vi.fn() }; } @@ -118,3 +123,15 @@ export function getToolHandlers(mockServer: any) { callToolHandler: handlers[1]?.[1] // Second handler is for CallToolRequestSchema }; } + +// Resource handlers are registered by registerResources() right after the two +// tool handlers, in this order (issue #218). +export function getResourceHandlers(mockServer: any) { + const handlers = mockServer.setRequestHandler.mock.calls; + return { + listResourcesHandler: handlers[2]?.[1], + readResourceHandler: handlers[3]?.[1], + subscribeHandler: handlers[4]?.[1], + unsubscribeHandler: handlers[5]?.[1] + }; +} diff --git a/tests/core/unit/session/output-buffer.test.ts b/tests/core/unit/session/output-buffer.test.ts new file mode 100644 index 00000000..3d29a426 --- /dev/null +++ b/tests/core/unit/session/output-buffer.test.ts @@ -0,0 +1,99 @@ +/** + * Unit tests for OutputRingBuffer (issue #218) + */ +import { describe, it, expect } from 'vitest'; +import { OutputRingBuffer, OUTPUT_BUFFER_CAP, MAX_OUTPUT_ENTRY_CHARS } from '../../../../src/session/output-buffer.js'; + +describe('OutputRingBuffer', () => { + it('assigns monotonic seq starting at 1 and preserves order', () => { + const buffer = new OutputRingBuffer(); + buffer.push('stdout', 'one\n', 1000); + buffer.push('stderr', 'two\n', 2000); + buffer.push('console', 'three\n', 3000); + + const { entries, nextSince, hasMore, dropped } = buffer.read(0, 100); + expect(entries.map(e => e.seq)).toEqual([1, 2, 3]); + expect(entries.map(e => e.category)).toEqual(['stdout', 'stderr', 'console']); + expect(entries.map(e => e.output)).toEqual(['one\n', 'two\n', 'three\n']); + expect(entries.map(e => e.timestamp)).toEqual([1000, 2000, 3000]); + expect(nextSince).toBe(3); + expect(hasMore).toBe(false); + expect(dropped).toBe(0); + }); + + it('evicts oldest entries past the cap, counting drops and keeping seq continuity', () => { + const buffer = new OutputRingBuffer(5); + for (let i = 0; i < 8; i++) { + buffer.push('stdout', `line ${i}\n`); + } + + const { entries, dropped } = buffer.read(0, 100); + expect(entries).toHaveLength(5); + expect(dropped).toBe(3); + // Oldest 3 evicted: retained seqs are 4..8 + expect(entries.map(e => e.seq)).toEqual([4, 5, 6, 7, 8]); + expect(entries[0].output).toBe('line 3\n'); + }); + + it('truncates oversized entries and flags them', () => { + const buffer = new OutputRingBuffer(); + buffer.push('stdout', 'x'.repeat(MAX_OUTPUT_ENTRY_CHARS + 100)); + buffer.push('stdout', 'small'); + + const { entries } = buffer.read(0, 100); + expect(entries[0].output).toHaveLength(MAX_OUTPUT_ENTRY_CHARS); + expect(entries[0].truncated).toBe(true); + expect(entries[1].truncated).toBeUndefined(); + }); + + it('filters by since and pages by limit with hasMore/nextSince', () => { + const buffer = new OutputRingBuffer(); + for (let i = 0; i < 10; i++) { + buffer.push('stdout', `line ${i}\n`); + } + + const page1 = buffer.read(0, 4); + expect(page1.entries.map(e => e.seq)).toEqual([1, 2, 3, 4]); + expect(page1.hasMore).toBe(true); + expect(page1.nextSince).toBe(4); + + const page2 = buffer.read(page1.nextSince, 4); + expect(page2.entries.map(e => e.seq)).toEqual([5, 6, 7, 8]); + expect(page2.hasMore).toBe(true); + + const page3 = buffer.read(page2.nextSince, 4); + expect(page3.entries.map(e => e.seq)).toEqual([9, 10]); + expect(page3.hasMore).toBe(false); + expect(page3.nextSince).toBe(10); + }); + + it('echoes since as nextSince when no new entries exist', () => { + const buffer = new OutputRingBuffer(); + buffer.push('stdout', 'only\n'); + + const drained = buffer.read(1, 100); + expect(drained.entries).toEqual([]); + expect(drained.nextSince).toBe(1); + expect(drained.hasMore).toBe(false); + }); + + it('renders a verbatim interleaved transcript', () => { + const buffer = new OutputRingBuffer(); + buffer.push('stdout', 'out 1\n'); + buffer.push('stderr', 'err 1\n'); + buffer.push('stdout', 'partial'); + buffer.push('stdout', ' line\n'); + + expect(buffer.renderText()).toBe('out 1\nerr 1\npartial line\n'); + }); + + it('uses the default cap of 1000', () => { + const buffer = new OutputRingBuffer(); + for (let i = 0; i < OUTPUT_BUFFER_CAP + 10; i++) { + buffer.push('stdout', `${i}\n`); + } + const { entries, dropped } = buffer.read(0, OUTPUT_BUFFER_CAP + 10); + expect(entries).toHaveLength(OUTPUT_BUFFER_CAP); + expect(dropped).toBe(10); + }); +}); diff --git a/tests/core/unit/session/session-manager-integration.test.ts b/tests/core/unit/session/session-manager-integration.test.ts index 6b749aad..2daa9894 100644 --- a/tests/core/unit/session/session-manager-integration.test.ts +++ b/tests/core/unit/session/session-manager-integration.test.ts @@ -140,6 +140,119 @@ describe('SessionManager - Integration Tests', () => { }); }); + describe('Debuggee output capture (issue #218)', () => { + async function startSession(dapLaunchArgs?: { stopOnEntry?: boolean }) { + const session = await sessionManager.createSession({ + language: DebugLanguage.MOCK, + executablePath: 'python' + }); + await sessionManager.startDebugging(session.id, 'test.py', [], dapLaunchArgs); + await vi.runAllTimersAsync(); + return session; + } + + it('captures stdout and stderr output events with increasing seq', async () => { + const session = await startSession(); + + dependencies.mockProxyManager.simulateEvent('output', { category: 'stdout', output: 'hello\n' }); + dependencies.mockProxyManager.simulateEvent('output', { category: 'stderr', output: 'oops\n' }); + + const read = sessionManager.getSession(session.id)?.outputBuffer?.read(0, 100); + expect(read?.entries).toHaveLength(2); + expect(read?.entries[0]).toMatchObject({ seq: 1, category: 'stdout', output: 'hello\n' }); + expect(read?.entries[1]).toMatchObject({ seq: 2, category: 'stderr', output: 'oops\n' }); + expect(typeof read?.entries[0].timestamp).toBe('number'); + }); + + it('defaults a missing category to console', async () => { + const session = await startSession(); + + dependencies.mockProxyManager.simulateEvent('output', { output: 'no category\n' } as never); + + const read = sessionManager.getSession(session.id)?.outputBuffer?.read(0, 100); + expect(read?.entries[0]).toMatchObject({ category: 'console', output: 'no category\n' }); + }); + + it('filters telemetry events at write time', async () => { + const session = await startSession(); + + dependencies.mockProxyManager.simulateEvent('output', { category: 'telemetry', output: '{"event":"x"}' }); + dependencies.mockProxyManager.simulateEvent('output', { category: 'stdout', output: 'real\n' }); + + const read = sessionManager.getSession(session.id)?.outputBuffer?.read(0, 100); + expect(read?.entries).toHaveLength(1); + expect(read?.entries[0].output).toBe('real\n'); + }); + + it('ignores malformed output bodies', async () => { + const session = await startSession(); + + dependencies.mockProxyManager.simulateEvent('output', {} as never); + dependencies.mockProxyManager.simulateEvent('output', { category: 'stdout', output: '' }); + dependencies.mockProxyManager.simulateEvent('output', undefined as never); + + const read = sessionManager.getSession(session.id)?.outputBuffer?.read(0, 100); + expect(read?.entries).toHaveLength(0); + }); + + it('captures output emitted before the first stop (entry auto-continue window)', async () => { + const session = await startSession({ stopOnEntry: false }); + + dependencies.mockProxyManager.simulateEvent('output', { category: 'stdout', output: 'early\n' }); + dependencies.mockProxyManager.simulateEvent('stopped', 1, 'entry'); + + const read = sessionManager.getSession(session.id)?.outputBuffer?.read(0, 100); + expect(read?.entries.map(e => e.output)).toEqual(['early\n']); + }); + + it('keeps output readable after termination but stops capturing (handlers removed)', async () => { + const session = await startSession(); + + dependencies.mockProxyManager.simulateEvent('output', { category: 'stdout', output: 'before exit\n' }); + dependencies.mockProxyManager.simulateEvent('terminated'); + dependencies.mockProxyManager.simulateEvent('output', { category: 'stdout', output: 'after exit\n' }); + + const read = sessionManager.getSession(session.id)?.outputBuffer?.read(0, 100); + expect(read?.entries.map(e => e.output)).toEqual(['before exit\n']); + }); + + it('starts a fresh buffer with restarted seq on re-launch', async () => { + const session = await startSession(); + + dependencies.mockProxyManager.simulateEvent('output', { category: 'stdout', output: 'first launch\n' }); + dependencies.mockProxyManager.simulateEvent('terminated'); + // Let the first launch's teardown (proxy exit) finish before re-launching — + // the shared mock ProxyManager would otherwise tear down the new handlers. + await vi.runAllTimersAsync(); + + await sessionManager.startDebugging(session.id, 'test.py'); + await vi.runAllTimersAsync(); + + const empty = sessionManager.getSession(session.id)?.outputBuffer?.read(0, 100); + expect(empty?.entries).toHaveLength(0); + + dependencies.mockProxyManager.simulateEvent('output', { category: 'stdout', output: 'second launch\n' }); + const read = sessionManager.getSession(session.id)?.outputBuffer?.read(0, 100); + expect(read?.entries).toHaveLength(1); + expect(read?.entries[0].seq).toBe(1); + }); + + it("emits 'output-captured' with the session id and entry", async () => { + const captured: Array<{ sessionId: string; entry: unknown }> = []; + sessionManager.on('output-captured', (sessionId: string, entry: unknown) => { + captured.push({ sessionId, entry }); + }); + + const session = await startSession(); + dependencies.mockProxyManager.simulateEvent('output', { category: 'stdout', output: 'ping\n' }); + dependencies.mockProxyManager.simulateEvent('output', { category: 'telemetry', output: 'noise' }); + + expect(captured).toHaveLength(1); + expect(captured[0].sessionId).toBe(session.id); + expect(captured[0].entry).toMatchObject({ seq: 1, category: 'stdout', output: 'ping\n' }); + }); + }); + describe('Logger Integration', () => { it('should log all major operations', async () => { const session = await sessionManager.createSession({ diff --git a/tests/e2e/comprehensive-mcp-tools.test.ts b/tests/e2e/comprehensive-mcp-tools.test.ts index f6c0b4eb..e35ab78a 100644 --- a/tests/e2e/comprehensive-mcp-tools.test.ts +++ b/tests/e2e/comprehensive-mcp-tools.test.ts @@ -1,5 +1,5 @@ /** - * Comprehensive MCP Debugger Test - All 20 Tools x All Languages + * Comprehensive MCP Debugger Test - All 21 Tools x All Languages * * Broad coverage of MCP tools across available language adapters. * Produces a detailed matrix report (PASS/FAIL/SKIP per tool per language). @@ -145,7 +145,7 @@ const LANGUAGES: LangDef[] = [ dapLaunchArgs: { mainClass: 'HelloWorld', classpath: JAVA_CLASS_DIR, cwd: JAVA_CLASS_DIR } }, ]; -/* ---------- all 20 tools ---------- */ +/* ---------- all 21 tools ---------- */ const ALL_TOOLS = [ 'list_supported_languages', @@ -165,6 +165,7 @@ const ALL_TOOLS = [ 'continue_execution', 'pause_execution', 'list_threads', + 'get_output', 'attach_to_process', 'detach_from_process', 'close_debug_session', @@ -172,7 +173,7 @@ const ALL_TOOLS = [ /* ---------- test suite ---------- */ -describe(`Comprehensive MCP Debugger Test — 20 Tools × ${LANGUAGES.length} Languages`, () => { +describe(`Comprehensive MCP Debugger Test — 21 Tools × ${LANGUAGES.length} Languages`, () => { let mcpClient: Client | null = null; let transport: StdioClientTransport | null = null; @@ -590,7 +591,24 @@ describe(`Comprehensive MCP Debugger Test — 20 Tools × ${LANGUAGES.length} La record('continue_execution', lang.language, 'FAIL', err.message, Date.now() - t0); } - /* ---- Tool 20: close_debug_session ---- */ + /* ---- Tool 16: get_output (issue #218) ---- */ + // Lenient: entries may legitimately be empty (Ruby routes debuggee + // stdio to the adapter process; the adapter may emit no DAP output + // events) — only the tool contract is asserted per-language. + t0 = Date.now(); + try { + const outRes = await callToolSafely(mcpClient!, 'get_output', { sessionId: currentSessionId }); + if (outRes.success === true) { + const count = Array.isArray(outRes.entries) ? outRes.entries.length : 0; + record('get_output', lang.language, 'PASS', `entries=${count}`, Date.now() - t0); + } else { + record('get_output', lang.language, 'FAIL', `success=${outRes.success}: ${outRes.error ?? outRes.message ?? ''}`, Date.now() - t0); + } + } catch (err: any) { + record('get_output', lang.language, 'FAIL', err.message, Date.now() - t0); + } + + /* ---- Tool 21: close_debug_session ---- */ t0 = Date.now(); try { const closeRes = await callToolSafely(mcpClient!, 'close_debug_session', { sessionId: currentSessionId }); @@ -645,8 +663,9 @@ describe(`Comprehensive MCP Debugger Test — 20 Tools × ${LANGUAGES.length} La await new Promise(r => setTimeout(r, 2000)); - // Try inspection tools on mock - for (const tool of ['get_stack_trace', 'get_local_variables', 'step_over', 'continue_execution'] as const) { + // Try inspection tools on mock (get_output: mock emits no DAP output + // events, so this asserts the empty-success contract) + for (const tool of ['get_stack_trace', 'get_local_variables', 'step_over', 'continue_execution', 'get_output'] as const) { t0 = Date.now(); try { const args: Record = { sessionId: currentSessionId }; diff --git a/tests/e2e/mcp-server-smoke-python.test.ts b/tests/e2e/mcp-server-smoke-python.test.ts index 5db06367..8de53509 100644 --- a/tests/e2e/mcp-server-smoke-python.test.ts +++ b/tests/e2e/mcp-server-smoke-python.test.ts @@ -15,6 +15,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { ResourceUpdatedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; import { parseSdkToolResult, callToolSafely } from './smoke-test-utils.js'; const __filename = fileURLToPath(import.meta.url); @@ -103,6 +104,16 @@ describe('MCP Server Python Debugging Smoke Test', () => { sessionId = createResponse.sessionId as string; console.log(`[Python Smoke Test] Session created: ${sessionId}`); + // 1.5 Subscribe to the session's output resource (issue #218) so we can + // assert that resources/updated pings arrive as the debuggee prints. + const outputResourceUri = `debug://sessions/${sessionId}/output`; + const updatedUris: string[] = []; + mcpClient!.setNotificationHandler(ResourceUpdatedNotificationSchema, (notification) => { + updatedUris.push(notification.params.uri); + }); + await mcpClient!.subscribeResource({ uri: outputResourceUri }); + console.log('[Python Smoke Test] Subscribed to output resource'); + // 2. Set breakpoint (initially returns verified: false; verified on launch) console.log('[Python Smoke Test] Setting breakpoint at line 32...'); const bpResult = await mcpClient!.callTool({ @@ -229,10 +240,37 @@ describe('MCP Server Python Debugging Smoke Test', () => { // 7. Continue execution console.log('[Python Smoke Test] Continuing execution...'); const continueResult = await callToolSafely(mcpClient!, 'continue_execution', { sessionId }); - + // Wait for script to complete await new Promise(resolve => setTimeout(resolve, 3000)); + // 7.5 Debuggee output must be retrievable (issue #218) — the script's + // print() output flows back as DAP output events into the session buffer. + console.log('[Python Smoke Test] Fetching debuggee output...'); + const outputResult = await callToolSafely(mcpClient!, 'get_output', { sessionId }); + expect(outputResult.success).toBe(true); + const outputEntries = outputResult.entries as Array<{ seq: number; category: string; output: string }>; + console.log(`[Python Smoke Test] Captured ${outputEntries.length} output entries`); + const factorialEntry = outputEntries.find(e => e.output.includes('Factorial of 5')); + expect(factorialEntry).toBeDefined(); + expect(factorialEntry!.category).toBe('stdout'); + + // Cursor round-trip: draining from nextSince returns nothing new + const drained = await callToolSafely(mcpClient!, 'get_output', { + sessionId, + since: outputResult.nextSince as number + }); + expect(drained.success).toBe(true); + expect(drained.entries).toEqual([]); + + // Resource flow (issue #218): the subscription produced updated-pings and + // the resource read returns the plain-text transcript. + expect(updatedUris).toContain(outputResourceUri); + const resource = await mcpClient!.readResource({ uri: outputResourceUri }); + const transcript = (resource.contents[0] as { text?: string }).text ?? ''; + expect(transcript).toContain('Factorial of 5'); + console.log('[Python Smoke Test] Output resource verified'); + // 8. Close session console.log('[Python Smoke Test] Closing session...'); const closeResult = await callToolSafely(mcpClient!, 'close_debug_session', { sessionId }); diff --git a/tests/unit/proxy/proxy-manager.branch-coverage.test.ts b/tests/unit/proxy/proxy-manager.branch-coverage.test.ts index 1f2e26c6..3ed7dd8f 100644 --- a/tests/unit/proxy/proxy-manager.branch-coverage.test.ts +++ b/tests/unit/proxy/proxy-manager.branch-coverage.test.ts @@ -200,10 +200,12 @@ describe('ProxyManager branch coverage scenarios', () => { expect((manager as unknown as { currentThreadId: number | null }).currentThreadId).toBe(42); }); - it('emits continued and default dap events', () => { + it('emits continued, typed output, and default dap events', () => { const continuedListener = vi.fn(); + const outputListener = vi.fn(); const defaultListener = vi.fn(); manager.on('continued', continuedListener); + manager.on('output', outputListener); manager.on('dap-event', defaultListener); (manager as unknown as { handleDapEvent: (msg: unknown) => void }).handleDapEvent({ @@ -219,8 +221,18 @@ describe('ProxyManager branch coverage scenarios', () => { body: { category: 'console', output: 'log' } }); + (manager as unknown as { handleDapEvent: (msg: unknown) => void }).handleDapEvent({ + type: 'dapEvent', + sessionId: 'session-1', + event: 'loadedSource', + body: { reason: 'new' } + }); + expect(continuedListener).toHaveBeenCalledTimes(1); - expect(defaultListener).toHaveBeenCalledWith('output', { category: 'console', output: 'log' }); + expect(outputListener).toHaveBeenCalledWith({ category: 'console', output: 'log' }); + // output is a first-class event now; only unhandled events ride 'dap-event' + expect(defaultListener).toHaveBeenCalledTimes(1); + expect(defaultListener).toHaveBeenCalledWith('loadedSource', { reason: 'new' }); }); it('resolves await-response launch barriers once dap response arrives', async () => { diff --git a/tests/unit/server-coverage.test.ts b/tests/unit/server-coverage.test.ts index 7a616c5d..a34f0255 100644 --- a/tests/unit/server-coverage.test.ts +++ b/tests/unit/server-coverage.test.ts @@ -47,6 +47,8 @@ describe('Server Coverage - Error Paths and Edge Cases', () => { stepInto: vi.fn(), stepOut: vi.fn(), evaluateExpression: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), adapterRegistry: { getSupportedLanguages: vi.fn().mockReturnValue(['python', 'mock']), listLanguages: vi.fn().mockResolvedValue(['python', 'mock']), diff --git a/tools/dev-proxy/dev-proxy.mjs b/tools/dev-proxy/dev-proxy.mjs index 3f923c7a..2292c833 100644 --- a/tools/dev-proxy/dev-proxy.mjs +++ b/tools/dev-proxy/dev-proxy.mjs @@ -25,7 +25,14 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + ListToolsRequestSchema, + CallToolRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, + SubscribeRequestSchema, + UnsubscribeRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; import { spawn, execSync } from 'child_process'; import { fileURLToPath } from 'url'; import path from 'path'; @@ -337,6 +344,18 @@ class BackendManager { async _connectClient(command, args) { this.mcpClient = new Client({ name: 'dev-proxy', version: '1.0.0' }); + // Relay backend resource notifications (resources/updated, list_changed) + // to the front client (issue #218). Registered as the fallback handler so + // unknown future notifications are ignored rather than crashing. + this.mcpClient.fallbackNotificationHandler = async (notification) => { + if ( + notification?.method === 'notifications/resources/updated' || + notification?.method === 'notifications/resources/list_changed' + ) { + this.onResourceNotification?.(notification); + } + }; + if (this.backendTransport === 'stdio') { // Stdio mode: StdioClientTransport spawns the child const transport = new StdioClientTransport({ @@ -688,9 +707,16 @@ async function main() { // Create the MCP Server that Claude Code talks to (via stdio) const server = new Server( { name: 'dev-proxy', version: '1.0.0' }, - { capabilities: { tools: { listChanged: true } } } + { capabilities: { tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } } } ); + // Relay backend resource notifications to the front client (issue #218) + backend.onResourceNotification = (notification) => { + server.notification(notification).catch((err) => { + log(`Failed to relay ${notification.method}: ${err.message}`); + }); + }; + // ListTools: forward live to backend, fall back to dev-tools-only when backend is down server.setRequestHandler(ListToolsRequestSchema, async () => { if (backend.state === 'running' && backend.mcpClient) { @@ -737,6 +763,32 @@ async function main() { } }); + // Resources: pure passthrough to the backend (issue #218). Note that + // subscriptions live in the backend process, so they are lost when the + // backend is restarted (dev_rebuild_and_restart) — re-subscribe after. + server.setRequestHandler(ListResourcesRequestSchema, async () => { + if (backend.state === 'running' && backend.mcpClient) { + try { + return await backend.mcpClient.listResources(); + } catch (err) { + log(`Live resources/list failed: ${err.message}`); + } + } + return { resources: [] }; + }); + + server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + return await backend.mcpClient.readResource(request.params); + }); + + server.setRequestHandler(SubscribeRequestSchema, async (request) => { + return await backend.mcpClient.subscribeResource(request.params); + }); + + server.setRequestHandler(UnsubscribeRequestSchema, async (request) => { + return await backend.mcpClient.unsubscribeResource(request.params); + }); + // Connect to stdio transport for Claude Code const transport = new StdioServerTransport(); await server.connect(transport);