diff --git a/src/debugMCPServer.ts b/src/debugMCPServer.ts index 30f8c07..93eb56c 100644 --- a/src/debugMCPServer.ts +++ b/src/debugMCPServer.ts @@ -181,6 +181,36 @@ export class DebugMCPServer { return { content: [{ type: 'text' as const, text: result }] }; }); + // Start debugging from a raw inline configuration (language-agnostic). + server.registerTool('start_debugging_with_config', { + description: 'Start a VS Code debug session from a raw, caller-supplied launch.json-style configuration object — ' + + 'no launch.json entry required. Use this to debug an ARBITRARY program/command (any language with an ' + + 'installed debug extension) when there is no suitable existing configuration. The caller owns all ' + + 'toolchain specifics: e.g. for a TypeScript file pass {"type":"node","request":"launch","program":"...",' + + '"runtimeExecutable":"tsx"}; for Python {"type":"python","request":"launch","program":"..."}; to attach ' + + 'to a `node --inspect-brk` process {"type":"node","request":"attach","port":9229}. Pass args/env/cwd/' + + 'console/stopOnEntry as needed; they are forwarded verbatim.', + inputSchema: { + configuration: z.object({ + type: z.string().describe("Debug adapter type, e.g. 'node', 'python', 'go', 'coreclr', 'lldb'."), + request: z.enum(['launch', 'attach']).describe("'launch' to start a new process, 'attach' to connect to a running one."), + name: z.string().optional().describe('Optional session name (defaults to "DebugMCP Inline").'), + program: z.string().optional().describe('Program/entry file to run (for request="launch").'), + }).passthrough().describe( + 'A VS Code DebugConfiguration. Adapter-specific fields (runtimeExecutable, args, env, cwd, console, ' + + 'port, stopOnEntry, …) are passed through unchanged. The agent is responsible for runtime/toolchain ' + + 'choices (e.g. runtimeExecutable:"tsx" for .ts).' + ), + workingDirectory: z.string().describe('Workspace folder for the debug session.'), + }, + }, async (args) => { + const result = await this.debuggingHandler.handleStartDebuggingWithConfig({ + configuration: args.configuration as unknown as vscode.DebugConfiguration, + workingDirectory: args.workingDirectory + }); + return { content: [{ type: 'text' as const, text: result }] }; + }); + // Stop debugging tool server.registerTool('stop_debugging', { description: 'Stop the current debug session', diff --git a/src/debuggingHandler.ts b/src/debuggingHandler.ts index b04a785..3c7050b 100644 --- a/src/debuggingHandler.ts +++ b/src/debuggingHandler.ts @@ -11,6 +11,7 @@ import { logger } from './utils/logger'; */ export interface IDebuggingHandler { handleStartDebugging(args: { fileFullPath: string; workingDirectory: string; testName?: string; configurationName?: string }): Promise; + handleStartDebuggingWithConfig(args: { configuration: vscode.DebugConfiguration; workingDirectory: string }): Promise; handleStopDebugging(): Promise; handleStepOver(): Promise; handleStepInto(): Promise; @@ -122,6 +123,81 @@ export class DebuggingHandler implements IDebuggingHandler { } } + /** + * Start a debugging session from a caller-supplied inline DebugConfiguration. + * + * This is the language-agnostic launcher: it forwards the raw config straight + * to vscode.debug.startDebugging() without injecting any runtime/toolchain + * opinions. The CALLER owns the specialization — e.g. runtimeExecutable:"tsx" + * for a .ts file, debugpy fields for Python, "request":"attach"+port for a + * --inspect-brk process, etc. This keeps the extension a thin shim while still + * supporting arbitrary programs (and languages) without a launch.json entry. + */ + public async handleStartDebuggingWithConfig(args: { + configuration: vscode.DebugConfiguration; + workingDirectory: string; + }): Promise { + const { configuration, workingDirectory } = args; + try { + this.validateConfiguration(configuration); + const label = configuration.name || configuration.program || configuration.type; + const configDescription = `inline configuration '${label}'`; + logger.info( + `handleStartDebuggingWithConfig: type=${configuration.type} ` + + `request=${configuration.request} program=${configuration.program ?? ''} cwd=${workingDirectory}` + ); + + // Subscribe to readiness BEFORE triggering the launch (see handleStartDebugging). + const readyPromise = this.executor.waitForDebugSessionReady(this.timeoutInSeconds * 1000); + + const started = await this.executor.startDebugging(workingDirectory, configuration); + if (!started) { + throw new Error( + 'Failed to start debug session. Make sure the appropriate language extension is ' + + 'installed and the configuration is valid.' + ); + } + + const readyState = await readyPromise; + logger.info(`handleStartDebuggingWithConfig: readyState=${readyState}, fetching current state…`); + const currentState = await this.executor.getCurrentDebugState(this.numNextLines); + + switch (readyState) { + case 'stopped': + return `Debug session stopped at breakpoint using ${configDescription}. Current state: ${currentState.toString()}`; + case 'terminated': + return `Debug session ran to completion without stopping (no breakpoint hit) using ${configDescription}. Final state: ${currentState.toString()}`; + case 'no-session': + throw new Error('Debug session failed to start within the timeout period. Make sure the appropriate language extension is installed and any required build step succeeded.'); + case 'timeout': + return `Debug session is running but did not stop or terminate within the timeout using ${configDescription}. Current state: ${currentState.toString()}`; + } + } catch (error) { + throw new Error(`Error starting debug session: ${error}`); + } + } + + /** + * Minimal validation for an inline DebugConfiguration. We deliberately do NOT + * validate adapter-specific fields — that is the caller's responsibility — but + * VS Code requires at least a `type`, a `request`, and a `name`. + */ + private validateConfiguration(config: vscode.DebugConfiguration): void { + if (!config || typeof config !== 'object') { + throw new Error('configuration must be an object (a VS Code DebugConfiguration).'); + } + if (typeof config.type !== 'string' || config.type.trim() === '') { + throw new Error("configuration.type is required (e.g. 'node', 'python', 'go', 'coreclr')."); + } + if (config.request !== 'launch' && config.request !== 'attach') { + throw new Error("configuration.request must be 'launch' or 'attach'."); + } + if (typeof config.name !== 'string' || config.name.trim() === '') { + // VS Code requires a name; inject a sensible default rather than failing. + config.name = 'DebugMCP Inline'; + } + } + /** * Stop the current debugging session */ diff --git a/src/test/startDebuggingMatrix.test.ts b/src/test/startDebuggingMatrix.test.ts index 5b9feca..e3addaa 100644 --- a/src/test/startDebuggingMatrix.test.ts +++ b/src/test/startDebuggingMatrix.test.ts @@ -312,3 +312,145 @@ suite('handleStartDebugging regression matrix', () => { function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + +/** + * Coverage for handleStartDebuggingWithConfig — the language-agnostic launcher + * that forwards a raw inline DebugConfiguration to vscode.debug.startDebugging + * without injecting any toolchain opinions. + */ +suite('handleStartDebuggingWithConfig (inline config)', () => { + + function makeCapturingMocks(opts: { readyState?: Deferred; startResult?: boolean }) { + const state = new DebugState(); + state.sessionActive = false; + let captured: string | vscode.DebugConfiguration | undefined; + let capturedCwd: string | undefined; + + const executor: IDebuggingExecutor = { + startDebugging: async (cwd: string, config: string | vscode.DebugConfiguration) => { + captured = config; + capturedCwd = cwd; + return opts.startResult ?? true; + }, + debugTestAtCursor: async () => ({ started: true, runComplete: new Promise(() => { /* pending */ }) }), + waitForDebugSessionReady: () => opts.readyState?.promise ?? Promise.resolve('no-session' as ReadyState), + getCurrentDebugState: async () => state, + stopDebugging: async () => { /* noop */ }, + stepOver: async () => { /* noop */ }, + stepInto: async () => { /* noop */ }, + stepOut: async () => { /* noop */ }, + continue: async () => { /* noop */ }, + restart: async () => { /* noop */ }, + addBreakpoint: async () => { /* noop */ }, + removeBreakpoint: async () => { /* noop */ }, + getVariables: async () => ({}), + evaluateExpression: async () => ({}), + getBreakpoints: () => [], + clearAllBreakpoints: () => { /* noop */ }, + hasActiveSession: async () => false, + getActiveSession: () => undefined + }; + + const configManager: IDebugConfigurationManager = { + getDebugConfig: async () => { throw new Error('getDebugConfig must NOT be called for inline-config launches'); }, + detectLanguageFromFilePath: () => 'node' + }; + + return { executor, configManager, getCaptured: () => captured, getCapturedCwd: () => capturedCwd }; + } + + test('forwards the raw config verbatim and returns "stopped"', async () => { + const ready = deferred(); + const mocks = makeCapturingMocks({ readyState: ready, startResult: true }); + const handler = new DebuggingHandler(mocks.executor, mocks.configManager, 30); + + const tsxConfig = { + type: 'node', + request: 'launch' as const, + name: 'ad-hoc tsx', + program: '/repo/scripts/foo.ts', + runtimeExecutable: 'tsx', + args: ['--flag', 'value'], + env: { FOO: 'bar' }, + console: 'integratedTerminal' + }; + + const pending = handler.handleStartDebuggingWithConfig({ + configuration: tsxConfig as unknown as vscode.DebugConfiguration, + workingDirectory: '/repo' + }); + ready.resolve('stopped'); + const result = await pending; + + assert.match(result, /stopped at breakpoint/); + assert.match(result, /ad-hoc tsx/); + // The config the executor received must be exactly what we passed — + // no toolchain fields injected or stripped by the extension. + assert.deepStrictEqual(mocks.getCaptured(), tsxConfig); + assert.strictEqual(mocks.getCapturedCwd(), '/repo'); + }); + + test('clean run returns "terminated"', async () => { + const ready = deferred(); + const mocks = makeCapturingMocks({ readyState: ready, startResult: true }); + const handler = new DebuggingHandler(mocks.executor, mocks.configManager, 30); + + const pending = handler.handleStartDebuggingWithConfig({ + configuration: { type: 'python', request: 'launch', name: 'py', program: '/repo/app.py' } as vscode.DebugConfiguration, + workingDirectory: '/repo' + }); + ready.resolve('terminated'); + assert.match(await pending, /ran to completion without stopping/); + }); + + test('defaults a missing name rather than failing', async () => { + const ready = deferred(); + const mocks = makeCapturingMocks({ readyState: ready, startResult: true }); + const handler = new DebuggingHandler(mocks.executor, mocks.configManager, 30); + + const pending = handler.handleStartDebuggingWithConfig({ + configuration: { type: 'node', request: 'attach', port: 9229 } as unknown as vscode.DebugConfiguration, + workingDirectory: '/repo' + }); + ready.resolve('stopped'); + await pending; + const captured = mocks.getCaptured() as vscode.DebugConfiguration; + assert.strictEqual(captured.name, 'DebugMCP Inline'); + }); + + test('rejects when type is missing', async () => { + const mocks = makeCapturingMocks({ startResult: true }); + const handler = new DebuggingHandler(mocks.executor, mocks.configManager, 30); + await assert.rejects( + handler.handleStartDebuggingWithConfig({ + configuration: { request: 'launch', program: '/x' } as unknown as vscode.DebugConfiguration, + workingDirectory: '/repo' + }), + /configuration\.type is required/ + ); + }); + + test('rejects an invalid request', async () => { + const mocks = makeCapturingMocks({ startResult: true }); + const handler = new DebuggingHandler(mocks.executor, mocks.configManager, 30); + await assert.rejects( + handler.handleStartDebuggingWithConfig({ + configuration: { type: 'node', request: 'connect', program: '/x' } as unknown as vscode.DebugConfiguration, + workingDirectory: '/repo' + }), + /request must be 'launch' or 'attach'/ + ); + }); + + test('surfaces a failed launch', async () => { + const mocks = makeCapturingMocks({ startResult: false }); + const handler = new DebuggingHandler(mocks.executor, mocks.configManager, 30); + await assert.rejects( + handler.handleStartDebuggingWithConfig({ + configuration: { type: 'node', request: 'launch', name: 'x', program: '/x' } as vscode.DebugConfiguration, + workingDirectory: '/repo' + }), + /Failed to start debug session/ + ); + }); +});