Skip to content
Open
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
30 changes: 30 additions & 0 deletions src/debugMCPServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
76 changes: 76 additions & 0 deletions src/debuggingHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { logger } from './utils/logger';
*/
export interface IDebuggingHandler {
handleStartDebugging(args: { fileFullPath: string; workingDirectory: string; testName?: string; configurationName?: string }): Promise<string>;
handleStartDebuggingWithConfig(args: { configuration: vscode.DebugConfiguration; workingDirectory: string }): Promise<string>;
handleStopDebugging(): Promise<string>;
handleStepOver(): Promise<string>;
handleStepInto(): Promise<string>;
Expand Down Expand Up @@ -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<string> {
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 ?? '<none>'} 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
*/
Expand Down
142 changes: 142 additions & 0 deletions src/test/startDebuggingMatrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReadyState>; 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<void>(() => { /* 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<ReadyState>();
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<ReadyState>();
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<ReadyState>();
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/
);
});
});