From e894316147a4181cdcf56518bd0b6b528fb3a559 Mon Sep 17 00:00:00 2001 From: ozzafar <48795672+ozzafar@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:56:38 +0300 Subject: [PATCH 1/6] Add standalone DebugMCP CLI Introduce an explicit DAP adapter registry, stdio MCP host, shared agent configuration, and standalone debugger execution for Python and compiled languages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5e39a3eb-0164-4765-9a88-9d240291ee6f --- README.md | 45 ++ .../architecture/debugConfigurationManager.md | 12 + docs/architecture/debugMCPServer.md | 23 +- docs/architecture/debuggingExecutor.md | 20 +- docs/architecture/debuggingHandler.md | 9 +- esbuild.js | 20 +- package.json | 4 + src/cli/adapterConfig.ts | 96 ++++ src/cli/adapterShorthand.ts | 90 ++++ src/cli/agentSelector.ts | 147 ++++++ src/cli/cliConfigurationManager.ts | 98 ++++ src/cli/cliDebuggingExecutor.ts | 487 ++++++++++++++++++ src/cli/copilotMcpConfig.ts | 137 +++++ src/cli/dapClient.ts | 227 ++++++++ src/cli/main.ts | 288 +++++++++++ src/debugMCPServer.ts | 29 +- src/debugTypes.ts | 24 + src/debuggingExecutor.ts | 68 ++- src/debuggingHandler.ts | 191 ++----- src/extension.ts | 5 +- src/index.ts | 1 + src/test/adapterShorthand.test.ts | 47 ++ src/test/agentSelector.test.ts | 81 +++ src/test/cliAdapterConfig.test.ts | 118 +++++ src/test/cliDapClient.test.ts | 54 ++ src/test/cliDebuggingExecutor.test.ts | 131 +++++ src/test/copilotMcpConfig.test.ts | 80 +++ src/test/gdbInspection.test.ts | 6 + src/test/getVariables.test.ts | 1 + src/test/mixedVariableChildren.test.ts | 1 + src/test/rubyInspection.test.ts | 4 + src/utils/agentCatalog.ts | 115 +++++ src/utils/agentConfigurationManager.ts | 150 +----- src/utils/debugConfigurationManager.ts | 7 +- src/utils/logger.ts | 35 +- 35 files changed, 2517 insertions(+), 334 deletions(-) create mode 100644 src/cli/adapterConfig.ts create mode 100644 src/cli/adapterShorthand.ts create mode 100644 src/cli/agentSelector.ts create mode 100644 src/cli/cliConfigurationManager.ts create mode 100644 src/cli/cliDebuggingExecutor.ts create mode 100644 src/cli/copilotMcpConfig.ts create mode 100644 src/cli/dapClient.ts create mode 100644 src/cli/main.ts create mode 100644 src/debugTypes.ts create mode 100644 src/test/adapterShorthand.test.ts create mode 100644 src/test/agentSelector.test.ts create mode 100644 src/test/cliAdapterConfig.test.ts create mode 100644 src/test/cliDapClient.test.ts create mode 100644 src/test/cliDebuggingExecutor.test.ts create mode 100644 src/test/copilotMcpConfig.test.ts create mode 100644 src/utils/agentCatalog.ts diff --git a/README.md b/README.md index f86d9eb..6f2b141 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,51 @@ DebugMCP follows systematic debugging practices for effective issue resolution: ## Installation +### Standalone CLI (preview) + +The standalone host talks directly to explicitly configured DAP adapters and +does not require VS Code. It never discovers, downloads, installs, or selects an +adapter automatically. + +```console +npm install +npm run package +node dist/debugmcp.js adapter add python --command "python -m debugpy.adapter" +node dist/debugmcp.js adapter validate python +node dist/debugmcp.js configure +``` + +Adapter registrations are stored in `.debugmcp.json` by default. Add `--user` +to `adapter add`, `adapter list`, or `adapter remove` to use the per-user +configuration. Project registrations override registrations with the same name +in user configuration. + +Language shorthands derive the DAP type and file extensions for `python`, +`csharp`, and `cpp`. The command remains explicit so the selected environment +determines which adapter installation is used. Compiled-language registrations +can provide the executable in `--launch`; values support `${workspaceFolder}`, +`${file}`, `${fileDirname}`, and `${fileBasenameNoExtension}`. Languages without +a shorthand must provide `--type` and `--extensions` explicitly. + +The current CLI supports adapters that speak DAP over stdio. A registration can +provide adapter-specific launch properties with `--launch` followed by a JSON +object. If multiple registered adapters claim the same file extension, +`start_debugging.configurationName` must identify the adapter to use. Test +discovery remains host-specific; configure the adapter launch properties to run +the required test command. + +`debugmcp configure` presents the same agent choices as the VS Code extension's +setup popup and writes the standalone stdio command for every selected agent. +In automation, repeat `--agent ` to bypass the terminal prompt, for example +`debugmcp configure --agent copilot-cli --agent codex`. Each configuration has +one canonical `debugmcp` entry, so configuring the CLI replaces an existing +extension HTTP entry rather than registering both. + +Use `debugmcp status` to inspect the GitHub Copilot CLI registration. The +DebugMCP command configures only the standalone CLI. To use the interactive +VS Code version, configure it through the DebugMCP extension's agent popup; +the command line does not select or configure the extension. + ### Quick Install Options **Option 1: Direct Link** (Fastest) diff --git a/docs/architecture/debugConfigurationManager.md b/docs/architecture/debugConfigurationManager.md index f67d873..30c6aa9 100644 --- a/docs/architecture/debugConfigurationManager.md +++ b/docs/architecture/debugConfigurationManager.md @@ -4,6 +4,10 @@ Produces the argument passed to `vscode.debug.startDebugging()` — either a launch.json configuration name or a minimal `DebugConfiguration` stub. +The standalone counterpart, `src/cli/cliConfigurationManager.ts`, resolves an +explicit adapter registration from project or user configuration and produces +the DAP launch/attach arguments. It intentionally has no default adapters. + ## Motivation Earlier versions of this class manually parsed `launch.json`, scored configurations, and assembled fully populated per-language config objects. That duplicated work VS Code and the language debug extensions already do better: @@ -25,6 +29,14 @@ Delegating to those mechanisms keeps this class small and ensures defaults stay ## Key Concepts +### Standalone adapter selection + +The CLI merges user registrations with `.debugmcp.json`, with project entries +overriding user entries of the same name. It selects the sole adapter claiming +the source extension, or the adapter named by `configurationName`. Missing and +ambiguous registrations fail with configuration commands instead of triggering +environment discovery or installation. + ### Return type `getDebugConfig()` returns `string | vscode.DebugConfiguration`. Both forms are accepted by `vscode.debug.startDebugging(folder, nameOrConfiguration)`. diff --git a/docs/architecture/debugMCPServer.md b/docs/architecture/debugMCPServer.md index aeb3362..092408a 100644 --- a/docs/architecture/debugMCPServer.md +++ b/docs/architecture/debugMCPServer.md @@ -2,11 +2,15 @@ ## Purpose -The MCP server component that exposes VS Code debugging capabilities to AI agents via the Model Context Protocol. This is the main entry point for all external AI agent communication. +The MCP server component that exposes debugging capabilities to AI agents via +the Model Context Protocol. It is shared by the VS Code extension and the +standalone CLI. ## Motivation -AI coding agents need a standardized way to control debuggers programmatically. MCP provides this standard, and `DebugMCPServer` implements it using the official `@modelcontextprotocol/sdk` with Streamable HTTP transport over an express HTTP server. +AI coding agents need a standardized way to control debuggers programmatically. +MCP provides this standard, and `DebugMCPServer` implements it using the official +`@modelcontextprotocol/sdk` with Streamable HTTP and stdio transports. ## Responsibility @@ -15,16 +19,17 @@ AI coding agents need a standardized way to control debuggers programmatically. - Register documentation resources for agent guidance - Delegate all debugging operations to `DebuggingHandler` - Manage Streamable HTTP transport via `StreamableHTTPServerTransport` on configurable port (default: 3001) +- Manage `StdioServerTransport` when launched by the standalone CLI ## Architecture Position ``` AI Agent (MCP Client) - │ - ▼ HTTP POST /mcp + │ HTTP or stdio + ▼ ┌───────────────────┐ │ DebugMCPServer │ ◄── You are here -│ (express + HTTP) │ +│ (MCP transport) │ └───────────────────┘ │ ▼ Delegates to @@ -35,6 +40,14 @@ AI Agent (MCP Client) ## Key Concepts +### Standalone CLI + +`src/cli/main.ts` starts this server without loading the VS Code module. It +injects a `CliDebuggingExecutor` and `CliConfigurationManager` for each MCP +session. The CLI can use stdio or the same loopback Streamable HTTP endpoint. +Unlike the extension host, it requires an explicit adapter registration and +does not use window routing. + ### Multi-window routing (multiple VS Code windows / repos) The MCP endpoint uses a fixed port, but every open VS Code window activates the diff --git a/docs/architecture/debuggingExecutor.md b/docs/architecture/debuggingExecutor.md index 50a83c8..123bcde 100644 --- a/docs/architecture/debuggingExecutor.md +++ b/docs/architecture/debuggingExecutor.md @@ -2,7 +2,9 @@ ## Purpose -Low-level wrapper around VS Code's Debug API and Debug Adapter Protocol (DAP). Executes actual debugging commands and retrieves debug state. +Host abstraction for executing debugging commands and retrieving debug state. +The VS Code implementation wraps the editor Debug API, while the standalone CLI +implementation hosts a debug adapter directly over DAP stdio. ## Motivation @@ -31,11 +33,23 @@ VS Code's debug API is powerful but requires careful handling. `DebuggingExecuto │ ▼ Calls ┌───────────────────┐ -│ VS Code Debug API │ -│ (DAP Protocol) │ +│ VS Code Debug API │ +│ or CLI DAP Client │ └───────────────────┘ ``` +### Standalone CLI host + +`src/cli/cliDebuggingExecutor.ts` implements the same executor interface without +VS Code. It starts an explicitly registered adapter, performs the DAP +initialize/launch/configuration sequence, handles adapter events and +`runInTerminal`, and owns session, thread, frame, and breakpoint state. +Step operations wait for a fresh stopped or terminated event. Continue allows a +short stop-event grace period so immediately reached breakpoints are reported, +while still returning promptly for long-running programs. +`src/cli/adapterConfig.ts` loads project and user registrations. No adapter is +registered, discovered, selected, installed, or upgraded implicitly. + ## Key Concepts ### Startup Failure Diagnostics diff --git a/docs/architecture/debuggingHandler.md b/docs/architecture/debuggingHandler.md index f5d55e3..3ef2b77 100644 --- a/docs/architecture/debuggingHandler.md +++ b/docs/architecture/debuggingHandler.md @@ -2,7 +2,9 @@ ## Purpose -High-level orchestration layer that coordinates debugging operations between the MCP server and VS Code's debug API. Handles the asynchronous nature of debugging by implementing state change detection. +Host-neutral orchestration layer that coordinates debugging operations between +the MCP server and an injected executor. The executor may use VS Code's debug +API or host a DAP adapter directly in the standalone CLI. ## Motivation @@ -48,7 +50,10 @@ After executing a debug command (step over, continue, etc.), the handler: ### Exponential Backoff -Polling starts at 1 second intervals and increases exponentially (capped at 10 seconds for session activation, 1 second for state changes). Jitter is added to prevent thundering herd issues. +State-change polling uses short bounded intervals so either executor can expose +new stopped/running state without the handler depending on host-specific event +APIs. Session activation remains delegated to the executor, which can use its +native VS Code or DAP events. ### Meaningful State Changes diff --git a/esbuild.js b/esbuild.js index 3a0fbfc..87bfc22 100644 --- a/esbuild.js +++ b/esbuild.js @@ -30,7 +30,7 @@ const esbuildProblemMatcherPlugin = { }; async function main() { - const ctx = await esbuild.context({ + const extensionCtx = await esbuild.context({ entryPoints: ['src/extension.ts'], bundle: true, format: 'cjs', @@ -43,11 +43,23 @@ async function main() { logLevel: 'silent', plugins: [esbuildProblemMatcherPlugin], }); + const cliCtx = await esbuild.context({ + entryPoints: ['src/cli/main.ts'], + bundle: true, + format: 'cjs', + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: 'node', + outfile: 'dist/debugmcp.js', + logLevel: 'silent', + plugins: [esbuildProblemMatcherPlugin], + }); if (watch) { - await ctx.watch(); + await Promise.all([extensionCtx.watch(), cliCtx.watch()]); } else { - await ctx.rebuild(); - await ctx.dispose(); + await Promise.all([extensionCtx.rebuild(), cliCtx.rebuild()]); + await Promise.all([extensionCtx.dispose(), cliCtx.dispose()]); } } diff --git a/package.json b/package.json index e460776..384de92 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,9 @@ "onDebug" ], "main": "./dist/extension.js", + "bin": { + "debugmcp": "./dist/debugmcp.js" + }, "contributes": { "commands": [ { @@ -119,6 +122,7 @@ "compile": "tsc -p ./", "check-types": "tsc --noEmit -p ./", "bundle": "node esbuild.js", + "cli": "node dist/debugmcp.js", "package": "npm run check-types && node esbuild.js --production", "watch": "tsc -watch -p ./", "pretest": "npm run compile && npm run bundle && npm run lint", diff --git a/src/cli/adapterConfig.ts b/src/cli/adapterConfig.ts new file mode 100644 index 0000000..69936cd --- /dev/null +++ b/src/cli/adapterConfig.ts @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +export interface AdapterRegistration { + command: string; + args?: string[]; + extensions: string[]; + type: string; + transport?: 'stdio'; + launch?: Record; +} + +export interface AdapterConfigFile { + version: 1; + adapters: Record; +} + +const emptyConfig = (): AdapterConfigFile => ({ version: 1, adapters: {} }); + +export function getProjectConfigPath(workingDirectory: string): string { + return path.join(workingDirectory, '.debugmcp.json'); +} + +export function getUserConfigPath(): string { + const configHome = process.platform === 'win32' + ? process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming') + : process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config'); + return path.join(configHome, 'debugmcp', 'config.json'); +} + +export async function readAdapterConfig(filePath: string): Promise { + try { + const content = await fs.promises.readFile(filePath, 'utf8'); + const parsed: unknown = JSON.parse(content); + if (!parsed || typeof parsed !== 'object' || (parsed as AdapterConfigFile).version !== 1) { + throw new Error('expected an object with version: 1'); + } + const adapters = (parsed as AdapterConfigFile).adapters; + if (!adapters || typeof adapters !== 'object' || Array.isArray(adapters)) { + throw new Error('expected an adapters object'); + } + for (const [name, adapter] of Object.entries(adapters)) { + validateAdapter(name, adapter); + } + return parsed as AdapterConfigFile; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return emptyConfig(); + } + throw new Error(`Invalid DebugMCP adapter configuration at ${filePath}: ${error}`); + } +} + +export async function writeAdapterConfig(filePath: string, config: AdapterConfigFile): Promise { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + const temporaryPath = `${filePath}.${process.pid}.tmp`; + await fs.promises.writeFile(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); + await fs.promises.rename(temporaryPath, filePath); +} + +export async function loadAdapters( + workingDirectory: string +): Promise> { + const user = await readAdapterConfig(getUserConfigPath()); + const project = await readAdapterConfig(getProjectConfigPath(workingDirectory)); + return { ...user.adapters, ...project.adapters }; +} + +export function validateAdapter(name: string, adapter: AdapterRegistration): void { + if (!name.trim()) { + throw new Error('adapter name cannot be empty'); + } + if (!adapter || typeof adapter !== 'object') { + throw new Error(`adapter '${name}' must be an object`); + } + if (typeof adapter.command !== 'string' || !adapter.command.trim()) { + throw new Error(`adapter '${name}' requires a non-empty command`); + } + if (!Array.isArray(adapter.extensions) || + adapter.extensions.length === 0 || + adapter.extensions.some(extension => typeof extension !== 'string' || !extension.startsWith('.'))) { + throw new Error(`adapter '${name}' requires extensions such as [".py"]`); + } + if (typeof adapter.type !== 'string' || !adapter.type.trim()) { + throw new Error(`adapter '${name}' requires a DAP type`); + } + if (adapter.transport && adapter.transport !== 'stdio') { + throw new Error(`adapter '${name}' uses unsupported transport '${adapter.transport}'; only stdio is supported`); + } + if (adapter.args && (!Array.isArray(adapter.args) || adapter.args.some(arg => typeof arg !== 'string'))) { + throw new Error(`adapter '${name}' args must be an array of strings`); + } +} diff --git a/src/cli/adapterShorthand.ts b/src/cli/adapterShorthand.ts new file mode 100644 index 0000000..b527d95 --- /dev/null +++ b/src/cli/adapterShorthand.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. + +import { AdapterRegistration } from './adapterConfig'; + +interface AdapterLanguage { + type: string; + extensions: string[]; +} + +const adapterLanguages: Record = { + python: { + type: 'python', + extensions: ['.py'] + }, + csharp: { + type: 'coreclr', + extensions: ['.cs'] + }, + cpp: { + type: 'cppvsdbg', + extensions: ['.cpp', '.cc', '.cxx', '.c', '.h', '.hpp'] + } +}; + +export function createShorthandAdapter( + language: string, + commandValues: string[] +): AdapterRegistration { + const metadata = adapterLanguages[language.toLowerCase()]; + if (!metadata) { + throw new Error( + `No adapter shorthand is defined for '${language}'. ` + + 'Provide --type and --extensions explicitly.' + ); + } + + const commandLine = commandValues.length === 1 + ? splitCommandLine(commandValues[0]) + : commandValues; + const [command, ...args] = commandLine; + if (!command) { + throw new Error('adapter add requires --command'); + } + + return { + command, + args, + type: metadata.type, + extensions: [...metadata.extensions], + transport: 'stdio' + }; +} + +export function splitCommandLine(value: string): string[] { + const parts: string[] = []; + let current = ''; + let quote: '"' | "'" | undefined; + + for (let index = 0; index < value.length; index++) { + const character = value[index]; + if (quote) { + if (character === quote) { + quote = undefined; + } else if (quote === '"' && + character === '\\' && + (value[index + 1] === '"' || value[index + 1] === '\\')) { + current += value[++index]; + } else { + current += character; + } + } else if (character === '"' || character === "'") { + quote = character; + } else if (/\s/.test(character)) { + if (current) { + parts.push(current); + current = ''; + } + } else { + current += character; + } + } + + if (quote) { + throw new Error('Adapter command contains an unterminated quote.'); + } + if (current) { + parts.push(current); + } + return parts; +} diff --git a/src/cli/agentSelector.ts b/src/cli/agentSelector.ts new file mode 100644 index 0000000..39a6c46 --- /dev/null +++ b/src/cli/agentSelector.ts @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { createInterface } from 'node:readline/promises'; +import { stdin, stdout } from 'node:process'; +import { AgentInfo, getSupportedAgents } from '../utils/agentCatalog'; +import { selectCopilotDebugMcpHost } from './copilotMcpConfig'; + +export async function selectAgentsInteractively(): Promise { + const agents = getSupportedAgents(); + if (!stdin.isTTY || !stdout.isTTY) { + throw new Error( + 'Agent selection requires an interactive terminal. ' + + 'Use one or more --agent options in non-interactive environments.' + ); + } + stdout.write('Choose AI agents to configure with the standalone DebugMCP CLI:\n\n'); + agents.forEach((agent, index) => { + stdout.write(` ${index + 1}. ${agent.displayName} (${agent.id})\n`); + }); + const prompt = createInterface({ input: stdin, output: stdout }); + try { + const answer = await prompt.question('\nEnter numbers or IDs separated by commas: '); + return resolveAgentSelections(answer.split(',').map(value => value.trim()), agents); + } finally { + prompt.close(); + } +} + +export function resolveAgentSelections( + selections: string[], + agents = getSupportedAgents() +): AgentInfo[] { + const selected = selections + .filter(Boolean) + .map(selection => { + const number = Number(selection); + const agent = Number.isInteger(number) && number >= 1 && number <= agents.length + ? agents[number - 1] + : agents.find(candidate => candidate.id === selection); + if (!agent) { + throw new Error( + `Unknown agent '${selection}'. Valid IDs: ${agents.map(candidate => candidate.id).join(', ')}` + ); + } + return agent; + }); + if (selected.length === 0) { + throw new Error('Select at least one agent.'); + } + return [...new Map(selected.map(agent => [agent.id, agent])).values()]; +} + +export async function configureCliForAgent( + agent: AgentInfo, + command: string, + args: string[] +): Promise { + if (agent.id === 'copilot-cli') { + await selectCopilotDebugMcpHost(agent.configPath, { + type: 'stdio', + command, + args, + tools: ['*'] + }); + return; + } + if (agent.configFormat === 'json') { + await upsertJsonStdioServer(agent, command, args); + return; + } + await upsertCodexStdioServer(agent.configPath, command, args); +} + +async function upsertJsonStdioServer( + agent: Extract, + command: string, + args: string[] +): Promise { + let config: Record = {}; + try { + config = JSON.parse(await fs.promises.readFile(agent.configPath, 'utf8')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error(`Cannot read ${agent.configPath}: ${error}`); + } + } + if (!config[agent.mcpServerFieldName] || + typeof config[agent.mcpServerFieldName] !== 'object' || + Array.isArray(config[agent.mcpServerFieldName])) { + config[agent.mcpServerFieldName] = {}; + } + config[agent.mcpServerFieldName].debugmcp = { + type: 'stdio', + command, + args + }; + await writeAtomic(agent.configPath, `${JSON.stringify(config, null, 2)}\n`); +} + +async function upsertCodexStdioServer( + configPath: string, + command: string, + args: string[] +): Promise { + let content = ''; + try { + content = await fs.promises.readFile(configPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error(`Cannot read ${configPath}: ${error}`); + } + } + const normalized = content.replace(/\r\n/g, '\n'); + const lines = normalized.split('\n'); + const header = '[mcp_servers.debugmcp]'; + const start = lines.findIndex(line => line.trim() === header); + const escapedCommand = escapeToml(command); + const renderedArgs = args.map(arg => `"${escapeToml(arg)}"`).join(', '); + const fields = [`command = "${escapedCommand}"`, `args = [${renderedArgs}]`]; + if (start < 0) { + const separator = normalized.length === 0 ? '' : normalized.endsWith('\n') ? '\n' : '\n\n'; + content = `${normalized}${separator}${header}\n${fields.join('\n')}\n`; + } else { + let end = start + 1; + while (end < lines.length && !/^\s*\[/.test(lines[end])) { + end++; + } + const retained = lines.slice(start + 1, end) + .filter(line => !/^\s*(?:url|command|args)\s*=/.test(line)); + lines.splice(start + 1, end - start - 1, ...fields, ...retained); + content = lines.join('\n'); + } + await writeAtomic(configPath, content); +} + +async function writeAtomic(filePath: string, content: string): Promise { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + const temporaryPath = `${filePath}.${process.pid}.tmp`; + await fs.promises.writeFile(temporaryPath, content, 'utf8'); + await fs.promises.rename(temporaryPath, filePath); +} + +function escapeToml(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} diff --git a/src/cli/cliConfigurationManager.ts b/src/cli/cliConfigurationManager.ts new file mode 100644 index 0000000..4248b0b --- /dev/null +++ b/src/cli/cliConfigurationManager.ts @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. + +import * as path from 'node:path'; +import { DebugConfiguration } from '../debugTypes'; +import { IDebugConfigurationManager } from '../utils/debugConfigurationManager'; +import { AdapterRegistration, loadAdapters } from './adapterConfig'; + +export interface CliDebugConfiguration extends DebugConfiguration { + adapterName: string; + adapter: AdapterRegistration; + request: 'launch' | 'attach'; +} + +export class CliConfigurationManager implements IDebugConfigurationManager { + public async getDebugConfig( + workingDirectory: string, + fileFullPath: string, + configurationName?: string + ): Promise { + const adapters = await loadAdapters(workingDirectory); + const extension = path.extname(fileFullPath).toLowerCase(); + const matches = Object.entries(adapters).filter(([name, adapter]) => + configurationName + ? name === configurationName + : adapter.extensions.map(item => item.toLowerCase()).includes(extension)); + + if (matches.length === 0) { + const selector = configurationName + ? `named '${configurationName}'` + : `for '${extension || fileFullPath}' files`; + throw new Error( + `No debug adapter is configured ${selector}. ` + + `Run "debugmcp adapter add --command --type ` + + `--extensions ${extension || '.ext'}" in ${workingDirectory}.` + ); + } + if (matches.length > 1) { + throw new Error( + `Multiple debug adapters match '${extension}': ${matches.map(([name]) => name).join(', ')}. ` + + 'Pass configurationName with the adapter name.' + ); + } + + const [adapterName, adapter] = matches[0]; + const launch = expandLaunchConfiguration( + adapter.launch ?? {}, + workingDirectory, + fileFullPath + ); + return { + ...launch, + name: adapterName, + type: adapter.type, + request: (launch.request === 'attach' ? 'attach' : 'launch'), + program: typeof launch.program === 'string' ? launch.program : fileFullPath, + cwd: typeof launch.cwd === 'string' ? launch.cwd : workingDirectory, + adapterName, + adapter + }; + } + + public detectLanguageFromFilePath(fileFullPath: string): string { + return path.extname(fileFullPath).toLowerCase(); + } +} + +function expandLaunchConfiguration( + launch: Record, + workingDirectory: string, + fileFullPath: string +): Record { + const replacements: Record = { + '${workspaceFolder}': workingDirectory, + '${file}': fileFullPath, + '${fileDirname}': path.dirname(fileFullPath), + '${fileBasenameNoExtension}': path.basename(fileFullPath, path.extname(fileFullPath)) + }; + + const expand = (value: unknown): unknown => { + if (typeof value === 'string') { + return Object.entries(replacements).reduce( + (result, [token, replacement]) => result.replaceAll(token, replacement), + value + ); + } + if (Array.isArray(value)) { + return value.map(expand); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, expand(child)]) + ); + } + return value; + }; + + return expand(launch) as Record; +} diff --git a/src/cli/cliDebuggingExecutor.ts b/src/cli/cliDebuggingExecutor.ts new file mode 100644 index 0000000..e943814 --- /dev/null +++ b/src/cli/cliDebuggingExecutor.ts @@ -0,0 +1,487 @@ +// Copyright (c) Microsoft Corporation. + +import { EventEmitter } from 'node:events'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { DebugState, StackFrame } from '../debugState'; +import { DebugBreakpoint, DebugConfiguration, DebugSessionInfo } from '../debugTypes'; +import { + IDebuggingExecutor, + TestDebugDispatch, + VariableChildrenOptions +} from '../debuggingExecutor'; +import { CliDebugConfiguration } from './cliConfigurationManager'; +import { DapClient } from './dapClient'; + +type SessionState = 'none' | 'starting' | 'running' | 'stopped' | 'terminated'; +type ReadyState = 'stopped' | 'terminated' | 'timeout' | 'no-session' | 'attached'; + +export class CliDebuggingExecutor implements IDebuggingExecutor { + private readonly events = new EventEmitter(); + private readonly breakpoints: DebugBreakpoint[] = []; + private client?: DapClient; + private state: SessionState = 'none'; + private session?: DebugSessionInfo; + private threadId?: number; + private frameId?: number; + private capabilities: Record = {}; + private initialized = false; + + public async startDebugging( + workingDirectory: string, + config: string | DebugConfiguration + ): Promise { + if (typeof config === 'string') { + throw new Error( + `The standalone CLI cannot resolve configuration '${config}'. ` + + 'Configure an adapter with that name instead.' + ); + } + const cliConfig = config as CliDebugConfiguration; + if (!cliConfig.adapter?.command) { + throw new Error('The selected CLI debug configuration has no adapter command.'); + } + if (this.client && this.state !== 'terminated') { + throw new Error('A debug session is already active. Stop it before starting another.'); + } + + this.state = 'starting'; + this.threadId = undefined; + this.frameId = undefined; + this.initialized = false; + this.session = { + id: randomUUID(), + name: cliConfig.name ?? cliConfig.adapterName, + type: cliConfig.type ?? cliConfig.adapter.type, + request: cliConfig.request + }; + this.emitState(); + + const client = new DapClient( + cliConfig.adapter.command, + cliConfig.adapter.args ?? [], + workingDirectory + ); + this.client = client; + this.registerClientEvents(client); + + try { + const initializedEvent = client.waitForEvent('initialized', 30_000); + this.capabilities = await client.request('initialize', { + clientID: 'debugmcp', + clientName: 'DebugMCP CLI', + adapterID: cliConfig.type ?? cliConfig.adapter.type, + pathFormat: 'path', + linesStartAt1: true, + columnsStartAt1: true, + supportsRunInTerminalRequest: true, + supportsVariableType: true, + supportsVariablePaging: true + }); + + const { adapter: _adapter, adapterName: _adapterName, ...launchArguments } = cliConfig; + const launch = client.request(cliConfig.request, launchArguments); + await initializedEvent; + this.initialized = true; + await this.syncAllBreakpoints(); + if (this.capabilities.supportsConfigurationDoneRequest === true) { + await client.request('configurationDone'); + } + await launch; + if (this.state === 'starting') { + this.state = 'running'; + this.emitState(); + } + return true; + } catch (error) { + await client.close(); + this.state = 'terminated'; + this.emitState(); + throw new Error(`Failed to start standalone debug session: ${error}`); + } + } + + public async debugTestAtCursor(_fileFullPath: string, testName: string): Promise { + throw new Error( + `Standalone test discovery is not available for '${testName}'. ` + + 'Register an adapter whose launch configuration starts the required test runner.' + ); + } + + public async stopDebugging(): Promise { + const client = this.requireClient(); + try { + await client.request('disconnect', { + restart: false, + terminateDebuggee: true + }); + } finally { + await client.close(); + this.client = undefined; + this.state = 'terminated'; + this.threadId = undefined; + this.frameId = undefined; + this.emitState(); + } + } + + public async stepOver(): Promise { + await this.runThreadCommand('next'); + } + + public async stepInto(): Promise { + await this.runThreadCommand('stepIn'); + } + + public async stepOut(): Promise { + await this.runThreadCommand('stepOut'); + } + + public async continue(): Promise { + await this.runThreadCommand('continue'); + } + + public async pause(): Promise { + await this.requireClient().request('pause', { threadId: await this.resolveThreadId() }); + } + + public async restart(): Promise { + if (!this.capabilities.supportsRestartRequest) { + throw new Error('The configured debug adapter does not support the DAP restart request.'); + } + await this.requireClient().request('restart'); + } + + public async addBreakpoint( + fileFullPath: string, + line: number, + condition?: string, + logMessage?: string + ): Promise { + if (!this.breakpoints.some(item => item.fileFullPath === fileFullPath && item.line === line)) { + this.breakpoints.push({ fileFullPath, line, condition, logMessage }); + } + if (this.initialized) { + await this.syncBreakpoints(fileFullPath); + } + } + + public async removeBreakpoint(fileFullPath: string, line: number): Promise { + const index = this.breakpoints.findIndex( + item => item.fileFullPath === fileFullPath && item.line === line); + if (index >= 0) { + this.breakpoints.splice(index, 1); + } + if (this.initialized) { + await this.syncBreakpoints(fileFullPath); + } + } + + public getBreakpoints(): readonly DebugBreakpoint[] { + return this.breakpoints; + } + + public async clearAllBreakpoints(): Promise { + const files = [...new Set(this.breakpoints.map(item => item.fileFullPath))]; + this.breakpoints.length = 0; + if (this.initialized) { + await Promise.all(files.map(file => this.syncBreakpoints(file))); + } + } + + public async getCurrentDebugState(numNextLines = 3): Promise { + const result = new DebugState(); + result.sessionActive = this.state !== 'none' && this.state !== 'terminated'; + result.updateConfigurationName(this.session?.name ?? null); + result.updateBreakpoints(this.breakpoints.map(item => { + const suffix = item.condition ? ` [when: ${item.condition}]` : ''; + return `${path.basename(item.fileFullPath)}:${item.line}${suffix}`; + })); + + if (!result.sessionActive || this.state !== 'stopped' || this.threadId === undefined) { + return result; + } + + const response = await this.requireClient().request('stackTrace', { + threadId: this.threadId, + startFrame: 0, + levels: 50 + }); + const frames = Array.isArray(response?.stackFrames) ? response.stackFrames : []; + if (frames.length === 0) { + return result; + } + const current = frames[0]; + this.frameId = current.id; + result.updateContext(current.id, this.threadId); + result.updateFrameName(current.name ?? null); + result.updateStackTrace(frames.map((frame: any): StackFrame => ({ + name: frame.name ?? 'unknown', + source: frame.source?.path ?? frame.source?.name, + line: frame.line, + column: frame.column + }))); + + if (typeof current.source?.path === 'string' && typeof current.line === 'number') { + await this.populateSource(result, current.source.path, current.line, numNextLines); + } + return result; + } + + public async getVariables( + frameId: number, + scope: 'local' | 'global' | 'all' = 'all' + ): Promise { + const client = this.requireClient(); + const response = await client.request('scopes', { frameId }); + const scopes = (response?.scopes ?? []).filter((item: any) => { + if (scope === 'all') { + return true; + } + return String(item.name).toLowerCase().includes(scope); + }); + for (const item of scopes) { + try { + const variables = await client.request('variables', { + variablesReference: item.variablesReference + }); + item.variables = variables?.variables ?? []; + } catch (error) { + item.variables = []; + item.error = error; + } + } + return { scopes }; + } + + public async getVariableChildren( + variablesReference: number, + options: VariableChildrenOptions = {} + ): Promise { + const client = this.requireClient(); + if (options.indexedVariables && options.indexedVariables > 0) { + const indexed = await client.request('variables', { + variablesReference, + filter: 'indexed', + start: 0, + count: options.indexedVariables + }); + const named = await client.request('variables', { + variablesReference, + filter: 'named' + }); + return [...(indexed?.variables ?? []), ...(named?.variables ?? [])]; + } + const response = await client.request('variables', { variablesReference }); + return response?.variables ?? []; + } + + public async evaluateExpression(expression: string, frameId: number): Promise { + return this.requireClient().request('evaluate', { + expression, + frameId, + context: 'repl' + }); + } + + public async hasActiveSession(): Promise { + return this.state !== 'none' && this.state !== 'terminated'; + } + + public getActiveSession(): DebugSessionInfo | undefined { + return this.session; + } + + public getActiveFrameId(): number | undefined { + return this.state === 'stopped' ? this.frameId : undefined; + } + + public async waitForDebugSessionReady( + timeoutMs: number, + signal?: AbortSignal + ): Promise { + const immediate = this.readyState(); + if (immediate) { + return immediate; + } + return new Promise(resolve => { + const finish = (value: ReadyState) => { + clearTimeout(timer); + this.events.off('state', onState); + signal?.removeEventListener('abort', onAbort); + resolve(value); + }; + const onState = () => { + const ready = this.readyState(); + if (ready) { + finish(ready); + } + }; + const onAbort = () => finish('no-session'); + const timer = setTimeout( + () => finish(this.state === 'none' ? 'no-session' : 'timeout'), + timeoutMs + ); + this.events.on('state', onState); + signal?.addEventListener('abort', onAbort, { once: true }); + }); + } + + private registerClientEvents(client: DapClient): void { + client.on('stopped', body => { + this.threadId = typeof body.threadId === 'number' ? body.threadId : this.threadId; + this.frameId = undefined; + this.state = 'stopped'; + this.emitState(); + }); + client.on('continued', () => { + this.frameId = undefined; + this.state = 'running'; + this.emitState(); + }); + const terminated = () => { + this.state = 'terminated'; + this.threadId = undefined; + this.frameId = undefined; + this.emitState(); + }; + client.on('terminated', terminated); + client.on('exited', terminated); + client.on('exit', terminated); + client.on('thread', body => { + if (body.reason === 'started' && typeof body.threadId === 'number' && this.threadId === undefined) { + this.threadId = body.threadId; + } + }); + } + + private readyState(): ReadyState | undefined { + if (this.state === 'stopped') { + return 'stopped'; + } + if (this.state === 'terminated') { + return 'terminated'; + } + if (this.session?.request === 'attach' && this.state === 'running') { + return 'attached'; + } + return undefined; + } + + private emitState(): void { + this.events.emit('state'); + } + + private requireClient(): DapClient { + if (!this.client || this.state === 'none' || this.state === 'terminated') { + throw new Error('No active standalone debug session.'); + } + return this.client; + } + + private async resolveThreadId(): Promise { + if (this.threadId !== undefined) { + return this.threadId; + } + const response = await this.requireClient().request('threads'); + const firstThread = Array.isArray(response?.threads) ? response.threads[0] : undefined; + if (typeof firstThread?.id !== 'number') { + throw new Error('The debug adapter has not reported an active thread.'); + } + this.threadId = firstThread.id; + return firstThread.id; + } + + private async runThreadCommand(command: 'next' | 'stepIn' | 'stepOut' | 'continue'): Promise { + const threadId = await this.resolveThreadId(); + const previousState = this.state; + this.state = 'running'; + this.frameId = undefined; + this.emitState(); + const settle = this.waitForNavigationSettle(command === 'continue' ? 500 : 30_000); + try { + await this.requireClient().request(command, { threadId }); + } catch (error) { + settle.cancel(); + this.state = previousState; + this.emitState(); + throw error; + } + await settle.promise; + } + + private waitForNavigationSettle(timeoutMs: number): { + promise: Promise; + cancel: () => void; + } { + let finish: () => void = () => {}; + const promise = new Promise(resolve => { + const onState = () => { + if (this.state === 'stopped' || this.state === 'terminated') { + finish(); + } + }; + const timer = setTimeout(() => finish(), timeoutMs); + finish = () => { + clearTimeout(timer); + this.events.off('state', onState); + resolve(); + }; + this.events.on('state', onState); + }); + return { promise, cancel: () => finish() }; + } + + private async syncAllBreakpoints(): Promise { + const files = [...new Set(this.breakpoints.map(item => item.fileFullPath))]; + for (const file of files) { + await this.syncBreakpoints(file); + } + } + + private async syncBreakpoints(fileFullPath: string): Promise { + const sourceBreakpoints = this.breakpoints.filter(item => item.fileFullPath === fileFullPath); + const response = await this.requireClient().request('setBreakpoints', { + source: { path: fileFullPath, name: path.basename(fileFullPath) }, + breakpoints: sourceBreakpoints.map(item => ({ + line: item.line, + condition: item.condition, + logMessage: item.logMessage + })), + sourceModified: false + }); + const resolved = response?.breakpoints ?? []; + sourceBreakpoints.forEach((item, index) => { + item.verified = resolved[index]?.verified; + item.message = resolved[index]?.message; + }); + } + + private async populateSource( + state: DebugState, + fileFullPath: string, + line: number, + numNextLines: number + ): Promise { + try { + const content = await fs.promises.readFile(fileFullPath, 'utf8'); + const lines = content.split(/\r?\n/); + const index = Math.max(0, Math.min(line - 1, lines.length - 1)); + const nextLines = lines + .slice(index + 1) + .map(value => value.trim()) + .filter(Boolean) + .slice(0, numNextLines); + state.updateLocation( + fileFullPath, + path.basename(fileFullPath), + line, + lines[index]?.trim() ?? '', + nextLines + ); + } catch { + // Adapter-provided source paths may refer to unavailable library sources. + } + } +} diff --git a/src/cli/copilotMcpConfig.ts b/src/cli/copilotMcpConfig.ts new file mode 100644 index 0000000..de8b901 --- /dev/null +++ b/src/cli/copilotMcpConfig.ts @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +export interface CopilotStdioMcpServer { + type: 'stdio'; + command: string; + args: string[]; + tools: string[]; +} + +export interface CopilotHttpMcpServer { + type: 'http'; + url: string; + tools: string[]; +} + +export type CopilotDebugMcpServer = CopilotStdioMcpServer | CopilotHttpMcpServer; + +interface CopilotMcpConfig { + mcpServers?: Record; + [key: string]: unknown; +} + +export function getCopilotMcpConfigPath(): string { + const copilotHome = process.env.COPILOT_HOME || path.join(os.homedir(), '.copilot'); + return path.join(copilotHome, 'mcp-config.json'); +} + +export async function selectCopilotDebugMcpHost( + configPath: string, + server: CopilotDebugMcpServer +): Promise { + validateServer(server); + let config: CopilotMcpConfig = {}; + try { + const content = await fs.promises.readFile(configPath, 'utf8'); + config = JSON.parse(content) as CopilotMcpConfig; + if (!config || typeof config !== 'object' || Array.isArray(config)) { + throw new Error('root value must be an object'); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error(`Cannot update Copilot MCP configuration at ${configPath}: ${error}`); + } + } + + if (!config.mcpServers || typeof config.mcpServers !== 'object' || + Array.isArray(config.mcpServers)) { + config.mcpServers = {}; + } + + // One canonical name is the exclusivity mechanism: selecting either host + // replaces the complete entry, so URL and command transports cannot coexist. + config.mcpServers.debugmcp = server; + await writeJsonAtomic(configPath, config); + await enableCopilotDebugMcpServer(path.join(path.dirname(configPath), 'settings.json')); +} + +export async function isCopilotDebugMcpServerDisabled(configPath: string): Promise { + const settingsPath = path.join(path.dirname(configPath), 'settings.json'); + try { + const settings = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as { + disabledMcpServers?: unknown; + }; + return Array.isArray(settings.disabledMcpServers) && + settings.disabledMcpServers.includes('debugmcp'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw new Error(`Cannot read Copilot settings at ${settingsPath}: ${error}`); + } +} + +async function enableCopilotDebugMcpServer(settingsPath: string): Promise { + let settings: Record; + try { + settings = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as Record; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + throw new Error(`Cannot update Copilot settings at ${settingsPath}: ${error}`); + } + if (!Array.isArray(settings.disabledMcpServers) || + !settings.disabledMcpServers.includes('debugmcp')) { + return; + } + settings.disabledMcpServers = settings.disabledMcpServers.filter( + name => name !== 'debugmcp' + ); + await writeJsonAtomic(settingsPath, settings); +} + +async function writeJsonAtomic(filePath: string, value: unknown): Promise { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + const temporaryPath = `${filePath}.${process.pid}.tmp`; + await fs.promises.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + await fs.promises.rename(temporaryPath, filePath); +} + +export async function readCopilotDebugMcpHost( + configPath: string +): Promise { + try { + const config = JSON.parse(await fs.promises.readFile(configPath, 'utf8')) as CopilotMcpConfig; + const server = config.mcpServers?.debugmcp as CopilotDebugMcpServer | undefined; + if (server) { + validateServer(server); + } + return server; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + throw new Error(`Cannot read Copilot MCP configuration at ${configPath}: ${error}`); + } +} + +function validateServer(server: CopilotDebugMcpServer): void { + if (server.type === 'stdio') { + if (!server.command || !Array.isArray(server.args) || 'url' in server) { + throw new Error('A CLI DebugMCP registration requires command/args and cannot contain url.'); + } + return; + } + if (server.type === 'http') { + if (!server.url || 'command' in server || 'args' in server) { + throw new Error('A VS Code DebugMCP registration requires url and cannot contain command/args.'); + } + return; + } + throw new Error(`Unsupported DebugMCP MCP transport '${(server as { type?: string }).type}'.`); +} diff --git a/src/cli/dapClient.ts b/src/cli/dapClient.ts new file mode 100644 index 0000000..63ccc81 --- /dev/null +++ b/src/cli/dapClient.ts @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. + +import { EventEmitter } from 'node:events'; +import { ChildProcess, spawn } from 'node:child_process'; +import { logger } from '../utils/logger'; + +interface DapProtocolMessage { + seq: number; + type: 'request' | 'response' | 'event'; + command?: string; + event?: string; + request_seq?: number; + success?: boolean; + message?: string; + arguments?: Record; + body?: any; +} + +interface PendingRequest { + resolve: (body: any) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; +} + +export class DapClient extends EventEmitter { + private sequence = 1; + private buffer = Buffer.alloc(0); + private readonly pending = new Map(); + private readonly debuggees = new Set(); + private readonly adapter: ChildProcess; + + constructor( + command: string, + args: string[], + cwd: string, + private readonly requestTimeoutMs = 30_000 + ) { + super(); + this.adapter = spawn(command, args, { + cwd, + env: process.env, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }); + this.adapter.stdout?.on('data', chunk => this.acceptData(chunk)); + this.adapter.stderr?.on('data', chunk => + logger.warn(`debug adapter: ${String(chunk).trimEnd()}`)); + this.adapter.on('error', error => this.failAll( + new Error(`Failed to start debug adapter '${command}': ${error.message}`))); + this.adapter.on('exit', (code, signal) => { + const detail = signal ? `signal ${signal}` : `exit code ${code ?? 'unknown'}`; + this.failAll(new Error(`Debug adapter exited with ${detail}`)); + this.emit('exit', { code, signal }); + }); + } + + public async request(command: string, args: Record = {}): Promise { + const seq = this.sequence++; + const response = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(seq); + reject(new Error(`Debug adapter did not respond to '${command}' within ${this.requestTimeoutMs / 1000}s.`)); + }, this.requestTimeoutMs); + this.pending.set(seq, { resolve, reject, timer }); + }); + this.send({ seq, type: 'request', command, arguments: args }); + return response; + } + + public waitForEvent( + event: string, + timeoutMs: number, + signal?: AbortSignal + ): Promise { + return new Promise((resolve, reject) => { + const finish = (error?: Error, body?: any) => { + clearTimeout(timer); + this.off(event, onEvent); + signal?.removeEventListener('abort', onAbort); + error ? reject(error) : resolve(body); + }; + const onEvent = (body: any) => finish(undefined, body); + const onAbort = () => finish(new Error(`Waiting for DAP event '${event}' was cancelled.`)); + const timer = setTimeout( + () => finish(new Error(`Debug adapter did not send '${event}' within ${timeoutMs / 1000}s.`)), + timeoutMs + ); + this.once(event, onEvent); + signal?.addEventListener('abort', onAbort, { once: true }); + }); + } + + public dispose(): void { + for (const debuggee of this.debuggees) { + if (!debuggee.killed) { + debuggee.kill(); + } + } + this.debuggees.clear(); + if (!this.adapter.killed) { + this.adapter.kill(); + } + this.failAll(new Error('Debug adapter connection closed.')); + } + + public async close(timeoutMs = 2_000): Promise { + if (this.adapter.exitCode !== null || this.adapter.signalCode !== null) { + return; + } + const exited = new Promise(resolve => this.adapter.once('exit', () => resolve())); + this.dispose(); + await Promise.race([ + exited, + new Promise(resolve => setTimeout(resolve, timeoutMs)) + ]); + } + + private acceptData(chunk: Buffer): void { + this.buffer = Buffer.concat([this.buffer, chunk]); + while (true) { + const headerEnd = this.buffer.indexOf('\r\n\r\n'); + if (headerEnd < 0) { + return; + } + const header = this.buffer.subarray(0, headerEnd).toString('ascii'); + const lengthMatch = header.match(/(?:^|\r\n)Content-Length:\s*(\d+)/i); + if (!lengthMatch) { + this.failAll(new Error('Debug adapter sent a DAP message without Content-Length.')); + return; + } + const length = Number.parseInt(lengthMatch[1], 10); + const bodyStart = headerEnd + 4; + if (this.buffer.length < bodyStart + length) { + return; + } + const payload = this.buffer.subarray(bodyStart, bodyStart + length).toString('utf8'); + this.buffer = this.buffer.subarray(bodyStart + length); + try { + this.handleMessage(JSON.parse(payload) as DapProtocolMessage); + } catch (error) { + this.failAll(new Error(`Debug adapter sent invalid JSON: ${error}`)); + } + } + } + + private handleMessage(message: DapProtocolMessage): void { + if (message.type === 'response' && message.request_seq !== undefined) { + const pending = this.pending.get(message.request_seq); + if (!pending) { + return; + } + clearTimeout(pending.timer); + this.pending.delete(message.request_seq); + if (message.success === false) { + pending.reject(new Error(message.message || `DAP request '${message.command}' failed.`)); + } else { + pending.resolve(message.body); + } + return; + } + if (message.type === 'event' && message.event) { + this.emit(message.event, message.body ?? {}); + return; + } + if (message.type === 'request' && message.command) { + void this.handleReverseRequest(message); + } + } + + private async handleReverseRequest(request: DapProtocolMessage): Promise { + try { + if (request.command !== 'runInTerminal') { + throw new Error(`Unsupported debug adapter request '${request.command}'.`); + } + const args = request.arguments?.args; + if (!Array.isArray(args) || args.length === 0 || args.some(arg => typeof arg !== 'string')) { + throw new Error('runInTerminal did not provide a valid argument array.'); + } + const env = request.arguments?.env; + const child = spawn(args[0], args.slice(1), { + cwd: typeof request.arguments?.cwd === 'string' ? request.arguments.cwd : undefined, + env: env && typeof env === 'object' + ? { ...process.env, ...(env as Record) } + : process.env, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + }); + this.debuggees.add(child); + child.once('exit', () => this.debuggees.delete(child)); + child.stdout?.on('data', chunk => logger.info(`debuggee: ${String(chunk).trimEnd()}`)); + child.stderr?.on('data', chunk => logger.warn(`debuggee: ${String(chunk).trimEnd()}`)); + this.send({ + seq: this.sequence++, + type: 'response', + request_seq: request.seq, + command: request.command, + success: true, + body: { processId: child.pid } + }); + } catch (error) { + this.send({ + seq: this.sequence++, + type: 'response', + request_seq: request.seq, + command: request.command, + success: false, + message: error instanceof Error ? error.message : String(error) + }); + } + } + + private send(message: DapProtocolMessage): void { + const payload = Buffer.from(JSON.stringify(message), 'utf8'); + this.adapter.stdin?.write(`Content-Length: ${payload.length}\r\n\r\n`); + this.adapter.stdin?.write(payload); + } + + private failAll(error: Error): void { + for (const request of this.pending.values()) { + clearTimeout(request.timer); + request.reject(error); + } + this.pending.clear(); + } +} diff --git a/src/cli/main.ts b/src/cli/main.ts new file mode 100644 index 0000000..09d9026 --- /dev/null +++ b/src/cli/main.ts @@ -0,0 +1,288 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. + +import { DebugMCPServer } from '../debugMCPServer'; +import * as path from 'node:path'; +import { DebuggingHandler } from '../debuggingHandler'; +import { logger } from '../utils/logger'; +import { + AdapterRegistration, + getProjectConfigPath, + getUserConfigPath, + loadAdapters, + readAdapterConfig, + validateAdapter, + writeAdapterConfig +} from './adapterConfig'; +import { CliConfigurationManager } from './cliConfigurationManager'; +import { CliDebuggingExecutor } from './cliDebuggingExecutor'; +import { DapClient } from './dapClient'; +import { + getCopilotMcpConfigPath, + isCopilotDebugMcpServerDisabled, + readCopilotDebugMcpHost, +} from './copilotMcpConfig'; +import { + configureCliForAgent, + resolveAgentSelections, + selectAgentsInteractively +} from './agentSelector'; +import { getSupportedAgents } from '../utils/agentCatalog'; +import { createShorthandAdapter } from './adapterShorthand'; + +interface ParsedOptions { + values: Record; + positionals: string[]; +} + +function parseOptions(args: string[]): ParsedOptions { + const values: Record = {}; + const positionals: string[] = []; + let current: string | undefined; + for (const arg of args) { + if (arg.startsWith('--')) { + current = arg.slice(2); + values[current] ??= []; + } else if (current) { + values[current].push(arg); + } else { + positionals.push(arg); + } + } + return { values, positionals }; +} + +function first(options: ParsedOptions, name: string): string | undefined { + return options.values[name]?.[0]; +} + +function flag(options: ParsedOptions, name: string): boolean { + return Object.hasOwn(options.values, name); +} + +function printHelp(): void { + process.stdout.write( + 'DebugMCP standalone CLI\n\n' + + 'Usage:\n' + + ' debugmcp serve [--stdio] [--port 3001] [--timeout 300]\n' + + ' debugmcp adapter add python --command "python -m debugpy.adapter" [--user]\n' + + ' debugmcp adapter add --command --type --extensions <.ext...> [--args ] [--user]\n' + + ' debugmcp adapter list [--user]\n' + + ' debugmcp adapter validate \n' + + ' debugmcp adapter remove [--user]\n\n' + + ' debugmcp configure [--agent ...]\n' + + ' debugmcp status\n\n' + + 'No debug adapter is configured, selected, downloaded, or installed automatically.\n' + ); +} + +async function printStatus(): Promise { + const configPath = getCopilotMcpConfigPath(); + const current = await readCopilotDebugMcpHost(configPath); + if (!current) { + process.stdout.write(`DebugMCP is not registered in ${configPath}\n`); + } else if (current.type === 'stdio') { + const disabled = await isCopilotDebugMcpServerDisabled(configPath); + process.stdout.write( + `Mode: standalone CLI\nEnabled: ${!disabled}\nTransport: stdio\n` + + `Command: ${current.command} ${current.args.join(' ')}\n` + ); + } else { + process.stdout.write( + `Mode: VS Code extension\nTransport: http\nURL: ${current.url}\n` + + 'Use the DebugMCP extension to manage this registration.\n' + ); + } +} + +async function configureAgents(args: string[]): Promise { + const options = parseOptions(args); + const requestedAgents = options.values.agent ?? []; + const agents = requestedAgents.length > 0 + ? resolveAgentSelections(requestedAgents, getSupportedAgents()) + : await selectAgentsInteractively(); + const command = process.execPath; + const serverArgs = [path.resolve(process.argv[1]), 'serve', '--stdio']; + for (const agent of agents) { + await configureCliForAgent(agent, command, serverArgs); + process.stdout.write( + `Configured ${agent.displayName} for the standalone DebugMCP CLI in ${agent.configPath}.\n` + ); + } + process.stdout.write('Restart the selected agents to load DebugMCP.\n'); +} + +async function runAdapterCommand(args: string[]): Promise { + const action = args[0]; + const options = parseOptions(args.slice(1)); + const name = options.positionals[0]; + const cwd = process.cwd(); + const configPath = flag(options, 'user') ? getUserConfigPath() : getProjectConfigPath(cwd); + + if (action === 'list') { + const adapters = flag(options, 'user') + ? (await readAdapterConfig(getUserConfigPath())).adapters + : await loadAdapters(cwd); + if (Object.keys(adapters).length === 0) { + process.stdout.write('No debug adapters configured.\n'); + return; + } + for (const [adapterName, adapter] of Object.entries(adapters)) { + process.stdout.write( + `${adapterName}\t${adapter.type}\t${adapter.command} ${(adapter.args ?? []).join(' ')}\t${adapter.extensions.join(',')}\n` + ); + } + return; + } + + if (!name) { + throw new Error(`adapter ${action ?? ''} requires an adapter name`); + } + + if (action === 'add') { + const commandValues = options.values.command ?? []; + const type = first(options, 'type'); + const extensions = options.values.extensions ?? []; + const usesExplicitMetadata = Boolean(type || extensions.length > 0); + if (commandValues.length === 0) { + throw new Error('adapter add requires --command'); + } + if (usesExplicitMetadata && (!type || extensions.length === 0)) { + throw new Error( + 'explicit adapter registration requires both --type and --extensions' + ); + } + const launchText = first(options, 'launch'); + let launch: Record | undefined; + if (launchText) { + const parsed: unknown = JSON.parse(launchText); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('--launch must be a JSON object'); + } + launch = parsed as Record; + } + const adapter: AdapterRegistration = usesExplicitMetadata + ? { + command: commandValues[0], + args: options.values.args ?? [], + type: type!, + extensions: extensions.flatMap(value => value.split(',')).map(value => + value.startsWith('.') ? value : `.${value}`), + transport: 'stdio', + launch + } + : { + ...createShorthandAdapter(name, commandValues), + ...(launch ? { launch } : {}) + }; + validateAdapter(name, adapter); + const config = await readAdapterConfig(configPath); + config.adapters[name] = adapter; + await writeAdapterConfig(configPath, config); + process.stdout.write(`Configured adapter '${name}' in ${configPath}\n`); + return; + } + + if (action === 'remove') { + const config = await readAdapterConfig(configPath); + if (!config.adapters[name]) { + throw new Error(`adapter '${name}' is not configured in ${configPath}`); + } + delete config.adapters[name]; + await writeAdapterConfig(configPath, config); + process.stdout.write(`Removed adapter '${name}' from ${configPath}\n`); + return; + } + + if (action === 'validate') { + const adapters = await loadAdapters(cwd); + const adapter = adapters[name]; + if (!adapter) { + throw new Error(`adapter '${name}' is not configured`); + } + await validateAdapterProcess(name, adapter, cwd); + process.stdout.write(`Adapter '${name}' started and completed the DAP initialize handshake.\n`); + return; + } + + throw new Error(`Unknown adapter action '${action ?? ''}'.`); +} + +async function validateAdapterProcess( + name: string, + adapter: AdapterRegistration, + cwd: string +): Promise { + validateAdapter(name, adapter); + const client = new DapClient(adapter.command, adapter.args ?? [], cwd, 10_000); + try { + await client.request('initialize', { + clientID: 'debugmcp-validator', + adapterID: adapter.type, + pathFormat: 'path', + linesStartAt1: true, + columnsStartAt1: true + }); + } finally { + await client.close(); + } +} + +async function runServer(args: string[]): Promise { + const options = parseOptions(args); + const timeout = Number(first(options, 'timeout') ?? '300'); + const port = Number(first(options, 'port') ?? '3001'); + if (!Number.isFinite(timeout) || timeout <= 0) { + throw new Error('--timeout must be a positive number'); + } + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('--port must be an integer from 1 to 65535'); + } + + const createHandler = () => new DebuggingHandler( + new CliDebuggingExecutor(), + new CliConfigurationManager(), + timeout + ); + const server = new DebugMCPServer(port, timeout, ['127.0.0.1', '::1'], createHandler); + await server.initialize(); + if (flag(options, 'stdio')) { + await server.startStdio(); + return; + } + const started = await server.start(); + if (!started) { + throw new Error(`port ${port} is already in use`); + } + process.stdout.write(`DebugMCP CLI listening at ${server.getEndpoint()}\n`); +} + +async function main(): Promise { + const [command, ...args] = process.argv.slice(2); + if (!command || command === 'help' || command === '--help' || command === '-h') { + printHelp(); + return; + } + if (command === 'adapter') { + await runAdapterCommand(args); + return; + } + if (command === 'serve') { + await runServer(args); + return; + } + if (command === 'configure') { + await configureAgents(args); + return; + } + if (command === 'status') { + await printStatus(); + return; + } + throw new Error(`Unknown command '${command}'. Run "debugmcp help".`); +} + +void main().catch(error => { + logger.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/src/debugMCPServer.ts b/src/debugMCPServer.ts index 253c4f8..b22cc40 100644 --- a/src/debugMCPServer.ts +++ b/src/debugMCPServer.ts @@ -1,19 +1,14 @@ // Copyright (c) Microsoft Corporation. -import * as vscode from 'vscode'; import { z } from 'zod'; import * as http from 'http'; import { randomUUID } from 'node:crypto'; -import { - DebuggingExecutor, - ConfigurationManager, - DebuggingHandler, - IDebuggingHandler -} from '.'; +import { IDebuggingHandler } from './debuggingHandler'; import { logger } from './utils/logger'; import { withTimeout } from './utils/withTimeout'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; /** @@ -126,11 +121,11 @@ export class DebugMCPServer { if (handlerFactory) { this.handlerFactory = handlerFactory; } else { - // Default (single-window) behaviour: debug in this very window. - const executor = new DebuggingExecutor(); - const configManager = new ConfigurationManager(); - const handler = new DebuggingHandler(executor, configManager, timeoutInSeconds); - this.handlerFactory = () => handler; + this.handlerFactory = () => new Proxy({}, { + get: () => async () => { + throw new Error('No debugging host was configured for this DebugMCP server.'); + } + }) as IDebuggingHandler; } this.port = port; this.hosts = Array.isArray(host) ? host : [host]; @@ -206,7 +201,7 @@ export class DebugMCPServer { private setupTools(server: McpServer, debuggingHandler: IDebuggingHandler) { // Start debugging tool server.registerTool('start_debugging', { - description: 'Start a VS Code debug session for a source file or for a single test method. ' + + description: 'Start a debug session for a source file or for a single test method. ' + 'Invoke the "debug-live" skill first.', inputSchema: { fileFullPath: z.string().describe('Full path to the source code file to debug'), @@ -217,7 +212,7 @@ export class DebugMCPServer { 'Leave empty to debug the entire file or test class.' ), configurationName: z.string().optional().describe( - 'Optional debug configuration name from launch.json. ' + + 'Optional debug configuration name. ' + 'If omitted, DebugMCP uses its default generated configuration.' ), }, @@ -342,6 +337,12 @@ export class DebugMCPServer { this.runTool('get_debug_status', () => debuggingHandler.handleGetDebugStatus(args))); } + public async startStdio(): Promise { + const server = this.createMcpServer(); + await server.connect(new StdioServerTransport()); + logger.info('DebugMCP CLI listening on stdio'); + } + /** * Check if the server is already running */ diff --git a/src/debugTypes.ts b/src/debugTypes.ts new file mode 100644 index 0000000..588feba --- /dev/null +++ b/src/debugTypes.ts @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. + +export interface DebugConfiguration { + name?: string; + type?: string; + request?: string; + [key: string]: unknown; +} + +export interface DebugBreakpoint { + fileFullPath: string; + line: number; + condition?: string; + logMessage?: string; + verified?: boolean; + message?: string; +} + +export interface DebugSessionInfo { + id: string; + name: string; + type: string; + request?: 'launch' | 'attach'; +} diff --git a/src/debuggingExecutor.ts b/src/debuggingExecutor.ts index c499067..64d784b 100644 --- a/src/debuggingExecutor.ts +++ b/src/debuggingExecutor.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { DebugState, StackFrame } from './debugState'; +import { DebugBreakpoint, DebugConfiguration, DebugSessionInfo } from './debugTypes'; import { logger } from './utils/logger'; import { withTimeout } from './utils/withTimeout'; import { getDebugStartupContext, startDebuggingWithDiagnostics } from './utils/debugStartup'; @@ -29,25 +30,26 @@ export interface VariableChildrenOptions { } export interface IDebuggingExecutor { - startDebugging(workingDirectory: string, config: string | vscode.DebugConfiguration): Promise; + startDebugging(workingDirectory: string, config: string | DebugConfiguration): Promise; debugTestAtCursor(fileFullPath: string, testName: string): Promise; - stopDebugging(session?: vscode.DebugSession): Promise; + stopDebugging(session?: DebugSessionInfo): Promise; stepOver(): Promise; stepInto(): Promise; stepOut(): Promise; continue(): Promise; pause(): Promise; restart(): Promise; - addBreakpoint(uri: vscode.Uri, line: number, condition?: string, logMessage?: string): Promise; - removeBreakpoint(uri: vscode.Uri, line: number): Promise; + addBreakpoint(fileFullPath: string, line: number, condition?: string, logMessage?: string): Promise; + removeBreakpoint(fileFullPath: string, line: number): Promise; getCurrentDebugState(numNextLines: number): Promise; getVariables(frameId: number, scope?: 'local' | 'global' | 'all'): Promise; getVariableChildren(variablesReference: number, options?: VariableChildrenOptions): Promise; evaluateExpression(expression: string, frameId: number): Promise; - getBreakpoints(): readonly vscode.Breakpoint[]; - clearAllBreakpoints(): void; + getBreakpoints(): readonly DebugBreakpoint[]; + clearAllBreakpoints(): Promise | void; hasActiveSession(): Promise; - getActiveSession(): vscode.DebugSession | undefined; + getActiveSession(): DebugSessionInfo | undefined; + getActiveFrameId?(): number | undefined; waitForDebugSessionReady(timeoutMs: number, signal?: AbortSignal): Promise<'stopped' | 'terminated' | 'timeout' | 'no-session' | 'attached'>; } @@ -84,13 +86,16 @@ export class DebuggingExecutor implements IDebuggingExecutor { */ public async startDebugging( workingDirectory: string, - config: string | vscode.DebugConfiguration + config: string | DebugConfiguration ): Promise { try { const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(workingDirectory)); return await startDebuggingWithDiagnostics( - () => vscode.debug.startDebugging(workspaceFolder, config), - getDebugStartupContext(config, workspaceFolder) + () => vscode.debug.startDebugging( + workspaceFolder, + config as string | vscode.DebugConfiguration + ), + getDebugStartupContext(config as string | vscode.DebugConfiguration, workspaceFolder) ); } catch (error) { throw new Error(`Failed to start debugging: ${error}`); @@ -254,9 +259,12 @@ export class DebuggingExecutor implements IDebuggingExecutor { /** * Stop the debugging session */ - public async stopDebugging(session?: vscode.DebugSession): Promise { + public async stopDebugging(session?: DebugSessionInfo): Promise { try { - const activeSession = session || vscode.debug.activeDebugSession; + const currentSession = vscode.debug.activeDebugSession; + const activeSession = !session || currentSession?.id === session.id + ? currentSession + : undefined; if (activeSession) { await vscode.debug.stopDebugging(activeSession); } @@ -339,8 +347,9 @@ export class DebuggingExecutor implements IDebuggingExecutor { * evaluates to true. An optional logMessage makes it a logpoint that logs * the message (with {expressions} interpolated) instead of pausing. */ - public async addBreakpoint(uri: vscode.Uri, line: number, condition?: string, logMessage?: string): Promise { + public async addBreakpoint(fileFullPath: string, line: number, condition?: string, logMessage?: string): Promise { try { + const uri = vscode.Uri.file(fileFullPath); const breakpoint = new vscode.SourceBreakpoint( new vscode.Location(uri, new vscode.Position(line - 1, 0)), true, @@ -357,8 +366,9 @@ export class DebuggingExecutor implements IDebuggingExecutor { /** * Remove a breakpoint from specified location */ - public async removeBreakpoint(uri: vscode.Uri, line: number): Promise { + public async removeBreakpoint(fileFullPath: string, line: number): Promise { try { + const uri = vscode.Uri.file(fileFullPath); const breakpoints = vscode.debug.breakpoints.filter(bp => { if (bp instanceof vscode.SourceBreakpoint) { return bp.location.uri.toString() === uri.toString() && @@ -730,14 +740,22 @@ export class DebuggingExecutor implements IDebuggingExecutor { /** * Get all active breakpoints */ - public getBreakpoints(): readonly vscode.Breakpoint[] { - return vscode.debug.breakpoints; + public getBreakpoints(): readonly DebugBreakpoint[] { + return vscode.debug.breakpoints + .filter((breakpoint): breakpoint is vscode.SourceBreakpoint => + breakpoint instanceof vscode.SourceBreakpoint) + .map(breakpoint => ({ + fileFullPath: breakpoint.location.uri.fsPath, + line: breakpoint.location.range.start.line + 1, + condition: breakpoint.condition, + logMessage: breakpoint.logMessage + })); } /** * Clear all breakpoints */ - public clearAllBreakpoints(): void { + public async clearAllBreakpoints(): Promise { const breakpoints = vscode.debug.breakpoints; if (breakpoints.length > 0) { vscode.debug.removeBreakpoints(breakpoints); @@ -754,8 +772,20 @@ export class DebuggingExecutor implements IDebuggingExecutor { /** * Get the active debug session */ - public getActiveSession(): vscode.DebugSession | undefined { - return vscode.debug.activeDebugSession; + public getActiveSession(): DebugSessionInfo | undefined { + const session = vscode.debug.activeDebugSession; + return session ? { + id: session.id, + name: session.name, + type: session.type, + request: session.configuration.request === 'attach' ? 'attach' : + session.configuration.request === 'launch' ? 'launch' : undefined + } : undefined; + } + + public getActiveFrameId(): number | undefined { + const item = vscode.debug.activeStackItem; + return item && 'frameId' in item ? item.frameId : undefined; } /** diff --git a/src/debuggingHandler.ts b/src/debuggingHandler.ts index 363c084..4d301d4 100644 --- a/src/debuggingHandler.ts +++ b/src/debuggingHandler.ts @@ -1,6 +1,6 @@ // Copyright (c) Microsoft Corporation. -import * as vscode from 'vscode'; +import * as fs from 'node:fs'; import { DebugConfigurationManager, IDebugConfigurationManager } from './utils/debugConfigurationManager'; import { DebugState } from './debugState'; import { IDebuggingExecutor } from './debuggingExecutor'; @@ -186,7 +186,7 @@ export class DebuggingHandler implements IDebuggingHandler { return 'No breakpoints to clear'; } - this.executor.clearAllBreakpoints(); + await this.executor.clearAllBreakpoints(); return `Successfully cleared ${breakpointCount} breakpoint(s)`; } catch (error) { throw new Error(`Error clearing breakpoints: ${error}`); @@ -345,47 +345,13 @@ export class DebuggingHandler implements IDebuggingHandler { * it resolves with the current state rather than rejecting. */ private async waitForPause(timeoutMs: number): Promise { - const subscriptions: vscode.Disposable[] = []; - try { - await new Promise(resolve => { - let settled = false; - const settle = (reason: string) => { - if (settled) { - return; - } - settled = true; - logger.info(`waitForPause: settled on ${reason}`); - clearTimeout(timer); - resolve(); - }; - - const timer = setTimeout(() => settle('timeout (still running)'), timeoutMs); - - // Subscribe before the fast-path check so a stop landing during - // that async check cannot slip through unobserved. - subscriptions.push( - vscode.debug.onDidChangeActiveStackItem(stackItem => { - if (stackItem && 'frameId' in stackItem) { - settle('breakpoint hit'); - } - }) - ); - subscriptions.push( - vscode.debug.onDidTerminateDebugSession(() => { - if (!vscode.debug.activeDebugSession) { - settle('session terminated'); - } - }) - ); - - void this.executor.getCurrentDebugState(this.numNextLines).then(currentState => { - if (!currentState.sessionActive || currentState.hasLocationInfo()) { - settle('fast path'); - } - }); - }); - } finally { - subscriptions.forEach(d => d.dispose()); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = await this.executor.getCurrentDebugState(this.numNextLines); + if (!state.sessionActive || state.hasLocationInfo()) { + return state; + } + await new Promise(resolve => setTimeout(resolve, Math.min(100, deadline - Date.now()))); } return this.executor.getCurrentDebugState(this.numNextLines); } @@ -433,13 +399,12 @@ export class DebuggingHandler implements IDebuggingHandler { // Validate the line exists so we fail clearly instead of setting an // unbound breakpoint past the end of the file. - const document = await vscode.workspace.openTextDocument(vscode.Uri.file(fileFullPath)); - if (line > document.lineCount) { - throw new Error(`Line ${line} is out of range: ${fileFullPath} has ${document.lineCount} lines.`); + const lineCount = await this.getFileLineCount(fileFullPath); + if (line > lineCount) { + throw new Error(`Line ${line} is out of range: ${fileFullPath} has ${lineCount} lines.`); } - const uri = vscode.Uri.file(fileFullPath); - await this.executor.addBreakpoint(uri, line, condition); + await this.executor.addBreakpoint(fileFullPath, line, condition); const conditionInfo = condition ? ` (condition: ${condition})` : ''; return `Breakpoint added at ${fileFullPath}:${line}${conditionInfo}${await this.sessionCaveat()}`; @@ -489,13 +454,12 @@ export class DebuggingHandler implements IDebuggingHandler { // Validate the line exists so we fail clearly instead of setting an // unbound logpoint past the end of the file. - const document = await vscode.workspace.openTextDocument(vscode.Uri.file(fileFullPath)); - if (line > document.lineCount) { - throw new Error(`Line ${line} is out of range: ${fileFullPath} has ${document.lineCount} lines.`); + const lineCount = await this.getFileLineCount(fileFullPath); + if (line > lineCount) { + throw new Error(`Line ${line} is out of range: ${fileFullPath} has ${lineCount} lines.`); } - const uri = vscode.Uri.file(fileFullPath); - await this.executor.addBreakpoint(uri, line, condition, logMessage); + await this.executor.addBreakpoint(fileFullPath, line, condition, logMessage); const conditionInfo = condition ? ` (condition: ${condition})` : ''; return `Logpoint added at ${fileFullPath}:${line}${conditionInfo}${await this.sessionCaveat()}`; @@ -511,23 +475,16 @@ export class DebuggingHandler implements IDebuggingHandler { const { fileFullPath, line } = args; try { - const uri = vscode.Uri.file(fileFullPath); - // Check if breakpoint exists at this location const breakpoints = this.executor.getBreakpoints(); - const existingBreakpoint = breakpoints.find(bp => { - if (bp instanceof vscode.SourceBreakpoint) { - return bp.location.uri.toString() === uri.toString() && - bp.location.range.start.line === line - 1; - } - return false; - }); + const existingBreakpoint = breakpoints.find(bp => + bp.fileFullPath === fileFullPath && bp.line === line); if (!existingBreakpoint) { return `No breakpoint found at ${fileFullPath}:${line}`; } - await this.executor.removeBreakpoint(uri, line); + await this.executor.removeBreakpoint(fileFullPath, line); return `Breakpoint removed from ${fileFullPath}:${line}`; } catch (error) { throw new Error(`Error removing breakpoint: ${error}`); @@ -547,16 +504,11 @@ export class DebuggingHandler implements IDebuggingHandler { let breakpointList = 'Active Breakpoints:\n'; breakpoints.forEach((bp, index) => { - if (bp instanceof vscode.SourceBreakpoint) { - const fileName = bp.location.uri.fsPath.split(/[/\\]/).pop(); - const line = bp.location.range.start.line + 1; - const conditionInfo = bp.condition ? ` (condition: ${bp.condition})` : ''; - const kind = bp.logMessage ? `Logpoint` : `Breakpoint`; - const logInfo = bp.logMessage ? ` (log: ${bp.logMessage})` : ''; - breakpointList += `${index + 1}. ${kind} ${fileName}:${line}${conditionInfo}${logInfo}\n`; - } else if (bp instanceof vscode.FunctionBreakpoint) { - breakpointList += `${index + 1}. Function: ${bp.functionName}\n`; - } + const fileName = bp.fileFullPath.split(/[/\\]/).pop(); + const conditionInfo = bp.condition ? ` (condition: ${bp.condition})` : ''; + const kind = bp.logMessage ? 'Logpoint' : 'Breakpoint'; + const logInfo = bp.logMessage ? ` (log: ${bp.logMessage})` : ''; + breakpointList += `${index + 1}. ${kind} ${fileName}:${bp.line}${conditionInfo}${logInfo}\n`; }); return breakpointList; @@ -582,12 +534,12 @@ export class DebuggingHandler implements IDebuggingHandler { throw new Error('Debug session is not ready. Start debugging first and ensure execution is paused.'); } - const activeStackItem = vscode.debug.activeStackItem; - if (!activeStackItem || !('frameId' in activeStackItem)) { + const frameId = this.executor.getActiveFrameId?.(); + if (frameId === undefined) { throw new Error('No active stack frame. Make sure execution is paused at a breakpoint.'); } - return activeStackItem.frameId; + return frameId; } /** @@ -838,12 +790,12 @@ export class DebuggingHandler implements IDebuggingHandler { throw new Error('Debug session is not ready. Start debugging first and ensure execution is paused.'); } - const activeStackItem = vscode.debug.activeStackItem; - if (!activeStackItem || !('frameId' in activeStackItem)) { + const frameId = this.executor.getActiveFrameId?.(); + if (frameId === undefined) { throw new Error('No active stack frame. Make sure execution is paused at a breakpoint.'); } - const response = await this.executor.evaluateExpression(expression, activeStackItem.frameId); + const response = await this.executor.evaluateExpression(expression, frameId); if (response && response.result !== undefined) { let resultText = `Expression: ${expression}\n`; @@ -1042,82 +994,29 @@ export class DebuggingHandler implements IDebuggingHandler { */ private async waitForStateChange(beforeState: DebugState, settleOnResume = false): Promise { const timeoutMs = this.timeoutInSeconds * 1000; - const subscriptions: vscode.Disposable[] = []; const operatingSession = this.executor.getActiveSession(); - let operatingSessionTerminated = false; - - try { - await new Promise(resolve => { - let settled = false; - const settle = (reason: string) => { - if (settled) { - return; - } - settled = true; - logger.info(`waitForStateChange: settled on ${reason}`); - clearTimeout(timer); - resolve(); - }; - - const timer = setTimeout(() => { - logger.info('State change detection timed out, returning current state'); - settle('timeout'); - }, timeoutMs); - - // Register listeners BEFORE the fast-path check so a stop that - // lands during that async check can't slip through unobserved. - subscriptions.push( - vscode.debug.onDidChangeActiveStackItem(stackItem => { - // A newly focused stack frame is the signal that the - // step/continue has landed at its next stop. - if (stackItem && 'frameId' in stackItem) { - settle('new stack frame'); - } else if (settleOnResume && !stackItem) { - // Continue only: the active stack item being cleared - // means the program resumed. That IS the terminal - // state for a continue against a process that keeps - // running (a server, an event loop) and will never - // stop again on its own. - settle('program resumed'); - } - }) - ); - subscriptions.push( - vscode.debug.onDidTerminateDebugSession(session => { - // continue/step that runs the program to completion. - if (operatingSession && session.id === operatingSession.id) { - operatingSessionTerminated = true; - settle('session terminated'); - } else if (!vscode.debug.activeDebugSession) { - settle('no active session'); - } - }) - ); - - // Fast path: the step/continue may already have landed by the - // time we subscribed (e.g. a trivial single-line step), or the - // program may already be running again after a continue. - void this.executor.getCurrentDebugState(this.numNextLines).then(currentState => { - const resumed = settleOnResume && currentState.sessionActive && !currentState.hasLocationInfo(); - if (this.hasStateChanged(beforeState, currentState) || !currentState.sessionActive || resumed) { - settle('fast path'); - } - }); - }); - } finally { - subscriptions.forEach(d => d.dispose()); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const currentState = await this.executor.getCurrentDebugState(this.numNextLines); + const resumed = settleOnResume && currentState.sessionActive && !currentState.hasLocationInfo(); + if (this.hasStateChanged(beforeState, currentState) || !currentState.sessionActive || resumed) { + return currentState; + } + await new Promise(resolve => setTimeout(resolve, Math.min(50, deadline - Date.now()))); } const afterState = await this.executor.getCurrentDebugState(this.numNextLines); - // The operating session ended (program ran to completion). A lingering - // parent session (e.g. the JS debug terminal) can leave a different - // session reported as active, so reflect termination explicitly here. - if (operatingSessionTerminated) { + if (operatingSession && this.executor.getActiveSession()?.id !== operatingSession.id) { afterState.sessionActive = false; } return afterState; } + private async getFileLineCount(fileFullPath: string): Promise { + const content = await fs.promises.readFile(fileFullPath, 'utf8'); + return content.length === 0 ? 0 : content.split(/\r?\n/).length; + } + /** * Determine if the debugger state has meaningfully changed */ diff --git a/src/extension.ts b/src/extension.ts index cfb1f3a..3c7ea6a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -23,9 +23,12 @@ const HEARTBEAT_INTERVAL_MS = 15_000; const ROUTER_RETRY_INTERVAL_MS = 5_000; export async function activate(context: vscode.ExtensionContext) { + const outputChannel = vscode.window.createOutputChannel('DebugMCP', { log: true }); + context.subscriptions.push(outputChannel); + logger.setSink(outputChannel); // Initialize logging first logger.info('DebugMCP extension is now active!'); - logger.logSystemInfo(); + logger.logSystemInfo(`VS Code ${vscode.version}`); logger.logEnvironment(); const config = vscode.workspace.getConfiguration('debugmcp'); diff --git a/src/index.ts b/src/index.ts index 3e93f6d..9a8abc0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ // Export all debugging-related classes and interfaces export { DebugState } from './debugState'; +export { DebugBreakpoint, DebugConfiguration, DebugSessionInfo } from './debugTypes'; export { DebuggingExecutor, IDebuggingExecutor } from './debuggingExecutor'; export { DebugConfigurationManager as ConfigurationManager, IDebugConfigurationManager as IConfigurationManager } from './utils/debugConfigurationManager'; export { DebuggingHandler, IDebuggingHandler } from './debuggingHandler'; diff --git a/src/test/adapterShorthand.test.ts b/src/test/adapterShorthand.test.ts new file mode 100644 index 0000000..c0025ae --- /dev/null +++ b/src/test/adapterShorthand.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'node:assert'; +import { createShorthandAdapter, splitCommandLine } from '../cli/adapterShorthand'; + +suite('CLI adapter shorthand', () => { + test('derives Python metadata and separates the adapter command', () => { + assert.deepStrictEqual( + createShorthandAdapter('python', ['python -m debugpy.adapter']), + { + command: 'python', + args: ['-m', 'debugpy.adapter'], + type: 'python', + extensions: ['.py'], + transport: 'stdio' + } + ); + }); + + test('supports quoted executable paths', () => { + assert.deepStrictEqual( + splitCommandLine('"C:\\Program Files\\Python\\python.exe" -m debugpy.adapter'), + ['C:\\Program Files\\Python\\python.exe', '-m', 'debugpy.adapter'] + ); + }); + + test('derives compiled-language metadata', () => { + assert.deepStrictEqual( + createShorthandAdapter('csharp', ['vsdbg --interpreter=vscode']), + { + command: 'vsdbg', + args: ['--interpreter=vscode'], + type: 'coreclr', + extensions: ['.cs'], + transport: 'stdio' + } + ); + assert.strictEqual(createShorthandAdapter('cpp', ['OpenDebugAD7.exe']).type, 'cppvsdbg'); + }); + + test('requires explicit metadata for an unknown language', () => { + assert.throws( + () => createShorthandAdapter('custom', ['custom-dap']), + /Provide --type and --extensions explicitly/ + ); + }); +}); diff --git a/src/test/agentSelector.test.ts b/src/test/agentSelector.test.ts new file mode 100644 index 0000000..0373ab3 --- /dev/null +++ b/src/test/agentSelector.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + configureCliForAgent, + resolveAgentSelections +} from '../cli/agentSelector'; +import { AgentInfo, getSupportedAgents } from '../utils/agentCatalog'; + +suite('CLI agent selector', () => { + let directory: string; + + setup(async () => { + directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'debugmcp-selector-')); + }); + + teardown(async () => { + await fs.promises.rm(directory, { recursive: true, force: true }); + }); + + test('uses the same agent catalog as the extension popup', () => { + const agents = getSupportedAgents(); + assert.deepStrictEqual( + agents.map(agent => agent.id), + ['cline', 'roo', 'copilot', 'copilot-cli', 'cursor', 'antigravity', 'claude-code', 'codex'] + ); + assert.deepStrictEqual( + resolveAgentSelections(['4', 'codex'], agents).map(agent => agent.id), + ['copilot-cli', 'codex'] + ); + }); + + test('writes an exclusive stdio entry for a selected JSON agent', async () => { + const agent: AgentInfo = { + id: 'test', + name: 'test', + displayName: 'Test Agent', + configPath: path.join(directory, 'mcp.json'), + configFormat: 'json', + mcpServerFieldName: 'mcpServers' + }; + await fs.promises.writeFile(agent.configPath, JSON.stringify({ + mcpServers: { + debugmcp: { type: 'http', url: 'http://localhost:3001/mcp' }, + other: { command: 'other' } + } + }), 'utf8'); + + await configureCliForAgent(agent, 'node', ['debugmcp.js', 'serve', '--stdio']); + const config = JSON.parse(await fs.promises.readFile(agent.configPath, 'utf8')); + assert.deepStrictEqual(config.mcpServers.debugmcp, { + type: 'stdio', + command: 'node', + args: ['debugmcp.js', 'serve', '--stdio'] + }); + assert.deepStrictEqual(config.mcpServers.other, { command: 'other' }); + }); + + test('replaces a Codex URL with command and args', async () => { + const configPath = path.join(directory, 'config.toml'); + await fs.promises.writeFile(configPath, + '[mcp_servers.debugmcp]\nurl = "http://localhost:3001/mcp"\n\n[other]\nvalue = true\n', + 'utf8' + ); + await configureCliForAgent({ + id: 'codex', + name: 'codex', + displayName: 'Codex', + configPath, + configFormat: 'toml' + }, 'node', ['debugmcp.js', 'serve', '--stdio']); + const content = await fs.promises.readFile(configPath, 'utf8'); + assert.match(content, /command = "node"/); + assert.match(content, /args = \["debugmcp.js", "serve", "--stdio"\]/); + assert.doesNotMatch(content, /url\s*=/); + assert.match(content, /\[other\]\nvalue = true/); + }); +}); diff --git a/src/test/cliAdapterConfig.test.ts b/src/test/cliAdapterConfig.test.ts new file mode 100644 index 0000000..66726e5 --- /dev/null +++ b/src/test/cliAdapterConfig.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { CliConfigurationManager } from '../cli/cliConfigurationManager'; +import { + AdapterConfigFile, + getProjectConfigPath, + loadAdapters, + writeAdapterConfig +} from '../cli/adapterConfig'; + +suite('CLI adapter configuration', () => { + let workspace: string; + let originalAppData: string | undefined; + let appData: string; + + setup(async () => { + workspace = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'debugmcp-cli-project-')); + appData = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'debugmcp-cli-user-')); + originalAppData = process.env.APPDATA; + process.env.APPDATA = appData; + }); + + teardown(async () => { + if (originalAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = originalAppData; + } + await fs.promises.rm(workspace, { recursive: true, force: true }); + await fs.promises.rm(appData, { recursive: true, force: true }); + }); + + test('starts with no configured adapters', async () => { + assert.deepStrictEqual(await loadAdapters(workspace), {}); + await assert.rejects( + () => new CliConfigurationManager().getDebugConfig( + workspace, + path.join(workspace, 'app.py') + ), + /No debug adapter is configured/ + ); + }); + + test('resolves an explicitly registered project adapter', async () => { + const config: AdapterConfigFile = { + version: 1, + adapters: { + python: { + command: 'python', + args: ['-m', 'debugpy.adapter'], + type: 'python', + extensions: ['.py'] + } + } + }; + await writeAdapterConfig(getProjectConfigPath(workspace), config); + + const resolved = await new CliConfigurationManager().getDebugConfig( + workspace, + path.join(workspace, 'app.py') + ); + assert.strictEqual(resolved.adapterName, 'python'); + assert.strictEqual(resolved.adapter.command, 'python'); + assert.strictEqual(resolved.program, path.join(workspace, 'app.py')); + }); + + test('expands a compiled adapter launch target', async () => { + await writeAdapterConfig(getProjectConfigPath(workspace), { + version: 1, + adapters: { + csharp: { + command: 'vsdbg', + type: 'coreclr', + extensions: ['.cs'], + launch: { + program: '${workspaceFolder}\\bin\\Calculator.exe', + sourceFileMap: { + '/source': '${fileDirname}' + } + } + } + } + }); + + const source = path.join(workspace, 'Calculator.cs'); + const resolved = await new CliConfigurationManager().getDebugConfig(workspace, source); + assert.strictEqual(resolved.program, path.join(workspace, 'bin', 'Calculator.exe')); + assert.deepStrictEqual(resolved.sourceFileMap, { + '/source': workspace + }); + }); + + test('requires a name when multiple adapters claim an extension', async () => { + await writeAdapterConfig(getProjectConfigPath(workspace), { + version: 1, + adapters: { + pythonA: { command: 'python-a', type: 'python', extensions: ['.py'] }, + pythonB: { command: 'python-b', type: 'python', extensions: ['.py'] } + } + }); + + const manager = new CliConfigurationManager(); + await assert.rejects( + () => manager.getDebugConfig(workspace, path.join(workspace, 'app.py')), + /Multiple debug adapters match/ + ); + const resolved = await manager.getDebugConfig( + workspace, + path.join(workspace, 'app.py'), + 'pythonB' + ); + assert.strictEqual(resolved.adapterName, 'pythonB'); + }); +}); diff --git a/src/test/cliDapClient.test.ts b/src/test/cliDapClient.test.ts new file mode 100644 index 0000000..b01e6a0 --- /dev/null +++ b/src/test/cliDapClient.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { DapClient } from '../cli/dapClient'; + +suite('CLI DAP client', () => { + let directory: string; + + setup(async () => { + directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'debugmcp-dap-')); + }); + + teardown(async () => { + await fs.promises.rm(directory, { recursive: true, force: true }); + }); + + test('frames requests and correlates responses', async () => { + const adapterPath = path.join(directory, 'adapter.js'); + await fs.promises.writeFile(adapterPath, ` +let buffer = Buffer.alloc(0); +process.stdin.on('data', chunk => { + buffer = Buffer.concat([buffer, chunk]); + const headerEnd = buffer.indexOf('\\r\\n\\r\\n'); + if (headerEnd < 0) return; + const header = buffer.subarray(0, headerEnd).toString('ascii'); + const length = Number(/Content-Length:\\s*(\\d+)/i.exec(header)[1]); + const start = headerEnd + 4; + if (buffer.length < start + length) return; + const request = JSON.parse(buffer.subarray(start, start + length).toString('utf8')); + const response = Buffer.from(JSON.stringify({ + seq: 1, + type: 'response', + request_seq: request.seq, + command: request.command, + success: true, + body: { echoed: request.arguments.value } + })); + process.stdout.write('Content-Length: ' + response.length + '\\r\\n\\r\\n'); + process.stdout.write(response); +}); +`, 'utf8'); + + const client = new DapClient(process.execPath, [adapterPath], directory, 5_000); + try { + const response = await client.request('echo', { value: 42 }); + assert.deepStrictEqual(response, { echoed: 42 }); + } finally { + await client.close(); + } + }); +}); diff --git a/src/test/cliDebuggingExecutor.test.ts b/src/test/cliDebuggingExecutor.test.ts new file mode 100644 index 0000000..6692b6c --- /dev/null +++ b/src/test/cliDebuggingExecutor.test.ts @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { CliDebuggingExecutor } from '../cli/cliDebuggingExecutor'; +import { CliDebugConfiguration } from '../cli/cliConfigurationManager'; + +suite('CLI debugging executor', () => { + let directory: string; + + setup(async () => { + directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'debugmcp-executor-')); + }); + + teardown(async () => { + await fs.promises.rm(directory, { recursive: true, force: true }); + }); + + test('performs a DAP launch and reports the stopped source frame', async () => { + const sourcePath = path.join(directory, 'app.fake'); + const adapterPath = path.join(directory, 'adapter.js'); + await fs.promises.writeFile(sourcePath, 'first();\nsecond();\nthird();\nfourth();\n', 'utf8'); + await fs.promises.writeFile(adapterPath, ` +let buffer = Buffer.alloc(0); +let sequence = 1; +let currentLine = 2; +function send(message) { + const payload = Buffer.from(JSON.stringify({ seq: sequence++, ...message })); + process.stdout.write('Content-Length: ' + payload.length + '\\r\\n\\r\\n'); + process.stdout.write(payload); +} +function respond(request, body = {}) { + send({ type: 'response', request_seq: request.seq, command: request.command, success: true, body }); +} +function handle(request) { + switch (request.command) { + case 'initialize': + respond(request, { supportsConfigurationDoneRequest: true }); + break; + case 'launch': + respond(request); + send({ type: 'event', event: 'initialized', body: {} }); + break; + case 'configurationDone': + respond(request); + send({ type: 'event', event: 'stopped', body: { reason: 'breakpoint', threadId: 7 } }); + break; + case 'stackTrace': + respond(request, { stackFrames: [{ + id: 11, + name: 'main', + line: currentLine, + column: 1, + source: { name: 'app.fake', path: ${JSON.stringify(sourcePath)} } + }] }); + break; + case 'next': + respond(request); + send({ type: 'event', event: 'continued', body: { threadId: 7 } }); + currentLine = 3; + send({ type: 'event', event: 'stopped', body: { reason: 'step', threadId: 7 } }); + break; + case 'continue': + respond(request); + send({ type: 'event', event: 'continued', body: { threadId: 7 } }); + currentLine = 4; + setTimeout(() => send({ + type: 'event', + event: 'stopped', + body: { reason: 'breakpoint', threadId: 7 } + }), 50); + break; + case 'disconnect': + respond(request); + setTimeout(() => process.exit(0), 20); + break; + default: + respond(request); + } +} +process.stdin.on('data', chunk => { + buffer = Buffer.concat([buffer, chunk]); + while (true) { + const headerEnd = buffer.indexOf('\\r\\n\\r\\n'); + if (headerEnd < 0) return; + const header = buffer.subarray(0, headerEnd).toString('ascii'); + const length = Number(/Content-Length:\\s*(\\d+)/i.exec(header)[1]); + const start = headerEnd + 4; + if (buffer.length < start + length) return; + const request = JSON.parse(buffer.subarray(start, start + length).toString('utf8')); + buffer = buffer.subarray(start + length); + handle(request); + } +}); +`, 'utf8'); + + const executor = new CliDebuggingExecutor(); + const config: CliDebugConfiguration = { + name: 'fake', + type: 'fake', + request: 'launch', + program: sourcePath, + adapterName: 'fake', + adapter: { + command: process.execPath, + args: [adapterPath], + type: 'fake', + extensions: ['.fake'] + } + }; + + const ready = executor.waitForDebugSessionReady(5_000); + assert.strictEqual(await executor.startDebugging(directory, config), true); + assert.strictEqual(await ready, 'stopped'); + const state = await executor.getCurrentDebugState(); + assert.strictEqual(state.currentLine, 2); + assert.strictEqual(state.currentLineContent, 'second();'); + assert.strictEqual(state.frameName, 'main'); + + await executor.stepOver(); + const steppedState = await executor.getCurrentDebugState(); + assert.strictEqual(steppedState.currentLine, 3); + assert.strictEqual(executor.getActiveFrameId(), 11); + await executor.continue(); + const continuedState = await executor.getCurrentDebugState(); + assert.strictEqual(continuedState.currentLine, 4); + await executor.stopDebugging(); + }); +}); diff --git a/src/test/copilotMcpConfig.test.ts b/src/test/copilotMcpConfig.test.ts new file mode 100644 index 0000000..02ec825 --- /dev/null +++ b/src/test/copilotMcpConfig.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + readCopilotDebugMcpHost, + selectCopilotDebugMcpHost +} from '../cli/copilotMcpConfig'; + +suite('Copilot CLI DebugMCP host selection', () => { + let directory: string; + let configPath: string; + + setup(async () => { + directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'debugmcp-host-')); + configPath = path.join(directory, 'mcp-config.json'); + await fs.promises.writeFile(configPath, JSON.stringify({ + mcpServers: { + other: { type: 'stdio', command: 'other', args: [] }, + debugmcp: { type: 'http', url: 'http://localhost:3001/mcp', tools: ['*'] } + }, + unrelated: true + }), 'utf8'); + await fs.promises.writeFile(path.join(directory, 'settings.json'), JSON.stringify({ + disabledMcpServers: ['other', 'debugmcp'] + }), 'utf8'); + }); + + teardown(async () => { + await fs.promises.rm(directory, { recursive: true, force: true }); + }); + + test('selecting CLI replaces the complete VS Code registration', async () => { + await selectCopilotDebugMcpHost(configPath, { + type: 'stdio', + command: 'node', + args: ['debugmcp.js', 'serve', '--stdio'], + tools: ['*'] + }); + const config = JSON.parse(await fs.promises.readFile(configPath, 'utf8')); + assert.deepStrictEqual(config.mcpServers.debugmcp, { + type: 'stdio', + command: 'node', + args: ['debugmcp.js', 'serve', '--stdio'], + tools: ['*'] + }); + assert.deepStrictEqual(config.mcpServers.other, { + type: 'stdio', + command: 'other', + args: [] + }); + assert.strictEqual(config.unrelated, true); + assert.ok(!('url' in config.mcpServers.debugmcp)); + assert.deepStrictEqual( + JSON.parse(await fs.promises.readFile(path.join(directory, 'settings.json'), 'utf8')), + { disabledMcpServers: ['other'] } + ); + }); + + test('selecting VS Code replaces the complete CLI registration', async () => { + await selectCopilotDebugMcpHost(configPath, { + type: 'stdio', + command: 'node', + args: ['debugmcp.js', 'serve', '--stdio'], + tools: ['*'] + }); + await selectCopilotDebugMcpHost(configPath, { + type: 'http', + url: 'http://localhost:4317/mcp', + tools: ['*'] + }); + assert.deepStrictEqual(await readCopilotDebugMcpHost(configPath), { + type: 'http', + url: 'http://localhost:4317/mcp', + tools: ['*'] + }); + }); +}); diff --git a/src/test/gdbInspection.test.ts b/src/test/gdbInspection.test.ts index 1f0074a..e21a1bf 100644 --- a/src/test/gdbInspection.test.ts +++ b/src/test/gdbInspection.test.ts @@ -126,6 +126,7 @@ suite('Cortex-Debug complex value inspection', () => { test('requested structs and arrays expose descendant names and types without values', async () => { const executor = { hasActiveSession: async () => true, + getActiveFrameId: () => 1, getVariables: async () => ({ scopes: [{ name: 'Locals', @@ -170,6 +171,7 @@ suite('Cortex-Debug complex value inspection', () => { test('nested fields do not expose values when only their parent is requested', async () => { const executor = { hasActiveSession: async () => true, + getActiveFrameId: () => 1, getVariables: async () => ({ scopes: [{ name: 'Locals', @@ -203,6 +205,7 @@ suite('Cortex-Debug complex value inspection', () => { test('evaluate_expression expands child names and types without reading their values', async () => { const executor = { hasActiveSession: async () => true, + getActiveFrameId: () => 1, evaluateExpression: async () => ({ result: '{Customer}', type: 'Customer', @@ -247,6 +250,7 @@ suite('Cortex-Debug complex value inspection', () => { test('explicitly evaluated pointers return their value instead of being treated as aggregates', async () => { const executor = { hasActiveSession: async () => true, + getActiveFrameId: () => 1, evaluateExpression: async () => ({ result: '0x94 "Alice"', type: 'const char *', @@ -269,6 +273,7 @@ suite('Cortex-Debug complex value inspection', () => { const expandedReferences: number[] = []; const executor = { hasActiveSession: async () => true, + getActiveFrameId: () => 1, evaluateExpression: async () => ({ result: '{Root}', type: 'Root', @@ -310,6 +315,7 @@ suite('Cortex-Debug complex value inspection', () => { test('successful empty adapter output is reported explicitly', async () => { const executor = { hasActiveSession: async () => true, + getActiveFrameId: () => 1, evaluateExpression: async () => ({ resultClass: 'done', output: '' }) } as unknown as IDebuggingExecutor; diff --git a/src/test/getVariables.test.ts b/src/test/getVariables.test.ts index b4a22b7..593c12f 100644 --- a/src/test/getVariables.test.ts +++ b/src/test/getVariables.test.ts @@ -12,6 +12,7 @@ import { IDebuggingExecutor } from '../debuggingExecutor'; function makeExecutor(scopes: any[]): IDebuggingExecutor { return { hasActiveSession: async () => true, + getActiveFrameId: () => 1, getVariables: async () => ({ scopes }) } as unknown as IDebuggingExecutor; } diff --git a/src/test/mixedVariableChildren.test.ts b/src/test/mixedVariableChildren.test.ts index aeb4949..d9f10b3 100644 --- a/src/test/mixedVariableChildren.test.ts +++ b/src/test/mixedVariableChildren.test.ts @@ -42,6 +42,7 @@ suite('Mixed indexed and named variable children', () => { }); const executor = { hasActiveSession: async () => true, + getActiveFrameId: () => 1, getActiveSession: () => ({ type: 'pwa-node' }), evaluateExpression: async () => ({ type: 'String', result: 'private preview', variablesReference: 1 }), getVariableChildren: async () => [ diff --git a/src/test/rubyInspection.test.ts b/src/test/rubyInspection.test.ts index 505590a..05619b2 100644 --- a/src/test/rubyInspection.test.ts +++ b/src/test/rubyInspection.test.ts @@ -23,6 +23,7 @@ suite('Ruby rdbg variable inspection', () => { let expanded = false; const executor = { hasActiveSession: async () => true, + getActiveFrameId: () => 1, getActiveSession: () => ({ type: 'ruby_lsp' }), getVariables: async () => ({ scopes: [{ @@ -55,6 +56,7 @@ suite('Ruby rdbg variable inspection', () => { const expandedReferences: number[] = []; const executor = { hasActiveSession: async () => true, + getActiveFrameId: () => 1, getActiveSession: () => ({ type: 'ruby_lsp' }), getVariables: async () => ({ scopes: [{ @@ -146,6 +148,7 @@ suite('Ruby rdbg variable inspection', () => { let expanded = false; const executor = { hasActiveSession: async () => true, + getActiveFrameId: () => 1, getActiveSession: () => ({ type: 'ruby_lsp' }), evaluateExpression: async () => ({ result: '42', @@ -171,6 +174,7 @@ suite('Ruby rdbg variable inspection', () => { test('still redacts Ruby String values that have metadata children', async () => { const executor = { hasActiveSession: async () => true, + getActiveFrameId: () => 1, getActiveSession: () => ({ type: 'ruby_lsp' }), getVariables: async () => ({ scopes: [{ diff --git a/src/utils/agentCatalog.ts b/src/utils/agentCatalog.ts new file mode 100644 index 0000000..e73d759 --- /dev/null +++ b/src/utils/agentCatalog.ts @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. + +import * as os from 'node:os'; +import * as path from 'node:path'; + +export interface BaseAgentInfo { + id: string; + name: string; + displayName: string; + configPath: string; + configFormat: 'json' | 'toml'; +} + +export interface JsonAgentInfo extends BaseAgentInfo { + configFormat: 'json'; + mcpServerFieldName: string; +} + +export interface TomlAgentInfo extends BaseAgentInfo { + configFormat: 'toml'; +} + +export type AgentInfo = JsonAgentInfo | TomlAgentInfo; + +export function getSupportedAgents(): AgentInfo[] { + const configBasePath = getConfigBasePath(); + return [ + { + id: 'cline', + name: 'cline', + displayName: 'Cline', + configPath: path.join(configBasePath, 'Code', 'User', 'globalStorage', + 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'), + configFormat: 'json', + mcpServerFieldName: 'mcpServers' + }, + { + id: 'roo', + name: 'roo', + displayName: 'Roo Code', + configPath: path.join(configBasePath, 'Code', 'User', 'globalStorage', + 'rooveterinaryinc.roo-cline', 'settings', 'mcp_settings.json'), + configFormat: 'json', + mcpServerFieldName: 'mcpServers' + }, + { + id: 'copilot', + name: 'copilot', + displayName: 'GitHub Copilot', + configPath: path.join(configBasePath, 'Code', 'User', 'mcp.json'), + configFormat: 'json', + mcpServerFieldName: 'servers' + }, + { + id: 'copilot-cli', + name: 'copilot-cli', + displayName: 'GitHub Copilot CLI', + configPath: path.join( + process.env.COPILOT_HOME || path.join(os.homedir(), '.copilot'), + 'mcp-config.json' + ), + configFormat: 'json', + mcpServerFieldName: 'mcpServers' + }, + { + id: 'cursor', + name: 'cursor', + displayName: 'Cursor', + configPath: path.join(configBasePath, 'Cursor', 'User', 'globalStorage', + 'cursor.mcp', 'settings', 'mcp_settings.json'), + configFormat: 'json', + mcpServerFieldName: 'mcpServers' + }, + { + id: 'antigravity', + name: 'antigravity', + displayName: 'Antigravity', + configPath: path.join(os.homedir(), '.gemini', 'antigravity', 'mcp_config.json'), + configFormat: 'json', + mcpServerFieldName: 'mcpServers' + }, + { + id: 'claude-code', + name: 'claude-code', + displayName: 'Claude Code', + configPath: path.join(os.homedir(), '.claude.json'), + configFormat: 'json', + mcpServerFieldName: 'mcpServers' + }, + { + id: 'codex', + name: 'codex', + displayName: 'Codex', + configPath: path.join( + process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), + 'config.toml' + ), + configFormat: 'toml' + } + ]; +} + +function getConfigBasePath(): string { + const userHome = os.homedir(); + switch (process.platform) { + case 'win32': + return process.env.APPDATA || path.join(userHome, 'AppData', 'Roaming'); + case 'darwin': + return path.join(userHome, 'Library', 'Application Support'); + case 'linux': + return process.env.XDG_CONFIG_HOME || path.join(userHome, '.config'); + default: + return process.env.APPDATA || path.join(userHome, 'AppData', 'Roaming'); + } +} diff --git a/src/utils/agentConfigurationManager.ts b/src/utils/agentConfigurationManager.ts index 531bb08..79e3076 100644 --- a/src/utils/agentConfigurationManager.ts +++ b/src/utils/agentConfigurationManager.ts @@ -4,25 +4,14 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; - -export interface BaseAgentInfo { - id: string; - name: string; - displayName: string; - configPath: string; - configFormat: 'json' | 'toml'; -} - -export interface JsonAgentInfo extends BaseAgentInfo { - configFormat: 'json'; - mcpServerFieldName: string; -} - -export interface TomlAgentInfo extends BaseAgentInfo { - configFormat: 'toml'; -} - -export type AgentInfo = JsonAgentInfo | TomlAgentInfo; +import { + AgentInfo, + getSupportedAgents, + JsonAgentInfo, + TomlAgentInfo +} from './agentCatalog'; +import { selectCopilotDebugMcpHost } from '../cli/copilotMcpConfig'; +export { AgentInfo, JsonAgentInfo, TomlAgentInfo } from './agentCatalog'; export interface MCPServerConfig { type: string; @@ -180,34 +169,6 @@ export class AgentConfigurationManager { /** * Get cross-platform configuration base path */ - private getConfigBasePath(): string { - const platform = os.platform(); - const userHome = os.homedir(); - - switch (platform) { - case 'win32': // Windows - return process.env.APPDATA || path.join(userHome, 'AppData', 'Roaming'); - case 'darwin': // MacOS - return path.join(userHome, 'Library', 'Application Support'); - case 'linux': // Linux - return process.env.XDG_CONFIG_HOME || path.join(userHome, '.config'); - default: - // Fallback to Windows-style for unknown platforms - console.warn(`Unknown platform: ${platform}, using Windows config path`); - return process.env.APPDATA || path.join(userHome, 'AppData', 'Roaming'); - } - } - - private getCodexConfigPath(): string { - const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); - return path.join(codexHome, 'config.toml'); - } - - private getCopilotCliConfigPath(): string { - const copilotHome = process.env.COPILOT_HOME || path.join(os.homedir(), '.copilot'); - return path.join(copilotHome, 'mcp-config.json'); - } - /** * Personal skill install targets, following the Agent Skills open standard * (agentskills.io). `~/.agents/skills/` is the cross-agent location honored @@ -293,82 +254,7 @@ export class AgentConfigurationManager { * Get list of supported agents */ private async getSupportedAgents(): Promise { - const configBasePath = this.getConfigBasePath(); - const platform = os.platform(); - - console.log(`Detected platform: ${platform}, using config base path: ${configBasePath}`); - - const agents: AgentInfo[] = [ - { - id: 'cline', - name: 'cline', - displayName: 'Cline', - configPath: path.join(configBasePath, 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'), - configFormat: 'json', - mcpServerFieldName: 'mcpServers' - }, - { - id: 'roo', - name: 'roo', - displayName: 'Roo Code', - configPath: path.join(configBasePath, 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'settings', 'mcp_settings.json'), - configFormat: 'json', - mcpServerFieldName: 'mcpServers' - }, - { - id: 'copilot', - name: 'copilot', - displayName: 'GitHub Copilot', - configPath: path.join(configBasePath, 'Code', 'User', 'mcp.json'), - configFormat: 'json', - mcpServerFieldName: 'servers' - }, - { - id: 'copilot-cli', - name: 'copilot-cli', - displayName: 'GitHub Copilot CLI', - configPath: this.getCopilotCliConfigPath(), - configFormat: 'json', - mcpServerFieldName: 'mcpServers' - }, - { - id: 'cursor', - name: 'cursor', - displayName: 'Cursor', - configPath: path.join(configBasePath, 'Cursor', 'User', 'globalStorage', 'cursor.mcp', 'settings', 'mcp_settings.json'), - configFormat: 'json', - mcpServerFieldName: 'mcpServers' - }, - { - id: 'antigravity', - name: 'antigravity', - displayName: 'Antigravity', - configPath: path.join(os.homedir(), '.gemini', 'antigravity', 'mcp_config.json'), - configFormat: 'json', - mcpServerFieldName: 'mcpServers' - }, - { - id: 'claude-code', - name: 'claude-code', - displayName: 'Claude Code', - // User-scope MCP servers live under the top-level `mcpServers` field of - // ~/.claude.json (shared across projects), distinct from the per-project - // `projects..mcpServers` entries Claude Code also stores there. - // See https://code.claude.com/docs/en/mcp. - configPath: path.join(os.homedir(), '.claude.json'), - configFormat: 'json', - mcpServerFieldName: 'mcpServers' - }, - { - id: 'codex', - name: 'codex', - displayName: 'Codex', - configPath: this.getCodexConfigPath(), - configFormat: 'toml' - } - ]; - - return agents; + return getSupportedAgents(); } /** @@ -573,11 +459,19 @@ export class AgentConfigurationManager { const fieldName = agent.mcpServerFieldName; try { - await upsertJsonDebugMCPConfigFile( - agent.configPath, - fieldName, - this.getDebugMCPConfig(agent) - ); + if (agent.id === 'copilot-cli') { + await selectCopilotDebugMcpHost(agent.configPath, { + type: 'http', + url: this.getMCPServerUrl(), + tools: ['*'] + }); + } else { + await upsertJsonDebugMCPConfigFile( + agent.configPath, + fieldName, + this.getDebugMCPConfig(agent) + ); + } } catch (error) { if (!(error instanceof SyntaxError)) { throw error; diff --git a/src/utils/debugConfigurationManager.ts b/src/utils/debugConfigurationManager.ts index 54e4102..702b678 100644 --- a/src/utils/debugConfigurationManager.ts +++ b/src/utils/debugConfigurationManager.ts @@ -3,6 +3,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; +import { DebugConfiguration } from '../debugTypes'; /** * Interface for configuration management operations @@ -12,7 +13,7 @@ export interface IDebugConfigurationManager { workingDirectory: string, fileFullPath: string, configurationName?: string - ): Promise; + ): Promise; detectLanguageFromFilePath(fileFullPath: string): string; } @@ -60,7 +61,7 @@ export class DebugConfigurationManager implements IDebugConfigurationManager { workingDirectory: string, fileFullPath: string, configurationName?: string - ): Promise { + ): Promise { // Named launch.json config — let VS Code resolve it itself. if (configurationName && configurationName.trim() !== '' && @@ -165,7 +166,7 @@ export class DebugConfigurationManager implements IDebugConfigurationManager { * Build a coreclr launch config pointing at the project's built DLL. * Throws a clear error if the project hasn't been built yet. */ - private async createDotNetLaunchConfig(fileFullPath: string): Promise { + private async createDotNetLaunchConfig(fileFullPath: string): Promise { const csproj = await this.findNearestCsproj(fileFullPath); if (!csproj) { throw new Error( diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 3c0848d..7c967cb 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,7 +1,5 @@ // Copyright (c) Microsoft Corporation. -import * as vscode from 'vscode'; - export enum LogLevel { DEBUG = 0, INFO = 1, @@ -9,14 +7,27 @@ export enum LogLevel { ERROR = 3 } +export interface LogSink { + debug(message: string): void; + info(message: string): void; + warn(message: string): void; + error(message: string): void; + show?(): void; +} + +const stderrSink: LogSink = { + debug: message => process.stderr.write(`[debug] ${message}\n`), + info: message => process.stderr.write(`[info] ${message}\n`), + warn: message => process.stderr.write(`[warn] ${message}\n`), + error: message => process.stderr.write(`[error] ${message}\n`) +}; + export class Logger { private static instance: Logger; - private outputChannel: vscode.LogOutputChannel; + private outputChannel: LogSink = stderrSink; private logLevel: LogLevel = LogLevel.INFO; - private constructor() { - this.outputChannel = vscode.window.createOutputChannel('DebugMCP', { log: true }); - } + private constructor() {} public static getInstance(): Logger { if (!Logger.instance) { @@ -81,9 +92,15 @@ export class Logger { this.info(`Log level set to ${LogLevel[level]}`); } - public logSystemInfo(): void { + public setSink(sink: LogSink): void { + this.outputChannel = sink; + } + + public logSystemInfo(hostVersion?: string): void { this.info('=== System Information ==='); - this.info(`VS Code Version: ${vscode.version}`); + if (hostVersion) { + this.info(`Host Version: ${hostVersion}`); + } this.info(`Platform: ${process.platform}`); this.info(`Architecture: ${process.arch}`); this.info(`Node.js Version: ${process.version}`); @@ -101,7 +118,7 @@ export class Logger { } public show(): void { - this.outputChannel.show(); + this.outputChannel.show?.(); } } From 65b42e17f89bd0121d30a1926a5f5d059251e9c0 Mon Sep 17 00:00:00 2001 From: ozzafar <48795672+ozzafar@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:06:41 +0300 Subject: [PATCH 2/6] Package standalone CLI for npm Add a minimal debugmcp npm package containing the bundled command, package documentation, and license. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5e39a3eb-0164-4765-9a88-9d240291ee6f --- .gitignore | 5 +++- README.md | 8 ++++++ npm/cli/README.md | 35 +++++++++++++++++++++++++ npm/cli/package.json | 41 ++++++++++++++++++++++++++++++ npm/cli/scripts/prepare-package.js | 24 +++++++++++++++++ package.json | 2 ++ 6 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 npm/cli/README.md create mode 100644 npm/cli/package.json create mode 100644 npm/cli/scripts/prepare-package.js diff --git a/.gitignore b/.gitignore index 9e0ec99..f93c355 100644 --- a/.gitignore +++ b/.gitignore @@ -421,4 +421,7 @@ FodyWeavers.xsd node_modules/* out/* dist/* -.vscode-test/* \ No newline at end of file +.vscode-test/* +*.tgz +npm/cli/LICENSE.txt +npm/cli/dist/ \ No newline at end of file diff --git a/README.md b/README.md index 6f2b141..2c8806c 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,14 @@ The standalone host talks directly to explicitly configured DAP adapters and does not require VS Code. It never discovers, downloads, installs, or selects an adapter automatically. +Install the published package: + +```console +npm install --global debugmcp +``` + +For repository development, build and invoke the local bundle: + ```console npm install npm run package diff --git a/npm/cli/README.md b/npm/cli/README.md new file mode 100644 index 0000000..174642e --- /dev/null +++ b/npm/cli/README.md @@ -0,0 +1,35 @@ +# DebugMCP CLI + +DebugMCP CLI lets MCP-capable coding agents control language debuggers without +running VS Code. It communicates with explicitly configured Debug Adapter +Protocol (DAP) adapters over stdio. + +## Install + +```console +npm install --global debugmcp +``` + +## Configure an agent + +Configure GitHub Copilot CLI once: + +```console +debugmcp configure --agent copilot-cli +``` + +## Configure a project + +DebugMCP does not discover, install, or choose debugger installations. Register +the adapter provided by the project's environment: + +```console +cd path\to\python-project +debugmcp adapter add python --command "python -m debugpy.adapter" +debugmcp adapter validate python +``` + +The project registration is stored in `.debugmcp.json`. Start the configured +agent from that project and ask it to use the `debug-live` skill. + +Use `debugmcp help` for all commands. diff --git a/npm/cli/package.json b/npm/cli/package.json new file mode 100644 index 0000000..6409f48 --- /dev/null +++ b/npm/cli/package.json @@ -0,0 +1,41 @@ +{ + "name": "debugmcp", + "version": "0.1.0", + "description": "Standalone MCP debugger host for explicitly configured Debug Adapter Protocol adapters.", + "license": "MIT", + "author": "Microsoft Corporation", + "homepage": "https://github.com/microsoft/DebugMCP#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/DebugMCP.git" + }, + "bugs": { + "url": "https://github.com/microsoft/DebugMCP/issues" + }, + "keywords": [ + "debugger", + "debug-adapter-protocol", + "dap", + "mcp", + "model-context-protocol", + "copilot" + ], + "bin": { + "debugmcp": "dist/debugmcp.js" + }, + "files": [ + "dist/debugmcp.js", + "README.md", + "LICENSE.txt" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "prepack": "node scripts/prepare-package.js" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + } +} diff --git a/npm/cli/scripts/prepare-package.js b/npm/cli/scripts/prepare-package.js new file mode 100644 index 0000000..34127f0 --- /dev/null +++ b/npm/cli/scripts/prepare-package.js @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. + +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const packageRoot = path.resolve(__dirname, '..'); +const repositoryRoot = path.resolve(packageRoot, '..', '..'); +const outputDirectory = path.join(packageRoot, 'dist'); + +execFileSync(process.execPath, [path.join(repositoryRoot, 'esbuild.js'), '--production'], { + cwd: repositoryRoot, + stdio: 'inherit' +}); + +fs.mkdirSync(outputDirectory, { recursive: true }); +fs.copyFileSync( + path.join(repositoryRoot, 'dist', 'debugmcp.js'), + path.join(outputDirectory, 'debugmcp.js') +); +fs.copyFileSync( + path.join(repositoryRoot, 'LICENSE.txt'), + path.join(packageRoot, 'LICENSE.txt') +); diff --git a/package.json b/package.json index 384de92..d4281b4 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,8 @@ "check-types": "tsc --noEmit -p ./", "bundle": "node esbuild.js", "cli": "node dist/debugmcp.js", + "cli:pack": "npm pack ./npm/cli", + "cli:publish": "npm publish ./npm/cli", "package": "npm run check-types && node esbuild.js --production", "watch": "tsc -watch -p ./", "pretest": "npm run compile && npm run bundle && npm run lint", From 6c3deb51b906920ea589bcfa2740a245f617dbe9 Mon Sep 17 00:00:00 2001 From: ozzafar <48795672+ozzafar@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:30:56 +0300 Subject: [PATCH 3/6] Harden standalone CLI reliability Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5e39a3eb-0164-4765-9a88-9d240291ee6f --- README.md | 4 + docs/architecture/debuggingExecutor.md | 2 + src/cli/adapterShorthand.ts | 5 +- src/cli/cliDebuggingExecutor.ts | 23 ++++- src/cli/cliOptions.ts | 45 ++++++++++ src/cli/dapClient.ts | 7 ++ src/cli/main.ts | 36 ++------ src/debugMCPServer.ts | 25 ++++-- src/debuggingExecutor.ts | 1 + src/debuggingHandler.ts | 5 ++ src/test/adapterShorthand.test.ts | 35 ++++++++ src/test/cliAdapterConfig.test.ts | 29 ++++++ src/test/cliDapClient.test.ts | 119 +++++++++++++++++++++++++ src/test/cliDebuggingExecutor.test.ts | 68 ++++++++++++++ src/test/cliOptions.test.ts | 36 ++++++++ src/test/copilotMcpConfig.test.ts | 18 ++++ src/test/debuggingHandler.test.ts | 13 +++ 17 files changed, 436 insertions(+), 35 deletions(-) create mode 100644 src/cli/cliOptions.ts create mode 100644 src/test/cliOptions.test.ts diff --git a/README.md b/README.md index 2c8806c..0753d47 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,10 @@ object. If multiple registered adapters claim the same file extension, discovery remains host-specific; configure the adapter launch properties to run the required test command. +Adapter arguments beginning with `--` can follow `--args` directly. Use a +standalone `--` after `--args` when an adapter argument has the same name as a +DebugMCP option, for example `--args -- --user`. + `debugmcp configure` presents the same agent choices as the VS Code extension's setup popup and writes the standalone stdio command for every selected agent. In automation, repeat `--agent ` to bypass the terminal prompt, for example diff --git a/docs/architecture/debuggingExecutor.md b/docs/architecture/debuggingExecutor.md index 123bcde..bda369e 100644 --- a/docs/architecture/debuggingExecutor.md +++ b/docs/architecture/debuggingExecutor.md @@ -47,6 +47,8 @@ initialize/launch/configuration sequence, handles adapter events and Step operations wait for a fresh stopped or terminated event. Continue allows a short stop-event grace period so immediately reached breakpoints are reported, while still returning promptly for long-running programs. +Closing an MCP session disposes its standalone executor, adapter process, and +any debuggee processes started through reverse `runInTerminal` requests. `src/cli/adapterConfig.ts` loads project and user registrations. No adapter is registered, discovered, selected, installed, or upgraded implicitly. diff --git a/src/cli/adapterShorthand.ts b/src/cli/adapterShorthand.ts index b527d95..bc4c6e2 100644 --- a/src/cli/adapterShorthand.ts +++ b/src/cli/adapterShorthand.ts @@ -24,7 +24,8 @@ const adapterLanguages: Record = { export function createShorthandAdapter( language: string, - commandValues: string[] + commandValues: string[], + adapterArgs: string[] = [] ): AdapterRegistration { const metadata = adapterLanguages[language.toLowerCase()]; if (!metadata) { @@ -44,7 +45,7 @@ export function createShorthandAdapter( return { command, - args, + args: [...args, ...adapterArgs], type: metadata.type, extensions: [...metadata.extensions], transport: 'stdio' diff --git a/src/cli/cliDebuggingExecutor.ts b/src/cli/cliDebuggingExecutor.ts index e943814..cfdac5b 100644 --- a/src/cli/cliDebuggingExecutor.ts +++ b/src/cli/cliDebuggingExecutor.ts @@ -45,6 +45,10 @@ export class CliDebuggingExecutor implements IDebuggingExecutor { if (this.client && this.state !== 'terminated') { throw new Error('A debug session is already active. Stop it before starting another.'); } + if (this.client) { + await this.client.close(); + this.client = undefined; + } this.state = 'starting'; this.threadId = undefined; @@ -82,7 +86,11 @@ export class CliDebuggingExecutor implements IDebuggingExecutor { const { adapter: _adapter, adapterName: _adapterName, ...launchArguments } = cliConfig; const launch = client.request(cliConfig.request, launchArguments); - await initializedEvent; + const launchRejected = launch.then( + () => new Promise(() => {}), + error => Promise.reject(error) + ); + await Promise.race([initializedEvent, launchRejected]); this.initialized = true; await this.syncAllBreakpoints(); if (this.capabilities.supportsConfigurationDoneRequest === true) { @@ -96,6 +104,7 @@ export class CliDebuggingExecutor implements IDebuggingExecutor { return true; } catch (error) { await client.close(); + this.client = undefined; this.state = 'terminated'; this.emitState(); throw new Error(`Failed to start standalone debug session: ${error}`); @@ -126,6 +135,18 @@ export class CliDebuggingExecutor implements IDebuggingExecutor { } } + public async dispose(): Promise { + const client = this.client; + this.client = undefined; + if (client) { + await client.close(); + } + this.state = 'terminated'; + this.threadId = undefined; + this.frameId = undefined; + this.emitState(); + } + public async stepOver(): Promise { await this.runThreadCommand('next'); } diff --git a/src/cli/cliOptions.ts b/src/cli/cliOptions.ts new file mode 100644 index 0000000..6de18b7 --- /dev/null +++ b/src/cli/cliOptions.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. + +export interface ParsedOptions { + values: Record; + positionals: string[]; +} + +export function parseOptions( + args: string[], + acceptedOptions: readonly string[] +): ParsedOptions { + const accepted = new Set(acceptedOptions); + const values: Record = {}; + const positionals: string[] = []; + let current: string | undefined; + let passthrough = false; + + for (const arg of args) { + if (passthrough && current) { + values[current].push(arg); + continue; + } + if (arg === '--' && current) { + passthrough = true; + continue; + } + if (arg.startsWith('--')) { + const option = arg.slice(2); + if (current === 'args' && !accepted.has(option)) { + values[current].push(arg); + continue; + } + if (!accepted.has(option)) { + throw new Error(`Unknown option '--${option}'.`); + } + current = option; + values[current] ??= []; + } else if (current) { + values[current].push(arg); + } else { + positionals.push(arg); + } + } + return { values, positionals }; +} diff --git a/src/cli/dapClient.ts b/src/cli/dapClient.ts index 63ccc81..8b9bee2 100644 --- a/src/cli/dapClient.ts +++ b/src/cli/dapClient.ts @@ -188,6 +188,13 @@ export class DapClient extends EventEmitter { windowsHide: true }); this.debuggees.add(child); + await new Promise((resolve, reject) => { + child.once('spawn', resolve); + child.once('error', error => { + this.debuggees.delete(child); + reject(error); + }); + }); child.once('exit', () => this.debuggees.delete(child)); child.stdout?.on('data', chunk => logger.info(`debuggee: ${String(chunk).trimEnd()}`)); child.stderr?.on('data', chunk => logger.warn(`debuggee: ${String(chunk).trimEnd()}`)); diff --git a/src/cli/main.ts b/src/cli/main.ts index 09d9026..af28a92 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -29,28 +29,7 @@ import { } from './agentSelector'; import { getSupportedAgents } from '../utils/agentCatalog'; import { createShorthandAdapter } from './adapterShorthand'; - -interface ParsedOptions { - values: Record; - positionals: string[]; -} - -function parseOptions(args: string[]): ParsedOptions { - const values: Record = {}; - const positionals: string[] = []; - let current: string | undefined; - for (const arg of args) { - if (arg.startsWith('--')) { - current = arg.slice(2); - values[current] ??= []; - } else if (current) { - values[current].push(arg); - } else { - positionals.push(arg); - } - } - return { values, positionals }; -} +import { ParsedOptions, parseOptions } from './cliOptions'; function first(options: ParsedOptions, name: string): string | undefined { return options.values[name]?.[0]; @@ -96,7 +75,7 @@ async function printStatus(): Promise { } async function configureAgents(args: string[]): Promise { - const options = parseOptions(args); + const options = parseOptions(args, ['agent']); const requestedAgents = options.values.agent ?? []; const agents = requestedAgents.length > 0 ? resolveAgentSelections(requestedAgents, getSupportedAgents()) @@ -114,7 +93,10 @@ async function configureAgents(args: string[]): Promise { async function runAdapterCommand(args: string[]): Promise { const action = args[0]; - const options = parseOptions(args.slice(1)); + const options = parseOptions( + args.slice(1), + ['user', 'command', 'type', 'extensions', 'args', 'launch'] + ); const name = options.positionals[0]; const cwd = process.cwd(); const configPath = flag(options, 'user') ? getUserConfigPath() : getProjectConfigPath(cwd); @@ -172,7 +154,7 @@ async function runAdapterCommand(args: string[]): Promise { launch } : { - ...createShorthandAdapter(name, commandValues), + ...createShorthandAdapter(name, commandValues, options.values.args), ...(launch ? { launch } : {}) }; validateAdapter(name, adapter); @@ -214,7 +196,7 @@ async function validateAdapterProcess( cwd: string ): Promise { validateAdapter(name, adapter); - const client = new DapClient(adapter.command, adapter.args ?? [], cwd, 10_000); + const client = new DapClient(adapter.command, adapter.args ?? [], cwd, 30_000); try { await client.request('initialize', { clientID: 'debugmcp-validator', @@ -229,7 +211,7 @@ async function validateAdapterProcess( } async function runServer(args: string[]): Promise { - const options = parseOptions(args); + const options = parseOptions(args, ['stdio', 'port', 'timeout']); const timeout = Number(first(options, 'timeout') ?? '300'); const port = Number(first(options, 'port') ?? '3001'); if (!Number.isFinite(timeout) || timeout <= 0) { diff --git a/src/debugMCPServer.ts b/src/debugMCPServer.ts index b22cc40..a6da21d 100644 --- a/src/debugMCPServer.ts +++ b/src/debugMCPServer.ts @@ -153,7 +153,7 @@ export class DebugMCPServer { * Build a fresh McpServer with all tools registered. * Called once per session, when an `initialize` request opens it. */ - private createMcpServer(): McpServer { + private createMcpServer(debuggingHandler = this.handlerFactory()): McpServer { const server = new McpServer({ name: 'debugmcp', version: '1.0.0', @@ -167,7 +167,7 @@ export class DebugMCPServer { 'investigation workflow using the debugger, including breakpoint strategy, step-and-inspect ' + 'pattern and root-cause guidance.', }); - this.setupTools(server, this.handlerFactory()); + this.setupTools(server, debuggingHandler); return server; } @@ -338,11 +338,24 @@ export class DebugMCPServer { } public async startStdio(): Promise { - const server = this.createMcpServer(); - await server.connect(new StdioServerTransport()); + const debuggingHandler = this.handlerFactory(); + const server = this.createMcpServer(debuggingHandler); + const transport = new StdioServerTransport(); + transport.onclose = () => { + void this.disposeHandler(debuggingHandler); + }; + await server.connect(transport); logger.info('DebugMCP CLI listening on stdio'); } + private async disposeHandler(handler: IDebuggingHandler): Promise { + try { + await handler.dispose?.(); + } catch (error) { + logger.warn('Error disposing MCP debugging handler', error); + } + } + /** * Check if the server is already running */ @@ -473,6 +486,7 @@ export class DebugMCPServer { } else if (!sessionId && isInitializeRequest(req.body)) { // Brand-new session: build a transport + server and register it // once the SDK assigns a session id. + const debuggingHandler = this.handlerFactory(); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sid: string) => { @@ -486,8 +500,9 @@ export class DebugMCPServer { delete this.transports[sid]; logger.info(`MCP session closed: ${sid}`); } + void this.disposeHandler(debuggingHandler); }; - const server = this.createMcpServer(); + const server = this.createMcpServer(debuggingHandler); await server.connect(transport); } else { // No session id and not an initialize request — invalid. diff --git a/src/debuggingExecutor.ts b/src/debuggingExecutor.ts index 64d784b..3849ff4 100644 --- a/src/debuggingExecutor.ts +++ b/src/debuggingExecutor.ts @@ -51,6 +51,7 @@ export interface IDebuggingExecutor { getActiveSession(): DebugSessionInfo | undefined; getActiveFrameId?(): number | undefined; waitForDebugSessionReady(timeoutMs: number, signal?: AbortSignal): Promise<'stopped' | 'terminated' | 'timeout' | 'no-session' | 'attached'>; + dispose?(): Promise | void; } /** diff --git a/src/debuggingHandler.ts b/src/debuggingHandler.ts index 4d301d4..bcb41ce 100644 --- a/src/debuggingHandler.ts +++ b/src/debuggingHandler.ts @@ -35,6 +35,7 @@ export interface IDebuggingHandler { handleListVariableNames(args?: { scope?: 'local' | 'global' | 'all' }): Promise; handleEvaluateExpression(args: { expression: string }): Promise; handleGetDebugStatus(args?: { waitForPauseSeconds?: number }): Promise; + dispose?(): Promise | void; } /** @@ -70,6 +71,10 @@ export class DebuggingHandler implements IDebuggingHandler { this.timeoutInSeconds = timeoutInSeconds; } + public async dispose(): Promise { + await this.executor.dispose?.(); + } + /** * Start a debugging session */ diff --git a/src/test/adapterShorthand.test.ts b/src/test/adapterShorthand.test.ts index c0025ae..1794139 100644 --- a/src/test/adapterShorthand.test.ts +++ b/src/test/adapterShorthand.test.ts @@ -24,6 +24,41 @@ suite('CLI adapter shorthand', () => { ); }); + test('preserves separately tokenized command values', () => { + assert.deepStrictEqual( + createShorthandAdapter('python', ['python', '-m', 'debugpy.adapter']), + { + command: 'python', + args: ['-m', 'debugpy.adapter'], + type: 'python', + extensions: ['.py'], + transport: 'stdio' + } + ); + }); + + test('appends explicitly separated adapter arguments', () => { + assert.deepStrictEqual( + createShorthandAdapter( + 'csharp', + ['netcoredbg'], + ['--interpreter=vscode'] + ), + { + command: 'netcoredbg', + args: ['--interpreter=vscode'], + type: 'coreclr', + extensions: ['.cs'], + transport: 'stdio' + } + ); + }); + + test('rejects malformed or empty command lines', () => { + assert.throws(() => splitCommandLine('"unterminated'), /unterminated quote/); + assert.throws(() => createShorthandAdapter('python', [' ']), /requires --command/); + }); + test('derives compiled-language metadata', () => { assert.deepStrictEqual( createShorthandAdapter('csharp', ['vsdbg --interpreter=vscode']), diff --git a/src/test/cliAdapterConfig.test.ts b/src/test/cliAdapterConfig.test.ts index 66726e5..4c4c287 100644 --- a/src/test/cliAdapterConfig.test.ts +++ b/src/test/cliAdapterConfig.test.ts @@ -8,6 +8,7 @@ import { CliConfigurationManager } from '../cli/cliConfigurationManager'; import { AdapterConfigFile, getProjectConfigPath, + getUserConfigPath, loadAdapters, writeAdapterConfig } from '../cli/adapterConfig'; @@ -68,6 +69,32 @@ suite('CLI adapter configuration', () => { assert.strictEqual(resolved.program, path.join(workspace, 'app.py')); }); + test('project registration overrides a same-name user adapter', async () => { + await writeAdapterConfig(getUserConfigPath(), { + version: 1, + adapters: { + python: { + command: 'user-python', + type: 'python', + extensions: ['.py'] + } + } + }); + await writeAdapterConfig(getProjectConfigPath(workspace), { + version: 1, + adapters: { + python: { + command: 'project-python', + type: 'python', + extensions: ['.py'] + } + } + }); + + const adapters = await loadAdapters(workspace); + assert.strictEqual(adapters.python.command, 'project-python'); + }); + test('expands a compiled adapter launch target', async () => { await writeAdapterConfig(getProjectConfigPath(workspace), { version: 1, @@ -78,6 +105,7 @@ suite('CLI adapter configuration', () => { extensions: ['.cs'], launch: { program: '${workspaceFolder}\\bin\\Calculator.exe', + args: ['${file}', '${fileBasenameNoExtension}'], sourceFileMap: { '/source': '${fileDirname}' } @@ -92,6 +120,7 @@ suite('CLI adapter configuration', () => { assert.deepStrictEqual(resolved.sourceFileMap, { '/source': workspace }); + assert.deepStrictEqual(resolved.args, [source, 'Calculator']); }); test('requires a name when multiple adapters claim an extension', async () => { diff --git a/src/test/cliDapClient.test.ts b/src/test/cliDapClient.test.ts index b01e6a0..c526723 100644 --- a/src/test/cliDapClient.test.ts +++ b/src/test/cliDapClient.test.ts @@ -51,4 +51,123 @@ process.stdin.on('data', chunk => { await client.close(); } }); + + test('surfaces DAP error responses', async () => { + const adapterPath = path.join(directory, 'rejecting-adapter.js'); + await fs.promises.writeFile(adapterPath, ` +let buffer = Buffer.alloc(0); +process.stdin.on('data', chunk => { + buffer = Buffer.concat([buffer, chunk]); + const headerEnd = buffer.indexOf('\\r\\n\\r\\n'); + if (headerEnd < 0) return; + const length = Number(/Content-Length:\\s*(\\d+)/i.exec( + buffer.subarray(0, headerEnd).toString('ascii') + )[1]); + const start = headerEnd + 4; + if (buffer.length < start + length) return; + const request = JSON.parse(buffer.subarray(start, start + length).toString('utf8')); + const response = Buffer.from(JSON.stringify({ + seq: 1, + type: 'response', + request_seq: request.seq, + command: request.command, + success: false, + message: 'launch configuration rejected' + })); + process.stdout.write('Content-Length: ' + response.length + '\\r\\n\\r\\n'); + process.stdout.write(response); +}); +`, 'utf8'); + + const client = new DapClient(process.execPath, [adapterPath], directory, 5_000); + try { + await assert.rejects( + () => client.request('launch'), + /launch configuration rejected/ + ); + } finally { + await client.close(); + } + }); + + test('rejects pending requests when the adapter exits', async () => { + const adapterPath = path.join(directory, 'exiting-adapter.js'); + await fs.promises.writeFile( + adapterPath, + 'process.stdin.once("data", () => process.exit(7));\n', + 'utf8' + ); + + const client = new DapClient(process.execPath, [adapterPath], directory, 5_000); + try { + await assert.rejects( + () => client.request('initialize'), + /Debug adapter exited with exit code 7/ + ); + } finally { + await client.close(); + } + }); + + test('returns a failed runInTerminal response when spawn fails', async () => { + const adapterPath = path.join(directory, 'terminal-adapter.js'); + await fs.promises.writeFile(adapterPath, ` +let buffer = Buffer.alloc(0); +let initializeRequest; +function send(message) { + const payload = Buffer.from(JSON.stringify(message)); + process.stdout.write('Content-Length: ' + payload.length + '\\r\\n\\r\\n'); + process.stdout.write(payload); +} +function accept(message) { + if (message.type === 'request' && message.command === 'initialize') { + initializeRequest = message; + send({ + seq: 10, + type: 'request', + command: 'runInTerminal', + arguments: { args: ['definitely-missing-debugmcp-executable'] } + }); + return; + } + if (message.type === 'response' && message.request_seq === 10) { + send({ + seq: 11, + type: 'response', + request_seq: initializeRequest.seq, + command: 'initialize', + success: true, + body: { + reverseRequestSucceeded: message.success, + reverseRequestMessage: message.message + } + }); + } +} +process.stdin.on('data', chunk => { + buffer = Buffer.concat([buffer, chunk]); + while (true) { + const headerEnd = buffer.indexOf('\\r\\n\\r\\n'); + if (headerEnd < 0) return; + const length = Number(/Content-Length:\\s*(\\d+)/i.exec( + buffer.subarray(0, headerEnd).toString('ascii') + )[1]); + const start = headerEnd + 4; + if (buffer.length < start + length) return; + const message = JSON.parse(buffer.subarray(start, start + length).toString('utf8')); + buffer = buffer.subarray(start + length); + accept(message); + } +}); +`, 'utf8'); + + const client = new DapClient(process.execPath, [adapterPath], directory, 5_000); + try { + const response = await client.request('initialize'); + assert.strictEqual(response.reverseRequestSucceeded, false); + assert.match(response.reverseRequestMessage, /ENOENT|not found/i); + } finally { + await client.close(); + } + }); }); diff --git a/src/test/cliDebuggingExecutor.test.ts b/src/test/cliDebuggingExecutor.test.ts index 6692b6c..9184209 100644 --- a/src/test/cliDebuggingExecutor.test.ts +++ b/src/test/cliDebuggingExecutor.test.ts @@ -128,4 +128,72 @@ process.stdin.on('data', chunk => { assert.strictEqual(continuedState.currentLine, 4); await executor.stopDebugging(); }); + + test('reports launch rejection without waiting for initialized', async () => { + const sourcePath = path.join(directory, 'app.fake'); + const adapterPath = path.join(directory, 'rejecting-adapter.js'); + await fs.promises.writeFile(sourcePath, 'run();\n', 'utf8'); + await fs.promises.writeFile(adapterPath, ` +let buffer = Buffer.alloc(0); +let sequence = 1; +function send(message) { + const payload = Buffer.from(JSON.stringify({ seq: sequence++, ...message })); + process.stdout.write('Content-Length: ' + payload.length + '\\r\\n\\r\\n'); + process.stdout.write(payload); +} +process.stdin.on('data', chunk => { + buffer = Buffer.concat([buffer, chunk]); + while (true) { + const headerEnd = buffer.indexOf('\\r\\n\\r\\n'); + if (headerEnd < 0) return; + const length = Number(/Content-Length:\\s*(\\d+)/i.exec( + buffer.subarray(0, headerEnd).toString('ascii') + )[1]); + const start = headerEnd + 4; + if (buffer.length < start + length) return; + const request = JSON.parse(buffer.subarray(start, start + length).toString('utf8')); + buffer = buffer.subarray(start + length); + if (request.command === 'initialize') { + send({ + type: 'response', + request_seq: request.seq, + command: request.command, + success: true, + body: {} + }); + } else if (request.command === 'launch') { + send({ + type: 'response', + request_seq: request.seq, + command: request.command, + success: false, + message: 'invalid launch target' + }); + } + } +}); +`, 'utf8'); + + const executor = new CliDebuggingExecutor(); + const config: CliDebugConfiguration = { + name: 'fake', + type: 'fake', + request: 'launch', + program: sourcePath, + adapterName: 'fake', + adapter: { + command: process.execPath, + args: [adapterPath], + type: 'fake', + extensions: ['.fake'] + } + }; + const startedAt = Date.now(); + await assert.rejects( + () => executor.startDebugging(directory, config), + /invalid launch target/ + ); + assert.ok(Date.now() - startedAt < 5_000); + await executor.dispose(); + }); }); diff --git a/src/test/cliOptions.test.ts b/src/test/cliOptions.test.ts new file mode 100644 index 0000000..cff3daf --- /dev/null +++ b/src/test/cliOptions.test.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'node:assert'; +import { parseOptions } from '../cli/cliOptions'; + +suite('CLI options', () => { + const adapterOptions = ['user', 'command', 'type', 'extensions', 'args', 'launch']; + + test('preserves long adapter arguments after --args', () => { + const parsed = parseOptions([ + 'python', + '--command', 'netcoredbg', + '--args', '--interpreter=vscode', + '--user' + ], adapterOptions); + + assert.deepStrictEqual(parsed.positionals, ['python']); + assert.deepStrictEqual(parsed.values.args, ['--interpreter=vscode']); + assert.ok(Object.hasOwn(parsed.values, 'user')); + }); + + test('supports passthrough for arguments matching CLI option names', () => { + const parsed = parseOptions( + ['python', '--args', '--', '--user', '--type'], + adapterOptions + ); + assert.deepStrictEqual(parsed.values.args, ['--user', '--type']); + }); + + test('rejects unknown top-level options', () => { + assert.throws( + () => parseOptions(['--unknown'], adapterOptions), + /Unknown option '--unknown'/ + ); + }); +}); diff --git a/src/test/copilotMcpConfig.test.ts b/src/test/copilotMcpConfig.test.ts index 02ec825..a984af1 100644 --- a/src/test/copilotMcpConfig.test.ts +++ b/src/test/copilotMcpConfig.test.ts @@ -77,4 +77,22 @@ suite('Copilot CLI DebugMCP host selection', () => { tools: ['*'] }); }); + + test('rejects registrations that mix HTTP and stdio fields', async () => { + await fs.promises.writeFile(configPath, JSON.stringify({ + mcpServers: { + debugmcp: { + type: 'stdio', + command: 'node', + args: [], + tools: ['*'], + url: 'http://localhost:3001/mcp' + } + } + }), 'utf8'); + await assert.rejects( + () => readCopilotDebugMcpHost(configPath), + /cannot contain url/ + ); + }); }); diff --git a/src/test/debuggingHandler.test.ts b/src/test/debuggingHandler.test.ts index 39ded7e..5bc8f8c 100644 --- a/src/test/debuggingHandler.test.ts +++ b/src/test/debuggingHandler.test.ts @@ -207,6 +207,19 @@ suite('DebuggingHandler waitForStateChange (event-driven)', () => { assert.ok(elapsed >= 200, `should wait for the ~300ms timeout, only took ${elapsed}ms`); assert.ok(elapsed < 3000, `timeout should bound the wait, took ${elapsed}ms`); }); + + test('dispose forwards cleanup to the executor', async () => { + const executor = makeExecutor(() => lineState(10)); + let disposeCalls = 0; + executor.dispose = async () => { + disposeCalls++; + }; + const handler = new DebuggingHandler(executor, {} as any, 30); + + await handler.dispose(); + + assert.strictEqual(disposeCalls, 1); + }); }); /** From 998f071e7feb76f6c89adee4e63f59a61a19e670 Mon Sep 17 00:00:00 2001 From: ozzafar <48795672+ozzafar@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:04:49 +0300 Subject: [PATCH 4/6] Bundle debug skill with CLI package Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5e39a3eb-0164-4765-9a88-9d240291ee6f --- README.md | 2 + .../architecture/agentConfigurationManager.md | 11 ++- npm/cli/README.md | 4 ++ npm/cli/package.json | 3 +- npm/cli/scripts/prepare-package.js | 7 ++ src/cli/main.ts | 9 +++ src/test/cliPackage.test.ts | 17 +++++ src/test/debugSkillInstaller.test.ts | 71 +++++++++++++++++++ src/utils/agentConfigurationManager.ts | 54 ++------------ src/utils/debugSkillInstaller.ts | 42 +++++++++++ 10 files changed, 169 insertions(+), 51 deletions(-) create mode 100644 src/test/cliPackage.test.ts create mode 100644 src/test/debugSkillInstaller.test.ts create mode 100644 src/utils/debugSkillInstaller.ts diff --git a/README.md b/README.md index 0753d47..ec27cec 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,8 @@ In automation, repeat `--agent ` to bypass the terminal prompt, for example `debugmcp configure --agent copilot-cli --agent codex`. Each configuration has one canonical `debugmcp` entry, so configuring the CLI replaces an existing extension HTTP entry rather than registering both. +The command also installs the bundled `debug-live` skill into the standard +personal skills directories. Restart configured agents to discover it. Use `debugmcp status` to inspect the GitHub Copilot CLI registration. The DebugMCP command configures only the standalone CLI. To use the interactive diff --git a/docs/architecture/agentConfigurationManager.md b/docs/architecture/agentConfigurationManager.md index b4a35d8..2ab0109 100644 --- a/docs/architecture/agentConfigurationManager.md +++ b/docs/architecture/agentConfigurationManager.md @@ -95,7 +95,12 @@ The `debug-live` Agent Skill is installed into the **standard personal skills di - **`~/.agents/skills/debug-live/`** — the cross-agent location honored by skills-compatible harnesses, including VS Code agent mode and Copilot CLI. Always installed. - **`~/.copilot/skills/debug-live/`** — Copilot's own skills path; also installed when a Copilot home directory (`~/.copilot`, or `$COPILOT_HOME`) exists. -`installDebugMCPSkill()` copies the one bundled source (`skills/debug-live/SKILL.md`) into each target with `force: true` (idempotent refresh) and removes stale legacy copies (`debug`, `really-debug`). It is agent-independent — a single shared install covers every skills-compatible harness. +The shared installer in `src/utils/debugSkillInstaller.ts` copies the one bundled +source (`skills/debug-live/`) into each target with `force: true` (idempotent +refresh) and removes stale legacy copies (`debug`, `really-debug`). Both the VS +Code extension and standalone CLI use this installer. The npm package includes +the complete skill tree, and `debugmcp configure` installs it while registering +the selected agents. This fixes issue #105: earlier builds copied the skill next to each agent's config (e.g. `Code/User/skills/` for VS Code Copilot), a directory no harness scans, so the skill never loaded. Installing to `~/.agents/skills/` — which VS Code agent mode does scan — makes it discoverable. @@ -104,7 +109,9 @@ This fixes issue #105: earlier builds copied the skill next to each agent's conf - Class definition: `src/utils/agentConfigurationManager.ts` - Agent definitions: `getSupportedAgents()` - Config writing: `addDebugMCPToAgent()` -- Skill install: `installDebugMCPSkill()` / `getSkillInstallTargets()` / `ensureSkillRegistered()` +- Shared skill install: `src/utils/debugSkillInstaller.ts` +- Extension skill orchestration: `installDebugMCPSkill()` / `ensureSkillRegistered()` +- Standalone skill orchestration: `src/cli/main.ts` (`configureAgents()`) - Codex TOML upsert: `upsertCodexDebugMCPConfig()` - Path detection: `getConfigBasePath()` - Popup logic: `shouldShowPopup()`, `showAgentSelectionPopup()` diff --git a/npm/cli/README.md b/npm/cli/README.md index 174642e..86b62d8 100644 --- a/npm/cli/README.md +++ b/npm/cli/README.md @@ -18,6 +18,10 @@ Configure GitHub Copilot CLI once: debugmcp configure --agent copilot-cli ``` +This also installs the bundled `debug-live` skill into the standard personal +skills directories. Restart the agent after configuration so it discovers the +skill. + ## Configure a project DebugMCP does not discover, install, or choose debugger installations. Register diff --git a/npm/cli/package.json b/npm/cli/package.json index 6409f48..270b099 100644 --- a/npm/cli/package.json +++ b/npm/cli/package.json @@ -1,6 +1,6 @@ { "name": "debugmcp", - "version": "0.1.0", + "version": "0.1.1", "description": "Standalone MCP debugger host for explicitly configured Debug Adapter Protocol adapters.", "license": "MIT", "author": "Microsoft Corporation", @@ -25,6 +25,7 @@ }, "files": [ "dist/debugmcp.js", + "skills/debug-live", "README.md", "LICENSE.txt" ], diff --git a/npm/cli/scripts/prepare-package.js b/npm/cli/scripts/prepare-package.js index 34127f0..8707114 100644 --- a/npm/cli/scripts/prepare-package.js +++ b/npm/cli/scripts/prepare-package.js @@ -7,6 +7,7 @@ const { execFileSync } = require('node:child_process'); const packageRoot = path.resolve(__dirname, '..'); const repositoryRoot = path.resolve(packageRoot, '..', '..'); const outputDirectory = path.join(packageRoot, 'dist'); +const skillsDirectory = path.join(packageRoot, 'skills'); execFileSync(process.execPath, [path.join(repositoryRoot, 'esbuild.js'), '--production'], { cwd: repositoryRoot, @@ -22,3 +23,9 @@ fs.copyFileSync( path.join(repositoryRoot, 'LICENSE.txt'), path.join(packageRoot, 'LICENSE.txt') ); +fs.rmSync(skillsDirectory, { recursive: true, force: true }); +fs.cpSync( + path.join(repositoryRoot, 'skills', 'debug-live'), + path.join(skillsDirectory, 'debug-live'), + { recursive: true } +); diff --git a/src/cli/main.ts b/src/cli/main.ts index af28a92..96b8768 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -28,6 +28,10 @@ import { selectAgentsInteractively } from './agentSelector'; import { getSupportedAgents } from '../utils/agentCatalog'; +import { + getDebugSkillInstallTargets, + installDebugSkill +} from '../utils/debugSkillInstaller'; import { createShorthandAdapter } from './adapterShorthand'; import { ParsedOptions, parseOptions } from './cliOptions'; @@ -88,6 +92,11 @@ async function configureAgents(args: string[]): Promise { `Configured ${agent.displayName} for the standalone DebugMCP CLI in ${agent.configPath}.\n` ); } + const bundledSkillPath = path.resolve(__dirname, '..', 'skills', 'debug-live'); + for (const destination of getDebugSkillInstallTargets()) { + await installDebugSkill(bundledSkillPath, destination); + process.stdout.write(`Installed debug-live skill in ${destination}.\n`); + } process.stdout.write('Restart the selected agents to load DebugMCP.\n'); } diff --git a/src/test/cliPackage.test.ts b/src/test/cliPackage.test.ts new file mode 100644 index 0000000..2446f8f --- /dev/null +++ b/src/test/cliPackage.test.ts @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'node:assert'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +suite('CLI npm package', () => { + const packageRoot = path.resolve(__dirname, '..', '..', 'npm', 'cli'); + + test('publishes the bundled debug-live skill', () => { + const manifest = JSON.parse( + fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8') + ) as { files?: string[] }; + + assert.ok(manifest.files?.includes('skills/debug-live')); + }); +}); diff --git a/src/test/debugSkillInstaller.test.ts b/src/test/debugSkillInstaller.test.ts new file mode 100644 index 0000000..fdd254a --- /dev/null +++ b/src/test/debugSkillInstaller.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + getDebugSkillInstallTargets, + installDebugSkill +} from '../utils/debugSkillInstaller'; + +suite('debug-live skill installer', () => { + let directory: string; + + setup(async () => { + directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'debugmcp-skill-')); + }); + + teardown(async () => { + await fs.promises.rm(directory, { recursive: true, force: true }); + }); + + test('includes Copilot target when its home exists', async () => { + const home = path.join(directory, 'home'); + const copilotHome = path.join(home, '.copilot'); + await fs.promises.mkdir(copilotHome, { recursive: true }); + + assert.deepStrictEqual(getDebugSkillInstallTargets(home, copilotHome), [ + path.join(home, '.agents', 'skills', 'debug-live'), + path.join(copilotHome, 'skills', 'debug-live') + ]); + }); + + test('copies the complete skill and removes legacy names', async () => { + const source = path.join(directory, 'bundled', 'debug-live'); + const destination = path.join(directory, 'home', '.agents', 'skills', 'debug-live'); + const skillsDirectory = path.dirname(destination); + await fs.promises.mkdir(path.join(source, 'references'), { recursive: true }); + await fs.promises.writeFile(path.join(source, 'SKILL.md'), '# Debug live\n', 'utf8'); + await fs.promises.writeFile( + path.join(source, 'references', 'python.md'), + '# Python\n', + 'utf8' + ); + await fs.promises.mkdir(path.join(skillsDirectory, 'debug'), { recursive: true }); + await fs.promises.mkdir(path.join(skillsDirectory, 'really-debug'), { recursive: true }); + + await installDebugSkill(source, destination); + + assert.strictEqual( + await fs.promises.readFile(path.join(destination, 'SKILL.md'), 'utf8'), + '# Debug live\n' + ); + assert.strictEqual( + await fs.promises.readFile(path.join(destination, 'references', 'python.md'), 'utf8'), + '# Python\n' + ); + assert.strictEqual(fs.existsSync(path.join(skillsDirectory, 'debug')), false); + assert.strictEqual(fs.existsSync(path.join(skillsDirectory, 'really-debug')), false); + }); + + test('rejects a package without the skill entry point', async () => { + await assert.rejects( + () => installDebugSkill( + path.join(directory, 'missing'), + path.join(directory, 'destination') + ), + /Bundled debug-live skill not found/ + ); + }); +}); diff --git a/src/utils/agentConfigurationManager.ts b/src/utils/agentConfigurationManager.ts index 79e3076..41ee052 100644 --- a/src/utils/agentConfigurationManager.ts +++ b/src/utils/agentConfigurationManager.ts @@ -3,13 +3,16 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { AgentInfo, getSupportedAgents, JsonAgentInfo, TomlAgentInfo } from './agentCatalog'; +import { + getDebugSkillInstallTargets, + installDebugSkill +} from './debugSkillInstaller'; import { selectCopilotDebugMcpHost } from '../cli/copilotMcpConfig'; export { AgentInfo, JsonAgentInfo, TomlAgentInfo } from './agentCatalog'; @@ -166,26 +169,6 @@ export class AgentConfigurationManager { } } - /** - * Get cross-platform configuration base path - */ - /** - * Personal skill install targets, following the Agent Skills open standard - * (agentskills.io). `~/.agents/skills/` is the cross-agent location honored - * by skills-compatible harnesses (including VS Code agent mode and Copilot - * CLI); we also install into `~/.copilot/skills/` when a Copilot home exists. - * See issue #105. - */ - private getSkillInstallTargets(): string[] { - const home = os.homedir(); - const targets = [path.join(home, '.agents', 'skills', 'debug-live')]; - const copilotHome = process.env.COPILOT_HOME || path.join(home, '.copilot'); - if (fs.existsSync(copilotHome)) { - targets.push(path.join(copilotHome, 'skills', 'debug-live')); - } - return targets; - } - /** * Path to the debugmcp skill bundled with the extension. */ @@ -210,13 +193,10 @@ export class AgentConfigurationManager { } let primaryDestination: string | null = null; - for (const destination of this.getSkillInstallTargets()) { - const skillsDir = path.dirname(destination); + for (const destination of getDebugSkillInstallTargets()) { try { - await fs.promises.mkdir(skillsDir, { recursive: true }); - await fs.promises.cp(bundledSkillPath, destination, { recursive: true, force: true }); + await installDebugSkill(bundledSkillPath, destination); console.log(`Installed debugmcp skill at ${destination}`); - await this.removeLegacySkills(skillsDir); if (!primaryDestination) { primaryDestination = destination; } @@ -228,28 +208,6 @@ export class AgentConfigurationManager { return primaryDestination; } - /** - * Remove stale skill copies from earlier builds (`debug` in 1.2.0, later - * `really-debug`) so users don't end up with competing entries alongside - * the current `debug-live` skill. - */ - private async removeLegacySkills(skillsDir: string): Promise { - const legacyDestinations = [ - path.join(skillsDir, 'debug'), - path.join(skillsDir, 'really-debug'), - ]; - for (const legacyDestination of legacyDestinations) { - if (fs.existsSync(legacyDestination)) { - try { - await fs.promises.rm(legacyDestination, { recursive: true, force: true }); - console.log(`Removed legacy debugmcp skill at ${legacyDestination}`); - } catch (cleanupError) { - console.warn(`Failed to remove legacy debugmcp skill at ${legacyDestination}:`, cleanupError); - } - } - } - } - /** * Get list of supported agents */ diff --git a/src/utils/debugSkillInstaller.ts b/src/utils/debugSkillInstaller.ts new file mode 100644 index 0000000..85d074c --- /dev/null +++ b/src/utils/debugSkillInstaller.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const skillName = 'debug-live'; +const legacySkillNames = ['debug', 'really-debug']; + +export function getDebugSkillInstallTargets( + homeDirectory = os.homedir(), + copilotHome = process.env.COPILOT_HOME || path.join(homeDirectory, '.copilot') +): string[] { + const targets = [path.join(homeDirectory, '.agents', 'skills', skillName)]; + if (fs.existsSync(copilotHome)) { + targets.push(path.join(copilotHome, 'skills', skillName)); + } + return targets; +} + +export async function installDebugSkill( + bundledSkillPath: string, + destination: string +): Promise { + const skillEntry = path.join(bundledSkillPath, 'SKILL.md'); + if (!fs.existsSync(skillEntry)) { + throw new Error(`Bundled debug-live skill not found at ${bundledSkillPath}.`); + } + + const skillsDirectory = path.dirname(destination); + await fs.promises.mkdir(skillsDirectory, { recursive: true }); + await fs.promises.cp(bundledSkillPath, destination, { + recursive: true, + force: true + }); + await Promise.all(legacySkillNames.map(async legacySkillName => { + await fs.promises.rm(path.join(skillsDirectory, legacySkillName), { + recursive: true, + force: true + }); + })); +} From f9b83b8c33e23604ccb9f4484d80db79247bf30d Mon Sep 17 00:00:00 2001 From: ozzafar <48795672+ozzafar@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:01:32 +0300 Subject: [PATCH 5/6] Announce standalone DebugMCP CLI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5e39a3eb-0164-4765-9a88-9d240291ee6f --- README.md | 12 +++++++++++- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ec27cec..d74ca29 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,20 @@ Let AI agents debug your code inside VS Code - set breakpoints, step through exe [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![VS Code](https://img.shields.io/badge/VS%20Code-1.104.0+-blue.svg)](https://code.visualstudio.com/) -[![Version](https://img.shields.io/badge/version-2.3.7-green.svg)](https://github.com/microsoft/DebugMCP) +[![Version](https://img.shields.io/badge/version-2.4.0-green.svg)](https://github.com/microsoft/DebugMCP) [![VS Marketplace](https://img.shields.io/badge/VS%20Marketplace-Install-blue.svg)](https://marketplace.visualstudio.com/items?itemName=ozzafar.debugmcpextension) > ⭐ **If you find DebugMCP useful, please [star the repo on GitHub](https://github.com/microsoft/DebugMCP)!** It helps others discover the project and motivates continued development. > **📢 Developers Notice**: This extension is maintained by [ozzafar@microsoft.com](mailto:ozzafar@microsoft.com) and [orbarila@microsoft.com](mailto:orbarila@microsoft.com). We welcome feedback and contributions to help improve this extension. +> 🚀 **DebugMCP CLI is now available on npm!** Debug directly from the +> terminal without VS Code by connecting AI coding agents to explicitly +> configured Debug Adapter Protocol (DAP) adapters. Install it with +> `npm install --global debugmcp`, then run +> `debugmcp configure --agent copilot-cli`. The CLI also installs the +> `debug-live` skill automatically. **[View the package on npm](https://www.npmjs.com/package/debugmcp)** + > 🎬 Watch DebugMCP in action — your AI assistant autonomously sets breakpoints, steps through code, and inspects variables directly in VS Code.

