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
82 changes: 72 additions & 10 deletions packages/adapter-java/java/JdiDapServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public class JdiDapServer {
private volatile boolean configurationDone = false;
private volatile boolean launchSuspended = false; // true if we launched with suspend=y
private volatile boolean stopOnEntry = true; // whether to stop on entry in launch mode
private volatile boolean lastStopAllThreads = true; // tracks whether last stop suspended all threads

// --- Logging ---
private static boolean debug = false;
Expand Down Expand Up @@ -195,6 +196,7 @@ private void handleMessage(Map<String, Object> msg) {
case "scopes": handleScopes(reqSeq, args); break;
case "variables": handleVariables(reqSeq, args); break;
case "continue": handleContinue(reqSeq, args); break;
case "pause": handlePause(reqSeq, args); break;
case "next": handleStep(reqSeq, args, StepRequest.STEP_OVER); break;
case "stepIn": handleStep(reqSeq, args, StepRequest.STEP_INTO); break;
case "stepOut": handleStep(reqSeq, args, StepRequest.STEP_OUT); break;
Expand Down Expand Up @@ -392,7 +394,8 @@ private void registerPendingBreakpoints() {
Map<String, Object> bpSpec = asMap(bpObj);
int line = intVal(bpSpec, "line");
String condition = str(bpSpec, "condition");
Map<String, Object> bp = setBreakpointOnClass(found, line, condition, sourcePath);
String suspendPol = str(bpSpec, "suspendPolicy");
Map<String, Object> bp = setBreakpointOnClass(found, line, condition, sourcePath, suspendPol);
if (!Boolean.TRUE.equals(bp.get("verified"))) {
hasUnresolved = true;
}
Expand Down Expand Up @@ -543,7 +546,8 @@ private void handleSetBreakpoints(int reqSeq, Map<String, Object> args) {
Map<String, Object> bpSpec = asMap(bpObj);
int line = intVal(bpSpec, "line");
String condition = str(bpSpec, "condition");
Map<String, Object> bp = setBreakpointOnClass(refType, line, condition, sourcePath);
String suspendPol = str(bpSpec, "suspendPolicy");
Map<String, Object> bp = setBreakpointOnClass(refType, line, condition, sourcePath, suspendPol);
results.add(bp);
if (!Boolean.TRUE.equals(bp.get("verified"))) {
hasUnresolvedBreakpoints = true;
Expand Down Expand Up @@ -600,7 +604,7 @@ private void handleSetBreakpoints(int reqSeq, Map<String, Object> args) {
sendResponse(reqSeq, "setBreakpoints", true, mapOf("breakpoints", results));
}

private Map<String, Object> setBreakpointOnClass(ReferenceType refType, int line, String condition, String sourcePath) {
private Map<String, Object> setBreakpointOnClass(ReferenceType refType, int line, String condition, String sourcePath, String suspendPolicy) {
Map<String, Object> bp = new HashMap<>();
bp.put("id", nextBreakpointId.getAndIncrement());
try {
Expand All @@ -624,7 +628,12 @@ private Map<String, Object> setBreakpointOnClass(ReferenceType refType, int line
if (sourcePath != null) {
bpr.putProperty("jdi-bp-source", sourcePath);
}
bpr.setSuspendPolicy(EventRequest.SUSPEND_ALL);
// "thread" = only suspend the event thread; default = suspend all threads
if ("thread".equals(suspendPolicy)) {
bpr.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD);
} else {
bpr.setSuspendPolicy(EventRequest.SUSPEND_ALL);
}
bpr.enable();

bp.put("verified", true);
Expand Down Expand Up @@ -891,16 +900,62 @@ private void handleContinue(int reqSeq, Map<String, Object> args) {
sendErrorResponse(reqSeq, "continue", "No active debug session");
return;
}
long threadId = longVal(args, "threadId");
boolean allContinued;
try {
vm.resume();
if (!lastStopAllThreads && threadId > 0) {
// Only a single thread was suspended — resume just that thread
for (ThreadReference t : vm.allThreads()) {
if (t.uniqueID() == threadId) {
t.resume();
break;
}
}
allContinued = false;
} else {
vm.resume();
allContinued = true;
}
} catch (VMDisconnectedException e) {
// VM already gone
allContinued = true;
}
Map<String, Object> body = new HashMap<>();
body.put("allThreadsContinued", true);
body.put("allThreadsContinued", allContinued);
sendResponse(reqSeq, "continue", true, body);
}

private void handlePause(int reqSeq, Map<String, Object> args) {
if (vm == null) {
sendErrorResponse(reqSeq, "pause", "No active debug session");
return;
}
long threadId = longVal(args, "threadId");
try {
if (threadId > 0) {
// Pause a specific thread
for (ThreadReference t : vm.allThreads()) {
if (t.uniqueID() == threadId) {
t.suspend();
sendStoppedEvent("pause", t.uniqueID(), false);
sendResponse(reqSeq, "pause", true, new HashMap<>());
return;
}
}
sendErrorResponse(reqSeq, "pause", "Thread not found: " + threadId);
} else {
// Pause all threads (threadId 0 or absent)
vm.suspend();
// Pick the first thread for the stopped event
List<ThreadReference> threads = vm.allThreads();
long stoppedThreadId = threads.isEmpty() ? 0 : threads.get(0).uniqueID();
sendStoppedEvent("pause", stoppedThreadId);
sendResponse(reqSeq, "pause", true, new HashMap<>());
}
} catch (VMDisconnectedException e) {
sendErrorResponse(reqSeq, "pause", "VM disconnected");
}
}

private void handleStep(int reqSeq, Map<String, Object> args, int depth) {
long threadId = longVal(args, "threadId");
String cmdName = stepCommandName(depth);
Expand Down Expand Up @@ -1043,7 +1098,8 @@ private void startEventLoop() {
}

log("Breakpoint hit: " + bpe.location());
sendStoppedEvent("breakpoint", bpe.thread().uniqueID());
boolean allStopped = bpr == null || bpr.suspendPolicy() == EventRequest.SUSPEND_ALL;
sendStoppedEvent("breakpoint", bpe.thread().uniqueID(), allStopped);
resume = false;

} else if (event instanceof StepEvent) {
Expand Down Expand Up @@ -1161,7 +1217,8 @@ private void handleClassPrepared(ReferenceType refType) {
Map<String, Object> bpSpec = asMap(bpObj);
int line = intVal(bpSpec, "line");
String condition = str(bpSpec, "condition");
Map<String, Object> bp = setBreakpointOnClass(refType, line, condition, sourcePath);
String suspendPol = str(bpSpec, "suspendPolicy");
Map<String, Object> bp = setBreakpointOnClass(refType, line, condition, sourcePath, suspendPol);

// Send breakpoint verified event
if (Boolean.TRUE.equals(bp.get("verified"))) {
Expand Down Expand Up @@ -1341,10 +1398,15 @@ private void sendEvent(String event, Map<String, Object> body) {
}

private void sendStoppedEvent(String reason, long threadId) {
sendStoppedEvent(reason, threadId, true);
}

private void sendStoppedEvent(String reason, long threadId, boolean allThreadsStopped) {
lastStopAllThreads = allThreadsStopped;
Map<String, Object> body = new HashMap<>();
body.put("reason", reason);
body.put("threadId", threadId);
body.put("allThreadsStopped", true);
body.put("allThreadsStopped", allThreadsStopped);
sendEvent("stopped", body);
}

Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ export interface Breakpoint {
line: number;
/** Conditional expression (if any) */
condition?: string;
/** Suspend policy: 'all' suspends all threads (default), 'thread' only suspends the event thread */
suspendPolicy?: 'all' | 'thread';
/** Whether the breakpoint is verified */
verified: boolean;
/** Validation message from DAP adapter */
Expand Down
40 changes: 31 additions & 9 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ interface ToolArguments {
stopOnEntry?: boolean;
justMyCode?: boolean;
terminateProcess?: boolean;
suspendPolicy?: 'all' | 'thread';
threadId?: number;
}

/**
Expand Down Expand Up @@ -336,14 +338,14 @@ export class DebugMcpServer {
return this.sessionManager.closeSession(sessionId);
}

public async setBreakpoint(sessionId: string, file: string, line: number, condition?: string): Promise<Breakpoint> {
public async setBreakpoint(sessionId: string, file: string, line: number, condition?: string, suspendPolicy?: 'all' | 'thread'): Promise<Breakpoint> {
this.validateSession(sessionId);

// Check if the adapter handles non-file source identifiers (e.g. Java FQCNs)
const policy = this.sessionManager.getSessionPolicy(sessionId);
if (policy.isNonFileSourceIdentifier?.(file)) {
this.logger.info(`[DebugMcpServer.setBreakpoint] Non-file source identifier detected: ${file}`);
return this.sessionManager.setBreakpoint(sessionId, file, line, condition);
return this.sessionManager.setBreakpoint(sessionId, file, line, condition, suspendPolicy);
}

// Check file exists for immediate feedback
Expand All @@ -355,7 +357,7 @@ export class DebugMcpServer {
this.logger.info(`[DebugMcpServer.setBreakpoint] File exists: ${fileCheck.effectivePath} (original: ${file})`);

// Pass the effective path (which has been resolved for container) to session manager
return this.sessionManager.setBreakpoint(sessionId, fileCheck.effectivePath, line, condition);
return this.sessionManager.setBreakpoint(sessionId, fileCheck.effectivePath, line, condition, suspendPolicy);
}

public async getVariables(sessionId: string, variablesReference: number): Promise<Variable[]> {
Expand Down Expand Up @@ -530,7 +532,7 @@ export class DebugMcpServer {
{ name: 'create_debug_session', description: 'Create a new debugging session. Provide host and port to attach to a running process; omit them for launch mode', inputSchema: { type: 'object', properties: { language: { type: 'string', enum: supportedLanguages, description: 'Programming language for debugging' }, name: { type: 'string', description: 'Optional session name' }, executablePath: {type: 'string', description: 'Path to language executable (optional, will auto-detect if not provided)'}, host: { type: 'string', description: 'Host to attach to for remote debugging (optional, triggers attach mode)' }, port: { type: 'number', description: 'Debug port to attach to for remote debugging (optional, triggers attach mode)' }, timeout: { type: 'number', description: 'Connection timeout in milliseconds for attach mode (default: 30000)' } }, required: ['language'] } },
{ name: 'list_supported_languages', description: 'List all supported debugging languages with metadata', inputSchema: { type: 'object', properties: {} } },
{ name: 'list_debug_sessions', description: 'List all active debugging sessions', inputSchema: { type: 'object', properties: {} } },
{ name: 'set_breakpoint', description: 'Set a breakpoint. Setting breakpoints on non-executable lines (structural, declarative) may lead to unexpected behavior', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, file: { type: 'string', description: 'Path to the source file or Java FQCN. For Java, passing a fully-qualified class name (e.g. "com.example.MyClass" or "com.example.Outer$Inner") is preferred — it works reliably with all classloaders including custom classloaders. Alternatively, use absolute file paths.' }, line: { type: 'number', description: 'Line number where to set breakpoint. Executable statements (assignments, function calls, conditionals, returns) work best. Structural lines (function/class definitions), declarative lines (imports), or non-executable lines (comments, blank lines) may cause unexpected stepping behavior' }, condition: { type: 'string' } }, required: ['sessionId', 'file', 'line'] } },
{ name: 'set_breakpoint', description: 'Set a breakpoint. Setting breakpoints on non-executable lines (structural, declarative) may lead to unexpected behavior', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, file: { type: 'string', description: 'Path to the source file or Java FQCN. For Java, passing a fully-qualified class name (e.g. "com.example.MyClass" or "com.example.Outer$Inner") is preferred — it works reliably with all classloaders including custom classloaders. Alternatively, use absolute file paths.' }, line: { type: 'number', description: 'Line number where to set breakpoint. Executable statements (assignments, function calls, conditionals, returns) work best. Structural lines (function/class definitions), declarative lines (imports), or non-executable lines (comments, blank lines) may cause unexpected stepping behavior' }, condition: { type: 'string' }, suspendPolicy: { type: 'string', enum: ['all', 'thread'], description: 'Suspend policy when breakpoint is hit: "all" suspends all threads (default), "thread" only suspends the event thread. Only supported by the Java/JDI adapter.' } }, required: ['sessionId', 'file', 'line'] } },
{ name: 'start_debugging', description: 'Start debugging a script', inputSchema: {
type: 'object',
properties: {
Expand Down Expand Up @@ -584,7 +586,8 @@ export class DebugMcpServer {
{ name: 'step_into', description: 'Step into', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } },
{ name: 'step_out', description: 'Step out', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } },
{ name: 'continue_execution', description: 'Continue execution', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } },
{ name: 'pause_execution', description: 'Pause a running program', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } },
{ name: 'pause_execution', description: 'Pause a running program', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, threadId: { type: 'number', description: 'Thread ID to pause. If omitted or 0, pauses all threads. Only supported by the Java/JDI adapter.' } }, 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'] } },
{ 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' } }, required: ['sessionId'] } },
{ name: 'get_stack_trace', description: 'Get stack trace', 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'] } },
Expand Down Expand Up @@ -702,7 +705,7 @@ export class DebugMcpServer {
}

try {
const breakpoint = await this.setBreakpoint(args.sessionId, args.file, args.line, args.condition);
const breakpoint = await this.setBreakpoint(args.sessionId, args.file, args.line, args.condition, args.suspendPolicy);

// Log breakpoint event
this.logger.info('debug:breakpoint', {
Expand Down Expand Up @@ -1045,7 +1048,14 @@ export class DebugMcpServer {
break;
}
case 'pause_execution': {
result = await this.handlePause(args as { sessionId: string });
result = await this.handlePause(args as { sessionId: string; threadId?: number });
break;
}
case 'list_threads': {
if (!args.sessionId) {
throw new McpError(McpErrorCode.InvalidParams, 'Missing required sessionId');
}
result = await this.handleListThreads(args as { sessionId: string });
break;
}
case 'get_variables': {
Expand Down Expand Up @@ -1202,10 +1212,10 @@ export class DebugMcpServer {
}
}

private async handlePause(args: { sessionId: string }): Promise<ServerResult> {
private async handlePause(args: { sessionId: string; threadId?: number }): Promise<ServerResult> {
try {
this.validateSession(args.sessionId);
const result = await this.sessionManager.pause(args.sessionId);
const result = await this.sessionManager.pause(args.sessionId, args.threadId);
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
} catch (error) {
this.logger.error('Failed to pause execution', { error });
Expand All @@ -1219,6 +1229,18 @@ export class DebugMcpServer {
}
}

private async handleListThreads(args: { sessionId: string }): Promise<ServerResult> {
try {
this.validateSession(args.sessionId);
const threads = await this.sessionManager.listThreads(args.sessionId);
return { content: [{ type: 'text', text: JSON.stringify({ success: true, threads }) }] };
} catch (error) {
this.logger.error('Failed to list threads', { error });
if (error instanceof McpError) throw error;
throw new McpError(McpErrorCode.InternalError, `Failed to list threads: ${(error as Error).message}`);
}
}

private async handleEvaluateExpression(args: { sessionId: string, expression: string, frameId?: number }): Promise<ServerResult> {
try {
// Validate session
Expand Down
Loading
Loading