diff --git a/packages/adapter-java/java/JdiDapServer.java b/packages/adapter-java/java/JdiDapServer.java index c5aa4bbc..31932881 100644 --- a/packages/adapter-java/java/JdiDapServer.java +++ b/packages/adapter-java/java/JdiDapServer.java @@ -204,6 +204,7 @@ private void handleMessage(Map msg) { case "evaluate": handleEvaluate(reqSeq, args); break; case "setExceptionBreakpoints": handleSetExceptionBreakpoints(reqSeq, args); break; case "source": sendResponse(reqSeq, command, true, mapOf("content", "")); break; + case "redefineClasses": handleRedefineClasses(reqSeq, args); break; default: log("Unhandled command: " + command); sendErrorResponse(reqSeq, command, "Unsupported command: " + command); @@ -252,6 +253,12 @@ private void handleAttach(int reqSeq, Map args) throws Exception vm = connector.attach(connArgs); log("Attached to VM: " + vm.description()); + boolean suspend = boolVal(args, "stopOnEntry", false); + if (suspend) { + vm.suspend(); + log("VM suspended (stopOnEntry=true)"); + } + startEventLoop(); registerPendingBreakpoints(); sendResponse(reqSeq, "attach", true, new HashMap<>()); @@ -1096,6 +1103,102 @@ private void handleSetExceptionBreakpoints(int reqSeq, Map args) sendResponse(reqSeq, "setExceptionBreakpoints", true, new HashMap<>()); } + // ========== Hot Reload (redefineClasses) ========== + + private void handleRedefineClasses(int reqSeq, Map args) { + String classesDir = str(args, "classesDir"); + long since = longVal(args, "sinceTimestamp"); + + if (vm == null) { + sendErrorResponse(reqSeq, "redefineClasses", "No VM attached"); + return; + } + if (classesDir == null || classesDir.isEmpty()) { + sendErrorResponse(reqSeq, "redefineClasses", "classesDir is required"); + return; + } + + java.nio.file.Path dir = java.nio.file.Paths.get(classesDir); + if (!java.nio.file.Files.isDirectory(dir)) { + sendErrorResponse(reqSeq, "redefineClasses", "Not a directory: " + classesDir); + return; + } + + try { + // 1. Scan for .class files, filter by mtime + List classFiles = new ArrayList<>(); + long newestTimestamp = 0; + java.util.Deque stack = new ArrayDeque<>(); + stack.push(dir); + while (!stack.isEmpty()) { + java.nio.file.Path current = stack.pop(); + try (java.nio.file.DirectoryStream stream = + java.nio.file.Files.newDirectoryStream(current)) { + for (java.nio.file.Path entry : stream) { + if (java.nio.file.Files.isDirectory(entry)) { + stack.push(entry); + } else if (entry.toString().endsWith(".class")) { + long mtime = java.nio.file.Files.getLastModifiedTime(entry).toMillis(); + if (mtime > newestTimestamp) newestTimestamp = mtime; + if (since <= 0 || mtime > since) { + classFiles.add(entry); + } + } + } + } + } + + // 2. Match against loaded classes and redefine + List redefined = new ArrayList<>(); + List> failed = new ArrayList<>(); + int skippedNotLoaded = 0; + + for (java.nio.file.Path classFile : classFiles) { + // Convert path to FQCN: com/example/Foo$Bar.class -> com.example.Foo$Bar + String relative = dir.relativize(classFile).toString(); + String fqcn = relative.replace(java.io.File.separatorChar, '.') + .replace('/', '.'); + if (fqcn.endsWith(".class")) { + fqcn = fqcn.substring(0, fqcn.length() - 6); + } + + List types = vm.classesByName(fqcn); + if (types.isEmpty()) { + skippedNotLoaded++; + continue; + } + + try { + byte[] bytes = java.nio.file.Files.readAllBytes(classFile); + Map redefMap = new HashMap<>(); + redefMap.put(types.get(0), bytes); + vm.redefineClasses(redefMap); + redefined.add(fqcn); + } catch (Exception e) { + Map entry = new HashMap<>(); + entry.put("fqcn", fqcn); + entry.put("error", e.getClass().getSimpleName() + ": " + e.getMessage()); + failed.add(entry); + } + } + + // 3. Build response + Map body = new HashMap<>(); + body.put("redefined", redefined); + body.put("redefinedCount", redefined.size()); + body.put("skippedNotLoaded", skippedNotLoaded); + body.put("failedCount", failed.size()); + if (!failed.isEmpty()) body.put("failed", failed); + body.put("scannedFiles", classFiles.size()); + body.put("newestTimestamp", newestTimestamp); + sendResponse(reqSeq, "redefineClasses", true, body); + + } catch (Exception e) { + sendErrorResponse(reqSeq, "redefineClasses", + "Scan/redefine error: " + e.getClass().getSimpleName() + ": " + e.getMessage()); + } + } + // ========== JDI Event Loop ========== private void startEventLoop() { diff --git a/src/server.ts b/src/server.ts index 70cd86b5..9803439b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -100,6 +100,9 @@ interface ToolArguments { terminateProcess?: boolean; suspendPolicy?: 'all' | 'thread'; threadId?: number; + // redefine_classes parameters + classesDir?: string; + sinceTimestamp?: number; } /** @@ -607,6 +610,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', 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' } }, 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: '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.' } }, required: ['sessionId', 'classesDir'] } }, ], }; }); @@ -672,7 +676,8 @@ export class DebugMcpServer { const attachResult = await this.sessionManager.attachToProcess(sessionInfo.id, { port: args.port as number, host: (args.host as string) || 'localhost', - timeout: (args.timeout as number) || 30000 + timeout: (args.timeout as number) || 30000, + stopOnEntry: args.stopOnEntry ?? false, }); result = { content: [{ type: 'text', text: JSON.stringify({ @@ -861,7 +866,7 @@ export class DebugMcpServer { processId: args.processId, timeout: args.timeout, sourcePaths: args.sourcePaths, - stopOnEntry: args.stopOnEntry, + stopOnEntry: args.stopOnEntry ?? false, justMyCode: args.justMyCode }); @@ -1167,6 +1172,17 @@ export class DebugMcpServer { result = await this.handleListSupportedLanguages(); break; } + case 'redefine_classes': { + const redefineResult = await this.sessionManager.redefineClasses( + args.sessionId as string, + args.classesDir as string, + (args.sinceTimestamp as number) || 0 + ); + result = { + content: [{ type: 'text' as const, text: JSON.stringify(redefineResult, null, 2) }], + }; + break; + } default: throw new McpError(McpErrorCode.MethodNotFound, `Unknown tool: ${toolName}`); } diff --git a/src/session/session-manager-operations.ts b/src/session/session-manager-operations.ts index d10428ff..ed4d9587 100644 --- a/src/session/session-manager-operations.ts +++ b/src/session/session-manager-operations.ts @@ -43,6 +43,18 @@ export interface EvaluateResult { error?: string; } +export interface RedefineClassesResult { + success: boolean; + redefined?: string[]; + redefinedCount?: number; + skippedNotLoaded?: number; + failedCount?: number; + failed?: Array<{ fqcn: string; error: string }>; + scannedFiles?: number; + newestTimestamp?: number; + error?: string; +} + /** * Debug operations functionality for session management */ @@ -1607,4 +1619,88 @@ export abstract class SessionManagerOperations extends SessionManagerData { } } + /** + * Wait for a session to emit a stopped event after launch to honour the first breakpoint. + */ + private async waitForInitialBreakpointPause(sessionId: string, timeoutMs: number): Promise { + const session = this._getSessionById(sessionId); + const proxyManager = session.proxyManager; + + if (!proxyManager) { + return false; + } + + if (session.state === SessionState.PAUSED) { + return true; + } + + return new Promise((resolve) => { + let settled = false; + + const cleanup = () => { + proxyManager.removeListener('stopped', onStopped); + clearTimeout(timer); + }; + + const onStopped = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(true); + }; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + resolve(false); + }, timeoutMs); + + proxyManager.once('stopped', onStopped); + }); + } + + async redefineClasses( + sessionId: string, + classesDir: string, + sinceTimestamp: number = 0 + ): Promise { + const session = this._getSessionById(sessionId); + this.logger.info( + `[SM redefineClasses ${sessionId}] classesDir: "${classesDir}", since: ${sinceTimestamp}` + ); + + if (!session.proxyManager || !session.proxyManager.isRunning()) { + return { success: false, error: 'No active debug session' }; + } + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const response = await session.proxyManager.sendDapRequest( + 'redefineClasses', { classesDir, sinceTimestamp } + ); + + const body = response?.body; + if (!body) { + return { success: false, error: 'No response body from redefineClasses' }; + } + + return { + success: true, + redefined: body.redefined, + redefinedCount: body.redefinedCount, + skippedNotLoaded: body.skippedNotLoaded, + failedCount: body.failedCount, + failed: body.failed, + scannedFiles: body.scannedFiles, + newestTimestamp: body.newestTimestamp, + }; + } catch (error) { + this.logger.error(`[SM redefineClasses ${sessionId}] Error: ${error}`); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } } diff --git a/tests/core/unit/server/server-redefine-and-attach.test.ts b/tests/core/unit/server/server-redefine-and-attach.test.ts new file mode 100644 index 00000000..62bb93f4 --- /dev/null +++ b/tests/core/unit/server/server-redefine-and-attach.test.ts @@ -0,0 +1,273 @@ +/** + * Tests for redefine_classes tool and attach stopOnEntry behavior + */ +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 { DebugMcpServer } from '../../../../src/server.js'; +import { SessionManager } from '../../../../src/session/session-manager.js'; +import { DebugSessionInfo, DebugLanguage, SessionState } from '@debugmcp/shared'; +import { createProductionDependencies } from '../../../../src/container/dependencies.js'; +import { + createMockDependencies, + createMockServer, + createMockSessionManager, + createMockStdioTransport, + getToolHandlers +} 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('redefine_classes and attach stopOnEntry tests', () => { + let mockServer: any; + let mockSessionManager: any; + let mockDependencies: any; + let callToolHandler: any; + let listToolsHandler: any; + + beforeEach(() => { + 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); + vi.mocked(SessionManager).mockImplementation(function () { return mockSessionManager as any; }); + + new DebugMcpServer(); + const handlers = getToolHandlers(mockServer); + callToolHandler = handlers.callToolHandler; + listToolsHandler = handlers.listToolsHandler; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('redefine_classes tool registration', () => { + it('should be listed in available tools', async () => { + const result = await listToolsHandler({ method: 'tools/list', params: {} }); + const toolNames = result.tools.map((t: any) => t.name); + expect(toolNames).toContain('redefine_classes'); + }); + + it('should have correct input schema', async () => { + const result = await listToolsHandler({ method: 'tools/list', params: {} }); + const tool = result.tools.find((t: any) => t.name === 'redefine_classes'); + expect(tool).toBeDefined(); + expect(tool.inputSchema.required).toContain('sessionId'); + expect(tool.inputSchema.required).toContain('classesDir'); + expect(tool.inputSchema.properties.sinceTimestamp).toBeDefined(); + }); + }); + + describe('redefine_classes tool dispatch', () => { + it('should call sessionManager.redefineClasses with correct args', async () => { + mockSessionManager.redefineClasses.mockResolvedValue({ + success: true, + redefined: ['com.example.Foo'], + redefinedCount: 1, + skippedNotLoaded: 5, + failedCount: 0, + scannedFiles: 6, + newestTimestamp: 1234567890, + }); + + const result = await callToolHandler({ + method: 'tools/call', + params: { + name: 'redefine_classes', + arguments: { + sessionId: 'test-session', + classesDir: '/path/to/classes', + sinceTimestamp: 1000000, + }, + }, + }); + + expect(mockSessionManager.redefineClasses).toHaveBeenCalledWith( + 'test-session', + '/path/to/classes', + 1000000 + ); + + const content = JSON.parse(result.content[0].text); + expect(content.success).toBe(true); + expect(content.redefined).toEqual(['com.example.Foo']); + expect(content.redefinedCount).toBe(1); + expect(content.newestTimestamp).toBe(1234567890); + }); + + it('should default sinceTimestamp to 0 when omitted', async () => { + mockSessionManager.redefineClasses.mockResolvedValue({ + success: true, + redefined: [], + redefinedCount: 0, + skippedNotLoaded: 0, + failedCount: 0, + scannedFiles: 0, + newestTimestamp: 0, + }); + + await callToolHandler({ + method: 'tools/call', + params: { + name: 'redefine_classes', + arguments: { + sessionId: 'test-session', + classesDir: '/path/to/classes', + }, + }, + }); + + expect(mockSessionManager.redefineClasses).toHaveBeenCalledWith( + 'test-session', + '/path/to/classes', + 0 + ); + }); + + it('should propagate errors from sessionManager', async () => { + mockSessionManager.redefineClasses.mockRejectedValue(new Error('Session not found')); + + await expect( + callToolHandler({ + method: 'tools/call', + params: { + name: 'redefine_classes', + arguments: { + sessionId: 'nonexistent', + classesDir: '/path/to/classes', + }, + }, + }) + ).rejects.toThrow(/Session not found/); + }); + }); + + describe('create_debug_session attach stopOnEntry', () => { + const mockSessionInfo: DebugSessionInfo = { + id: 'attach-session-1', + name: 'Attach Test', + language: 'python' as DebugLanguage, + state: 'created' as SessionState, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(() => { + mockSessionManager.createSession.mockResolvedValue(mockSessionInfo); + }); + + it('should default stopOnEntry to false for attach mode', async () => { + mockSessionManager.attachToProcess.mockResolvedValue({ + success: true, + state: 'running', + }); + + await callToolHandler({ + method: 'tools/call', + params: { + name: 'create_debug_session', + arguments: { + language: 'python', + port: 5009, + }, + }, + }); + + expect(mockSessionManager.attachToProcess).toHaveBeenCalledWith( + 'attach-session-1', + expect.objectContaining({ + stopOnEntry: false, + }) + ); + }); + + it('should forward stopOnEntry=true when explicitly set', async () => { + mockSessionManager.attachToProcess.mockResolvedValue({ + success: true, + state: 'paused', + }); + + await callToolHandler({ + method: 'tools/call', + params: { + name: 'create_debug_session', + arguments: { + language: 'python', + port: 5009, + stopOnEntry: true, + }, + }, + }); + + expect(mockSessionManager.attachToProcess).toHaveBeenCalledWith( + 'attach-session-1', + expect.objectContaining({ + stopOnEntry: true, + }) + ); + }); + }); + + describe('attach_to_process stopOnEntry', () => { + it('should default stopOnEntry to false', async () => { + mockSessionManager.attachToProcess.mockResolvedValue({ + success: true, + state: 'running', + }); + + await callToolHandler({ + method: 'tools/call', + params: { + name: 'attach_to_process', + arguments: { + sessionId: 'test-session', + port: 5006, + }, + }, + }); + + expect(mockSessionManager.attachToProcess).toHaveBeenCalledWith( + 'test-session', + expect.objectContaining({ + stopOnEntry: false, + }) + ); + }); + + it('should forward stopOnEntry=true when explicitly set', async () => { + mockSessionManager.attachToProcess.mockResolvedValue({ + success: true, + state: 'paused', + }); + + await callToolHandler({ + method: 'tools/call', + params: { + name: 'attach_to_process', + arguments: { + sessionId: 'test-session', + port: 5006, + stopOnEntry: true, + }, + }, + }); + + expect(mockSessionManager.attachToProcess).toHaveBeenCalledWith( + 'test-session', + expect.objectContaining({ + stopOnEntry: true, + }) + ); + }); + }); +}); diff --git a/tests/core/unit/server/server-test-helpers.ts b/tests/core/unit/server/server-test-helpers.ts index 71c0ba1e..5e51f182 100644 --- a/tests/core/unit/server/server-test-helpers.ts +++ b/tests/core/unit/server/server-test-helpers.ts @@ -101,6 +101,7 @@ export function createMockSessionManager(mockAdapterRegistry: any) { listThreads: vi.fn(), detachFromProcess: vi.fn(), attachToProcess: vi.fn(), + redefineClasses: vi.fn(), getAdapterRegistry: vi.fn().mockReturnValue(mockAdapterRegistry), adapterRegistry: mockAdapterRegistry };