@@ -19,6 +26,9 @@ Let AI agents debug your code inside VS Code - set breakpoints, step through exe ## ✨ What's New +### 2.4 +- **Standalone DebugMCP CLI** — install [`debugmcp`](https://www.npmjs.com/package/debugmcp) from npm and give MCP-compatible agents direct access to explicitly configured DAP adapters without running VS Code. The CLI supports Python, C#, C++, and custom adapter registrations, configures supported agents, and installs the `debug-live` skill automatically. + ### 2.2 - **Cross-agent `debug-live` skill install** — the systematic debugging workflow ships as an [Agent Skill](https://agentskills.io) and is now installed into the **standard skills directories** — `~/.agents/skills/` (the cross-agent location honored by skills-compatible harnesses, including VS Code agent mode) and `~/.copilot/skills/` when present — so it's discoverable everywhere instead of being copied next to each agent's config where nothing scans it (fixes [#105](https://github.com/microsoft/DebugMCP/issues/105), where VS Code never loaded the skill). The server also advertises MCP `instructions` and the `start_debugging` tool points at the skill for the full workflow. - **Pause running programs** — new `pause_execution` tool interrupts a freely-running program and stops at its current location, even with no breakpoint set (great for busy loops and embedded/bare-metal targets), so you can then inspect state or step from there. diff --git a/package-lock.json b/package-lock.json index e740608..5d7e89d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "debugmcpextension", - "version": "2.3.7", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "debugmcpextension", - "version": "2.3.7", + "version": "2.4.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/package.json b/package.json index d4281b4..4743edf 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "debugmcpextension", "displayName": "DebugMCP — Agentic Debugging for VS Code, Cursor & More", "description": "Your AI agent debugs for you — right inside VS Code, Cursor & other VS Code-based editors. Let Copilot, Cline, Cursor, Codex & any MCP agent set breakpoints, step through code, and inspect variables live instead of guessing from logs.", - "version": "2.3.7", + "version": "2.4.0", "publisher": "ozzafar", "author": { "name": "Oz Zafar", From 28ccfb5a59e9e192e243b4f54321bf57b42530db Mon Sep 17 00:00:00 2001 From: ozzafar <48795672+ozzafar@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:21:37 +0300 Subject: [PATCH 6/6] Expand CLI language shorthands Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5e39a3eb-0164-4765-9a88-9d240291ee6f --- README.md | 29 ++++++++++--------- npm/cli/README.md | 3 +- src/cli/adapterShorthand.ts | 48 +++++++++++++++++++++++++++++++ src/test/adapterShorthand.test.ts | 26 +++++++++++++++++ 4 files changed, 92 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index d74ca29..bd752d2 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,19 @@ Let AI agents debug your code inside VS Code - set breakpoints, step through exe [![Version](https://img.shields.io/badge/version-2.4.0-green.svg)](https://github.com/microsoft/DebugMCP) [![VS Marketplace](https://img.shields.io/badge/VS%20Marketplace-Install-blue.svg)](https://marketplace.visualstudio.com/items?itemName=ozzafar.debugmcpextension) -> ⭐ **If you find DebugMCP useful, please [star the repo on GitHub](https://github.com/microsoft/DebugMCP)!** It helps others discover the project and motivates continued development. - -> **📢 Developers Notice**: This extension is maintained by [ozzafar@microsoft.com](mailto:ozzafar@microsoft.com) and [orbarila@microsoft.com](mailto:orbarila@microsoft.com). We welcome feedback and contributions to help improve this extension. > 🚀 **DebugMCP CLI is now available on npm!** Debug directly from the -> terminal without VS Code by connecting AI coding agents to explicitly -> configured Debug Adapter Protocol (DAP) adapters. Install it with +> terminal **without requiring VS Code or any IDE at all** by connecting AI +> coding agents to explicitly configured Debug Adapter Protocol (DAP) adapters. +> The debug adapter and target process **run in the background**. Install it with > `npm install --global debugmcp`, then run > `debugmcp configure --agent copilot-cli`. The CLI also installs the > `debug-live` skill automatically. **[View the package on npm](https://www.npmjs.com/package/debugmcp)** +> ⭐ **If you find DebugMCP useful, please [star the repo on GitHub](https://github.com/microsoft/DebugMCP)!** It helps others discover the project and motivates continued development. + +> **📢 Developers Notice**: This extension is maintained by [ozzafar@microsoft.com](mailto:ozzafar@microsoft.com) and [orbarila@microsoft.com](mailto:orbarila@microsoft.com). We welcome feedback and contributions to help improve this extension. + > 🎬 Watch DebugMCP in action — your AI assistant autonomously sets breakpoints, steps through code, and inspects variables directly in VS Code.

