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 docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,8 @@ Gets the current call stack.
**Notes:**
- Stack frames are ordered from innermost (current) to outermost
- Frame IDs are used with `get_scopes`
- Internal/runtime frames (e.g. Node.js internals, Go `/runtime/`, `System.*`) are filtered out by default; pass `includeInternals: true` to see them. When any frames were hidden, the response additionally carries `hiddenFrames` (count) and a `note` explaining how to reveal them.
- The filtered stack is never empty when the adapter reported frames: if *every* frame is internal (e.g. a goroutine paused inside the Go runtime), the top internal frame is kept so `get_scopes`/`evaluate_expression` still have a valid `frameId`, and the `note` says so.

---

Expand Down
7 changes: 7 additions & 0 deletions examples/rust/pause_test/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 22 additions & 6 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ import {
ProxyNotRunningError
} from './errors/debug-errors.js';
import { SessionManager, SessionManagerConfig } from './session/session-manager.js';
import { StackTraceResult } from './session/session-manager-data.js';
import { createProductionDependencies } from './container/dependencies.js';
import { ContainerConfig } from './container/types.js';
import {
DebugSessionInfo,
Variable,
StackFrame,
DebugLanguage,
Breakpoint,
FunctionBreakpoint,
Expand Down Expand Up @@ -801,7 +801,7 @@ export class DebugMcpServer {
return this.sessionManager.getVariables(sessionId, variablesReference, names);
}

public async getStackTrace(sessionId: string, includeInternals: boolean = false): Promise<StackFrame[]> {
public async getStackTrace(sessionId: string, includeInternals: boolean = false): Promise<StackTraceResult> {
this.validateSession(sessionId);
const session = this.sessionManager.getSession(sessionId);
if (!session || !session.proxyManager) {
Expand All @@ -824,7 +824,7 @@ export class DebugMcpServer {
if (typeof currentThreadId !== 'number') {
throw new ProxyNotRunningError(sessionId || 'unknown', 'get stack trace');
}
return this.sessionManager.getStackTrace(sessionId, currentThreadId, includeInternals);
return this.sessionManager.getStackTraceDetailed(sessionId, currentThreadId, includeInternals);
}

public async getScopes(sessionId: string, frameId: number): Promise<DebugProtocol.Scope[]> {
Expand Down Expand Up @@ -1102,7 +1102,7 @@ export class DebugMcpServer {
{ 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" }, names: namesProp }, required: getVariablesRequired } },
{ name: 'get_local_variables', description: 'Get local variables for the current stack frame. This is a convenience tool that returns just the local variables without needing to traverse stack->scopes->variables manually', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, includeSpecial: { type: 'boolean', description: 'Include special/internal variables like this, __proto__, __builtins__, etc. Default: false' }, names: namesProp }, required: getLocalVariablesRequired } },
{ name: 'get_stack_trace', description: 'Get stack trace. The response includes stopReason — why the session is paused (e.g. "breakpoint" vs "exception")', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, includeInternals: { type: 'boolean', description: 'Include internal/framework frames (e.g., Node.js internals). Default: false for cleaner output.' } }, required: ['sessionId'] } },
{ name: 'get_stack_trace', description: 'Get stack trace. The response includes stopReason — why the session is paused (e.g. "breakpoint" vs "exception"). Internal/runtime frames are filtered by default; when any are hidden the response carries hiddenFrames and a note (the top frame is always kept even if internal, so frameId anchors keep working)', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, includeInternals: { type: 'boolean', description: 'Include internal/framework frames (e.g., Node.js internals). Default: false for cleaner output.' } }, required: ['sessionId'] } },
{ 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'] } },
Expand Down Expand Up @@ -1948,9 +1948,25 @@ export class DebugMcpServer {
try {
// Default to false for cleaner output
const includeInternals = args.includeInternals ?? false;
const stackFrames = await this.getStackTrace(args.sessionId, includeInternals);
const stackTrace = await this.getStackTrace(args.sessionId, includeInternals);
const lastStop = this.sessionManager.getSession(args.sessionId)?.lastStop;
result = { content: [{ type: 'text', text: JSON.stringify({ success: true, stackFrames, count: stackFrames.length, includeInternals, stopReason: lastStop?.reason, lastStop }) }] };
const payload: Record<string, unknown> = {
success: true,
stackFrames: stackTrace.frames,
count: stackTrace.frames.length,
includeInternals,
stopReason: lastStop?.reason,
lastStop
};
// Issue #346: when the language policy hid frames, say so in the
// response instead of relying on the agent knowing filtering exists.
if (stackTrace.hiddenFrameCount > 0) {
payload.hiddenFrames = stackTrace.hiddenFrameCount;
payload.note = stackTrace.allFramesInternal
? `All ${stackTrace.totalFrameCount} frames are internal/runtime frames; showing the top internal frame so scopes and evaluate still work. Pass includeInternals: true to see the full stack.`
: `${stackTrace.hiddenFrameCount} internal frame(s) hidden — pass includeInternals: true to see them.`;
}
result = { content: [{ type: 'text', text: JSON.stringify(payload) }] };
} catch (error) {
// Handle validation errors specifically
if (error instanceof SessionTerminatedError ||
Expand Down
67 changes: 53 additions & 14 deletions src/session/session-manager-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,22 @@ import {
import { SessionManagerCore } from './session-manager-core.js';
import { DebugProtocol } from '@vscode/debugprotocol';

/**
* Stack trace frames plus filtering metadata (issue #346): how many frames the
* adapter reported, how many the language policy hid, and whether the
* kept-first-frame fallback fired because every frame was internal.
*/
export interface StackTraceResult {
frames: StackFrame[];
totalFrameCount: number;
hiddenFrameCount: number;
allFramesInternal: boolean;
}

function emptyStackTraceResult(): StackTraceResult {
return { frames: [], totalFrameCount: 0, hiddenFrameCount: 0, allFramesInternal: false };
}

/**
* Data retrieval functionality for session management
*/
Expand Down Expand Up @@ -111,23 +127,35 @@ export abstract class SessionManagerData extends SessionManagerCore {
}

async getStackTrace(sessionId: string, threadId?: number, includeInternals: boolean = false): Promise<StackFrame[]> {
return (await this.getStackTraceDetailed(sessionId, threadId, includeInternals)).frames;
}

/**
* Stack trace plus filtering metadata. Guarantees a non-empty `frames` array
* whenever the adapter reported any frames: if the language policy filters
* every frame as internal (issue #346 — e.g. a goroutine paused entirely in
* Go runtime frames), the top unfiltered frame is kept so the agent always
* has a frameId to anchor scopes/evaluate, and `hiddenFrameCount` +
* `allFramesInternal` let the response say what was hidden.
*/
async getStackTraceDetailed(sessionId: string, threadId?: number, includeInternals: boolean = false): Promise<StackTraceResult> {
const session = this._getSessionById(sessionId);
const currentThreadId = session.proxyManager?.getCurrentThreadId();
this.logger.info(`[SM getStackTrace ${sessionId}] Entered. Requested threadId: ${threadId}, Current state: ${session.state}, Actual currentThreadId: ${currentThreadId}, includeInternals: ${includeInternals}`);

if (!session.proxyManager || !session.proxyManager.isRunning()) {
this.logger.warn(`[SM getStackTrace ${sessionId}] No active proxy.`);
return [];
if (!session.proxyManager || !session.proxyManager.isRunning()) {
this.logger.warn(`[SM getStackTrace ${sessionId}] No active proxy.`);
return emptyStackTraceResult();
}
if (session.state !== SessionState.PAUSED) {
this.logger.warn(`[SM getStackTrace ${sessionId}] Session not paused. State: ${session.state}.`);
return [];
if (session.state !== SessionState.PAUSED) {
this.logger.warn(`[SM getStackTrace ${sessionId}] Session not paused. State: ${session.state}.`);
return emptyStackTraceResult();
}

const currentThreadForRequest = threadId || currentThreadId;
if (typeof currentThreadForRequest !== 'number') {
this.logger.warn(`[SM getStackTrace ${sessionId}] No effective thread ID to use.`);
return [];
if (typeof currentThreadForRequest !== 'number') {
this.logger.warn(`[SM getStackTrace ${sessionId}] No effective thread ID to use.`);
return emptyStackTraceResult();
}

try {
Expand All @@ -150,15 +178,26 @@ export abstract class SessionManagerData extends SessionManagerCore {
}));

// Apply filtering using the language's policy
const totalFrameCount = frames.length;
let allFramesInternal = false;
const policy = this.selectPolicy(session.language);
if (policy.filterStackFrames) {
this.logger.info(`[SM getStackTrace ${sessionId}] Applying stack frame filtering for ${session.language}. Original count: ${frames.length}`);
frames = policy.filterStackFrames(frames, includeInternals);
this.logger.info(`[SM getStackTrace ${sessionId}] After filtering: ${frames.length} frames`);
const filtered = policy.filterStackFrames(frames, includeInternals);
// Central guarantee (issue #346): a policy filter must never leave the
// agent with zero frames when the adapter reported some — keep the top
// unfiltered frame so scopes/evaluate still have an anchor.
if (filtered.length === 0 && frames.length > 0) {
allFramesInternal = true;
frames = [frames[0]];
} else {
frames = filtered;
}
this.logger.info(`[SM getStackTrace ${sessionId}] After filtering: ${frames.length} frames (hidden: ${totalFrameCount - frames.length}, allFramesInternal: ${allFramesInternal})`);
}

this.logger.info(`[SM getStackTrace ${sessionId}] Parsed stack frames (top 3):`, frames.slice(0,3).map(f => ({name:f.name, file:f.file, line:f.line})));
return frames;
return { frames, totalFrameCount, hiddenFrameCount: totalFrameCount - frames.length, allFramesInternal };
}
this.logger.warn(`[SM getStackTrace ${sessionId}] No stackFrames in response body. Response:`, response);
throw new Error(`DAP 'stackTrace' response did not include stack frames`);
Expand Down
57 changes: 53 additions & 4 deletions tests/core/unit/server/server-inspection-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,19 +178,68 @@ describe('Server Inspection Tools Tests', () => {
};

mockSessionManager.getSession.mockReturnValue(mockSession);
mockSessionManager.getStackTrace.mockResolvedValue(mockStackFrames);

mockSessionManager.getStackTraceDetailed.mockResolvedValue({
frames: mockStackFrames, totalFrameCount: 1, hiddenFrameCount: 0, allFramesInternal: false
});

const result = await callToolHandler({
method: 'tools/call',
params: {
name: 'get_stack_trace',
arguments: { sessionId: 'test-session' }
}
});


const content = JSON.parse(result.content[0].text);
expect(content.success).toBe(true);
expect(content.stackFrames).toHaveLength(1);
// No frames hidden -> no annotation noise (issue #346)
expect(content.hiddenFrames).toBeUndefined();
expect(content.note).toBeUndefined();
});

it('annotates hidden internal frames with a count and how to reveal them (issue #346)', async () => {
const mockSession = {
proxyManager: { getCurrentThreadId: vi.fn().mockReturnValue(1) }
};
mockSessionManager.getSession.mockReturnValue(mockSession);
mockSessionManager.getStackTraceDetailed.mockResolvedValue({
frames: [{ id: 7, name: 'handler', file: 'app.go', line: 12 }],
totalFrameCount: 4, hiddenFrameCount: 3, allFramesInternal: false
});

const result = await callToolHandler({
method: 'tools/call',
params: { name: 'get_stack_trace', arguments: { sessionId: 'test-session' } }
});

const content = JSON.parse(result.content[0].text);
expect(content.success).toBe(true);
expect(content.hiddenFrames).toBe(3);
expect(content.note).toContain('includeInternals: true');
});

it('explains the kept-first-frame fallback when every frame is internal (issue #346)', async () => {
const mockSession = {
proxyManager: { getCurrentThreadId: vi.fn().mockReturnValue(1) }
};
mockSessionManager.getSession.mockReturnValue(mockSession);
mockSessionManager.getStackTraceDetailed.mockResolvedValue({
frames: [{ id: 1, name: 'runtime.gopark', file: '/usr/local/go/src/runtime/proc.go', line: 402 }],
totalFrameCount: 5, hiddenFrameCount: 4, allFramesInternal: true
});

const result = await callToolHandler({
method: 'tools/call',
params: { name: 'get_stack_trace', arguments: { sessionId: 'test-session' } }
});

const content = JSON.parse(result.content[0].text);
expect(content.success).toBe(true);
expect(content.stackFrames).toHaveLength(1);
expect(content.hiddenFrames).toBe(4);
expect(content.note).toContain('internal/runtime frames');
expect(content.note).toContain('includeInternals: true');
});

it('should handle missing session', async () => {
Expand Down Expand Up @@ -259,7 +308,7 @@ describe('Server Inspection Tools Tests', () => {
};

mockSessionManager.getSession.mockReturnValue(mockSession);
mockSessionManager.getStackTrace.mockRejectedValue(new Error('Stack trace failed'));
mockSessionManager.getStackTraceDetailed.mockRejectedValue(new Error('Stack trace failed'));

// DAP-level failures must produce success:false with the real error,
// never an empty-but-successful stack trace (issue #124).
Expand Down
6 changes: 6 additions & 0 deletions tests/core/unit/server/server-test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ export function createMockSessionManager(mockAdapterRegistry: any) {
getVariables: vi.fn(),
getLocalVariables: vi.fn(),
getStackTrace: vi.fn(),
getStackTraceDetailed: vi.fn().mockResolvedValue({
frames: [],
totalFrameCount: 0,
hiddenFrameCount: 0,
allFramesInternal: false
}),
getScopes: vi.fn(),
evaluateExpression: vi.fn(),
getSessionPolicy: vi.fn().mockReturnValue({}),
Expand Down
Loading
Loading