@@ -27,7 +29,7 @@ Let AI agents debug your code inside VS Code - set breakpoints, step through exe ## ✨ What's New ### 2.4 -- **Standalone DebugMCP CLI** — install [`debugmcp`](https://www.npmjs.com/package/debugmcp) from npm and give MCP-compatible agents direct access to explicitly configured DAP adapters without running VS Code. The CLI supports Python, C#, C++, and custom adapter registrations, configures supported agents, and installs the `debug-live` skill automatically. +- **Standalone DebugMCP CLI** — install [`debugmcp`](https://www.npmjs.com/package/debugmcp) from npm and give MCP-compatible agents direct access to explicitly configured DAP adapters without running VS Code. The CLI supports any language with a DAP adapter that communicates over stdio, configures supported agents, and installs the `debug-live` skill automatically. ### 2.2 - **Cross-agent `debug-live` skill install** — the systematic debugging workflow ships as an [Agent Skill](https://agentskills.io) and is now installed into the **standard skills directories** — `~/.agents/skills/` (the cross-agent location honored by skills-compatible harnesses, including VS Code agent mode) and `~/.copilot/skills/` when present — so it's discoverable everywhere instead of being copied next to each agent's config where nothing scans it (fixes [#105](https://github.com/microsoft/DebugMCP/issues/105), where VS Code never loaded the skill). The server also advertises MCP `instructions` and the `start_debugging` tool points at the skill for the full workflow. @@ -130,11 +132,14 @@ configuration. Project registrations override registrations with the same name in user configuration. Language shorthands derive the DAP type and file extensions for `python`, -`csharp`, and `cpp`. The command remains explicit so the selected environment -determines which adapter installation is used. Compiled-language registrations -can provide the executable in `--launch`; values support `${workspaceFolder}`, -`${file}`, `${fileDirname}`, and `${fileBasenameNoExtension}`. Languages without -a shorthand must provide `--type` and `--extensions` explicitly. +`csharp`, `dotnet`, `cpp`, `c`, `javascript`, `typescript`, `node`, `java`, +`go`, `rust`, `ruby`, `php`, `swift`, and `dart`. These are configuration +conveniences, not a language support boundary. The command remains explicit so +the selected environment determines which adapter installation is used. +Languages without a shorthand provide `--type` and `--extensions` explicitly. +Compiled-language registrations can provide the executable in `--launch`; +values support `${workspaceFolder}`, `${file}`, `${fileDirname}`, and +`${fileBasenameNoExtension}`. The current CLI supports adapters that speak DAP over stdio. A registration can provide adapter-specific launch properties with `--launch` followed by a JSON @@ -588,5 +593,3 @@ If DebugMCP has helped you debug faster, please consider giving it a star on Git ## License MIT License - See [LICENSE](LICENSE.txt) for details - -This extension was created by **Oz Zafar**, **Ori Bar-Ilan** and **Karin Brisker**. diff --git a/npm/cli/README.md b/npm/cli/README.md index 86b62d8..6451c99 100644 --- a/npm/cli/README.md +++ b/npm/cli/README.md @@ -2,7 +2,8 @@ DebugMCP CLI lets MCP-capable coding agents control language debuggers without running VS Code. It communicates with explicitly configured Debug Adapter -Protocol (DAP) adapters over stdio. +Protocol (DAP) adapters over stdio, supporting any language for which such an +adapter is available. ## Install diff --git a/src/cli/adapterShorthand.ts b/src/cli/adapterShorthand.ts index bc4c6e2..935a60b 100644 --- a/src/cli/adapterShorthand.ts +++ b/src/cli/adapterShorthand.ts @@ -16,9 +16,57 @@ const adapterLanguages: Record = { type: 'coreclr', extensions: ['.cs'] }, + dotnet: { + type: 'coreclr', + extensions: ['.cs'] + }, cpp: { type: 'cppvsdbg', extensions: ['.cpp', '.cc', '.cxx', '.c', '.h', '.hpp'] + }, + c: { + type: 'cppvsdbg', + extensions: ['.c', '.h'] + }, + javascript: { + type: 'pwa-node', + extensions: ['.js', '.mjs', '.cjs', '.jsx'] + }, + typescript: { + type: 'pwa-node', + extensions: ['.ts', '.mts', '.cts', '.tsx'] + }, + node: { + type: 'pwa-node', + extensions: ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.mts', '.cts', '.tsx'] + }, + java: { + type: 'java', + extensions: ['.java'] + }, + go: { + type: 'go', + extensions: ['.go'] + }, + rust: { + type: 'lldb', + extensions: ['.rs'] + }, + ruby: { + type: 'rdbg', + extensions: ['.rb'] + }, + php: { + type: 'php', + extensions: ['.php'] + }, + swift: { + type: 'lldb', + extensions: ['.swift'] + }, + dart: { + type: 'dart', + extensions: ['.dart'] } }; diff --git a/src/test/adapterShorthand.test.ts b/src/test/adapterShorthand.test.ts index 1794139..96639ae 100644 --- a/src/test/adapterShorthand.test.ts +++ b/src/test/adapterShorthand.test.ts @@ -73,6 +73,32 @@ suite('CLI adapter shorthand', () => { assert.strictEqual(createShorthandAdapter('cpp', ['OpenDebugAD7.exe']).type, 'cppvsdbg'); }); + test('derives metadata for popular language ecosystems', () => { + const expected: Record = { + dotnet: { type: 'coreclr', extensions: ['.cs'] }, + c: { type: 'cppvsdbg', extensions: ['.c', '.h'] }, + javascript: { type: 'pwa-node', extensions: ['.js', '.mjs', '.cjs', '.jsx'] }, + typescript: { type: 'pwa-node', extensions: ['.ts', '.mts', '.cts', '.tsx'] }, + node: { + type: 'pwa-node', + extensions: ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.mts', '.cts', '.tsx'] + }, + java: { type: 'java', extensions: ['.java'] }, + go: { type: 'go', extensions: ['.go'] }, + rust: { type: 'lldb', extensions: ['.rs'] }, + ruby: { type: 'rdbg', extensions: ['.rb'] }, + php: { type: 'php', extensions: ['.php'] }, + swift: { type: 'lldb', extensions: ['.swift'] }, + dart: { type: 'dart', extensions: ['.dart'] } + }; + + for (const [language, metadata] of Object.entries(expected)) { + const adapter = createShorthandAdapter(language, ['adapter']); + assert.strictEqual(adapter.type, metadata.type, language); + assert.deepStrictEqual(adapter.extensions, metadata.extensions, language); + } + }); + test('requires explicit metadata for an unknown language', () => { assert.throws( () => createShorthandAdapter('custom', ['custom-dap']),