From c83d4d7381671941b4a95a5a36dd3f03daa192fb Mon Sep 17 00:00:00 2001 From: ozzafar <48795672+ozzafar@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:46:49 +0300 Subject: [PATCH 1/2] Implement multi-window debugging support with control server and routing handler - Introduced ControlServer to handle local debug operations for each window. - Added RoutingDebuggingHandler to route operations to the appropriate window based on workspace ownership. - Enhanced DebuggingExecutor with a timeout mechanism for DAP requests to prevent hanging. - Updated extension activation to initialize control server and workspace registry for managing multiple windows. - Implemented workspace registry to track active windows and their associated workspace folders. - Added unit tests for routing logic and workspace registry functionality to ensure correct operation. --- docs/architecture/debugMCPServer.md | 40 ++++- docs/architecture/debuggingExecutor.md | 4 + src/controlServer.ts | 112 +++++++++++++ src/debugMCPServer.ts | 177 +++++++++++--------- src/debuggingExecutor.ts | 39 ++++- src/extension.ts | 113 +++++++++++-- src/routingDebuggingHandler.ts | 220 +++++++++++++++++++++++++ src/test/routing.test.ts | 161 ++++++++++++++++++ src/test/workspaceRegistry.test.ts | 115 +++++++++++++ src/utils/workspaceRegistry.ts | 154 +++++++++++++++++ 10 files changed, 1041 insertions(+), 94 deletions(-) create mode 100644 src/controlServer.ts create mode 100644 src/routingDebuggingHandler.ts create mode 100644 src/test/routing.test.ts create mode 100644 src/test/workspaceRegistry.test.ts create mode 100644 src/utils/workspaceRegistry.ts diff --git a/docs/architecture/debugMCPServer.md b/docs/architecture/debugMCPServer.md index 3e7e97a..6a79940 100644 --- a/docs/architecture/debugMCPServer.md +++ b/docs/architecture/debugMCPServer.md @@ -35,6 +35,25 @@ AI Agent (MCP Client) ## Key Concepts +### Multi-window routing (multiple VS Code windows / repos) + +The MCP endpoint uses a fixed port, but every open VS Code window activates the +extension. To avoid debugging the wrong workspace when several windows are open: + +- **Every window** starts a loopback `ControlServer` (`src/controlServer.ts`) that runs + debug operations against *its own* `DebuggingHandler`, and advertises its workspace + folders (plus control port + token) in a shared file registry + (`src/utils/workspaceRegistry.ts`). +- **One window** wins the public MCP port and becomes the **router**. Its per-MCP-session + handler is a `RoutingDebuggingHandler` (`src/routingDebuggingHandler.ts`) that resolves + the target window from the request's `workingDirectory`/`fileFullPath` and forwards the + operation to that window's `ControlServer`. The target is cached per session so hint-less + follow-ups (step/continue/inspect) reach the same window. If the router window closes, + a worker window takes over the port on retry. + +`DebugMCPServer` builds one handler **per MCP session** via a handler factory, which is +what lets concurrent agent sessions drive debuggers in different repos simultaneously. + ### Tools only — no resources, no instructions `DebugMCPServer` exposes **tools only**. Procedural workflow guidance (when to debug, @@ -53,11 +72,30 @@ Uses stateless HTTP POST requests for MCP communication. The express server expo Each request creates a new stateless `StreamableHTTPServerTransport` instance that is closed when the HTTP response closes. The server returns JSON-RPC error responses (not HTML pages) for malformed payloads or unsupported methods to keep client behavior predictable. +### Bounded operations (no hung tool calls) + +Every layer that a tool call passes through is time-bounded so a wedged debug +adapter or an unresponsive worker window can never leave an MCP request pending +forever (which surfaces to clients as "Request timed out" and makes the whole +server look stuck). The bounds are nested innermost-first so the most specific +error wins: + +- **DAP request** (`DebuggingExecutor.dapRequest`) caps each `customRequest` + (stackTrace/scopes/variables/evaluate). +- **Router → worker forward** (`RoutingDebuggingHandler`) caps the loopback + round-trip to a worker window's `ControlServer` (`timeoutInSeconds + 15s`). +- **Tool boundary** (`DebugMCPServer.runTool`) is the final backstop around every + tool invocation (`timeoutInSeconds + 30s`), guaranteeing the client always + gets a prompt response. + ## Key Code Locations - Class definition: `src/debugMCPServer.ts` - Tool registration: `setupTools()` method (uses `McpServer.registerTool()`) -- Server startup: `start()` method (creates express app with `/mcp` route) +- Server startup / router election: `start()` method (returns whether this window owns the port) +- Per-window control server: `src/controlServer.ts` +- Cross-window routing handler: `src/routingDebuggingHandler.ts` +- Shared window registry: `src/utils/workspaceRegistry.ts` - Agent Skill (companion, not part of the MCP surface): `skills/debug-live/SKILL.md` ## Exposed Tools diff --git a/docs/architecture/debuggingExecutor.md b/docs/architecture/debuggingExecutor.md index 728f661..4ecdf15 100644 --- a/docs/architecture/debuggingExecutor.md +++ b/docs/architecture/debuggingExecutor.md @@ -58,6 +58,9 @@ For data retrieval, the executor uses DAP's custom request mechanism: | `variables` | Get variables within a scope | | `evaluate` | Evaluate expressions in REPL context | +All custom requests go through `dapRequest()`, which caps each call so an +unresponsive adapter rejects with an error instead of hanging the caller. + ### Session Readiness A session is considered "ready" when: @@ -80,6 +83,7 @@ This handles cases where the debugger is still initializing (common with Python) - Interface: `IDebuggingExecutor` - State retrieval: `getCurrentDebugState()` - DAP requests: `getVariables()`, `evaluateExpression()` +- Bounded DAP calls: `dapRequest()` - Session readiness: `hasActiveSession()` ## Breakpoint Management diff --git a/src/controlServer.ts b/src/controlServer.ts new file mode 100644 index 0000000..cf72d1b --- /dev/null +++ b/src/controlServer.ts @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. + +import * as http from 'http'; +import { IDebuggingHandler } from './debuggingHandler'; +import { logger } from './utils/logger'; + +/** + * Per-window loopback HTTP server that runs debug operations against this + * window's local DebuggingHandler. The router window forwards each MCP tool + * call here so debugging happens in the window that owns the workspace. + * + * Bound to 127.0.0.1 and gated by a per-window token read from the registry. + */ +export class ControlServer { + private server: http.Server | undefined; + private boundPort = 0; + + constructor( + private readonly handler: IDebuggingHandler, + private readonly token: string + ) {} + + /** The ephemeral loopback port chosen by the OS, or 0 before start(). */ + public getPort(): number { + return this.boundPort; + } + + /** Start listening on an ephemeral loopback port. Resolves with the port. */ + public async start(): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => this.onRequest(req, res)); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + this.boundPort = typeof address === 'object' && address ? address.port : 0; + this.server = server; + logger.info(`DebugMCP control server listening on 127.0.0.1:${this.boundPort}`); + resolve(this.boundPort); + }); + }); + } + + /** Stop listening. */ + public async stop(): Promise { + if (this.server) { + await new Promise((resolve) => this.server!.close(() => resolve())); + this.server = undefined; + } + } + + private onRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + if (req.method !== 'POST' || req.url !== '/op') { + res.writeHead(404).end(); + return; + } + if (req.headers['x-debugmcp-token'] !== this.token) { + res.writeHead(403).end(); + return; + } + + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', async () => { + try { + const { op, args } = JSON.parse(body || '{}') as { op: string; args?: unknown }; + const result = await this.dispatch(op, args ?? {}); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ result })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: message })); + } + }); + } + + /** Map a control op name onto the local debugging handler. */ + private dispatch(op: string, args: any): Promise { + switch (op) { + case 'handleStartDebugging': + return this.handler.handleStartDebugging(args); + case 'handleStopDebugging': + return this.handler.handleStopDebugging(); + case 'handleStepOver': + return this.handler.handleStepOver(); + case 'handleStepInto': + return this.handler.handleStepInto(); + case 'handleStepOut': + return this.handler.handleStepOut(); + case 'handleContinue': + return this.handler.handleContinue(); + case 'handleRestart': + return this.handler.handleRestart(); + case 'handleAddBreakpoint': + return this.handler.handleAddBreakpoint(args); + case 'handleRemoveBreakpoint': + return this.handler.handleRemoveBreakpoint(args); + case 'handleClearAllBreakpoints': + return this.handler.handleClearAllBreakpoints(); + case 'handleListBreakpoints': + return this.handler.handleListBreakpoints(); + case 'handleGetVariables': + return this.handler.handleGetVariables(args); + case 'handleEvaluateExpression': + return this.handler.handleEvaluateExpression(args); + default: + return Promise.reject(new Error(`Unknown control op: ${op}`)); + } + } +} diff --git a/src/debugMCPServer.ts b/src/debugMCPServer.ts index 30f8c07..121568f 100644 --- a/src/debugMCPServer.ts +++ b/src/debugMCPServer.ts @@ -104,19 +104,36 @@ export class DebugMCPServer { private port: number; private hosts: string[]; private initialized: boolean = false; - private debuggingHandler: IDebuggingHandler; + // Overall backstop (ms) around every tool call so a wedged adapter or + // unresponsive worker can't leave an MCP request pending forever. Kept + // above the handler's own timeout so it only trips when truly stuck. + private toolBackstopMs: number; + // Per-MCP-session handler factory. A fresh handler per session lets each + // agent session keep its own routing target (repo/window) when proxying. + private handlerFactory: () => IDebuggingHandler; // Active Streamable-HTTP transports keyed by MCP session id. The transport // is created on `initialize` and reused for that session's subsequent // POST (requests), GET (server->client SSE stream), and DELETE (teardown). private transports: Record = {}; - constructor(port: number, timeoutInSeconds: number, host: string | string[] = ['127.0.0.1', '::1']) { - // Initialize the debugging components with dependency injection - const executor = new DebuggingExecutor(); - const configManager = new ConfigurationManager(); - this.debuggingHandler = new DebuggingHandler(executor, configManager, timeoutInSeconds); + constructor( + port: number, + timeoutInSeconds: number, + host: string | string[] = ['127.0.0.1', '::1'], + handlerFactory?: () => IDebuggingHandler + ) { + 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.port = port; this.hosts = Array.isArray(host) ? host : [host]; + this.toolBackstopMs = timeoutInSeconds * 1000 + 30_000; } /** @@ -145,10 +162,37 @@ export class DebugMCPServer { name: 'debugmcp', version: '1.0.0', }); - this.setupTools(server); + this.setupTools(server, this.handlerFactory()); return server; } + /** + * Run a tool's handler with an overall backstop timeout so a wedged adapter + * or unresponsive worker can't hang the MCP request. Last line of defense. + */ + private async runTool( + toolName: string, + run: () => Promise + ): Promise<{ content: { type: 'text'; text: string }[] }> { + let timer: ReturnType | undefined; + const backstop = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error( + `Tool "${toolName}" did not complete within ${Math.round(this.toolBackstopMs / 1000)}s and was aborted. ` + + 'The debug adapter or target VS Code window may be unresponsive. Try stop_debugging and retry, or reload the window.' + )); + }, this.toolBackstopMs); + }); + try { + const result = await Promise.race([run(), backstop]); + return { content: [{ type: 'text' as const, text: result }] }; + } finally { + if (timer) { + clearTimeout(timer); + } + } + } + /** * Setup MCP tools that delegate to the debugging handler. * @@ -157,7 +201,7 @@ export class DebugMCPServer { * language-specific quirks) lives in the companion Agent Skill at * `skills/debug-live/SKILL.md`. */ - private setupTools(server: McpServer) { + private setupTools(server: McpServer, debuggingHandler: IDebuggingHandler) { // Start debugging tool server.registerTool('start_debugging', { description: 'Start a VS Code debug session for a source file, optionally for a single test method. ' + @@ -176,58 +220,38 @@ export class DebugMCPServer { 'If omitted, DebugMCP uses its default generated configuration.' ), }, - }, async (args: { fileFullPath: string; workingDirectory: string; testName?: string; configurationName?: string }) => { - const result = await this.debuggingHandler.handleStartDebugging(args); - return { content: [{ type: 'text' as const, text: result }] }; - }); + }, async (args: { fileFullPath: string; workingDirectory: string; testName?: string; configurationName?: string }) => + this.runTool('start_debugging', () => debuggingHandler.handleStartDebugging(args))); // Stop debugging tool server.registerTool('stop_debugging', { description: 'Stop the current debug session', - }, async () => { - const result = await this.debuggingHandler.handleStopDebugging(); - return { content: [{ type: 'text' as const, text: result }] }; - }); + }, async () => this.runTool('stop_debugging', () => debuggingHandler.handleStopDebugging())); // Step over tool server.registerTool('step_over', { description: 'Execute the current line of code without diving into it.', - }, async () => { - const result = await this.debuggingHandler.handleStepOver(); - return { content: [{ type: 'text' as const, text: result }] }; - }); + }, async () => this.runTool('step_over', () => debuggingHandler.handleStepOver())); // Step into tool server.registerTool('step_into', { description: 'Dive into the current line of code.', - }, async () => { - const result = await this.debuggingHandler.handleStepInto(); - return { content: [{ type: 'text' as const, text: result }] }; - }); + }, async () => this.runTool('step_into', () => debuggingHandler.handleStepInto())); // Step out tool server.registerTool('step_out', { description: 'Step out of the current function', - }, async () => { - const result = await this.debuggingHandler.handleStepOut(); - return { content: [{ type: 'text' as const, text: result }] }; - }); + }, async () => this.runTool('step_out', () => debuggingHandler.handleStepOut())); // Continue execution tool server.registerTool('continue_execution', { description: 'Resume program execution until the next breakpoint is hit or the program completes.', - }, async () => { - const result = await this.debuggingHandler.handleContinue(); - return { content: [{ type: 'text' as const, text: result }] }; - }); + }, async () => this.runTool('continue_execution', () => debuggingHandler.handleContinue())); // Restart debugging tool server.registerTool('restart_debugging', { description: 'Restart the debug session from the beginning with the same configuration.', - }, async () => { - const result = await this.debuggingHandler.handleRestart(); - return { content: [{ type: 'text' as const, text: result }] }; - }); + }, async () => this.runTool('restart_debugging', () => debuggingHandler.handleRestart())); // Add breakpoint tool server.registerTool('add_breakpoint', { @@ -237,10 +261,8 @@ export class DebugMCPServer { lineContent: z.string().describe('Line content'), condition: z.string().optional().describe('Optional condition expression. When provided, execution only pauses if this expression evaluates to true at the breakpoint location.'), }, - }, async (args: { fileFullPath: string; lineContent: string; condition?: string }) => { - const result = await this.debuggingHandler.handleAddBreakpoint(args); - return { content: [{ type: 'text' as const, text: result }] }; - }); + }, async (args: { fileFullPath: string; lineContent: string; condition?: string }) => + this.runTool('add_breakpoint', () => debuggingHandler.handleAddBreakpoint(args))); // Remove breakpoint tool server.registerTool('remove_breakpoint', { @@ -249,26 +271,18 @@ export class DebugMCPServer { fileFullPath: z.string().describe('Full path to the file'), line: z.number().describe('Line number (1-based)'), }, - }, async (args: { fileFullPath: string; line: number }) => { - const result = await this.debuggingHandler.handleRemoveBreakpoint(args); - return { content: [{ type: 'text' as const, text: result }] }; - }); + }, async (args: { fileFullPath: string; line: number }) => + this.runTool('remove_breakpoint', () => debuggingHandler.handleRemoveBreakpoint(args))); // Clear all breakpoints tool server.registerTool('clear_all_breakpoints', { description: 'Clear all breakpoints at once. Use this after verifying the root cause to clean up before moving on to the next task.', - }, async () => { - const result = await this.debuggingHandler.handleClearAllBreakpoints(); - return { content: [{ type: 'text' as const, text: result }] }; - }); + }, async () => this.runTool('clear_all_breakpoints', () => debuggingHandler.handleClearAllBreakpoints())); // List breakpoints tool server.registerTool('list_breakpoints', { description: 'View all currently set breakpoints across all files.', - }, async () => { - const result = await this.debuggingHandler.handleListBreakpoints(); - return { content: [{ type: 'text' as const, text: result }] }; - }); + }, async () => this.runTool('list_breakpoints', () => debuggingHandler.handleListBreakpoints())); // Get variables tool server.registerTool('get_variables_values', { @@ -276,10 +290,8 @@ export class DebugMCPServer { inputSchema: { scope: z.enum(['local', 'global', 'all']).optional().describe("Variable scope: 'local', 'global', or 'all'"), }, - }, async (args: { scope?: 'local' | 'global' | 'all' }) => { - const result = await this.debuggingHandler.handleGetVariables(args); - return { content: [{ type: 'text' as const, text: result }] }; - }); + }, async (args: { scope?: 'local' | 'global' | 'all' }) => + this.runTool('get_variables_values', () => debuggingHandler.handleGetVariables(args))); // Evaluate expression tool server.registerTool('evaluate_expression', { @@ -287,10 +299,8 @@ export class DebugMCPServer { inputSchema: { expression: z.string().describe('Expression to evaluate in the current programming language context'), }, - }, async (args: { expression: string }) => { - const result = await this.debuggingHandler.handleEvaluateExpression(args); - return { content: [{ type: 'text' as const, text: result }] }; - }); + }, async (args: { expression: string }) => + this.runTool('evaluate_expression', () => debuggingHandler.handleEvaluateExpression(args))); } /** @@ -322,14 +332,19 @@ export class DebugMCPServer { } /** - * Start the MCP server with SSE transport over HTTP + * Try to become the router by binding the public port. + * Returns `true` if this window owns the port, `false` if another already does. */ - async start(): Promise { - // First check if server is already running + async start(): Promise { + // If the port is already served, another window is the router. const isRunning = await this.isServerRunning(); if (isRunning) { - logger.info(`DebugMCP server is already running on port ${this.port}`); - return; + logger.info(`DebugMCP router already owned by another window on port ${this.port}`); + return false; + } + if (this.httpServers.length > 0) { + // We already bound the port on a previous call — nothing to do. + return true; } try { @@ -501,26 +516,35 @@ export class DebugMCPServer { // Binding to '127.0.0.1' alone does not cover clients that resolve `localhost` to `::1` // (common on IPv6-preferred systems), so we listen on both loopback families explicitly. for (const host of this.hosts) { - await new Promise((resolve, reject) => { + const bound = await new Promise((resolve, reject) => { const server = app.listen(this.port, host, () => { this.httpServers.push(server); - resolve(); + resolve(true); }); server.on('error', (err: NodeJS.ErrnoException) => { - // EADDRINUSE on the IPv6 loopback is expected on some platforms (e.g. Linux - // with net.ipv6.bindv6only=0) where the IPv4 bind already covers IPv6 via - // dual-stack mapping. Treat as a soft warning instead of a hard failure. - if (err.code === 'EADDRINUSE' && this.httpServers.length > 0) { - logger.warn(`Skipping bind on ${host}:${this.port} (already covered by another loopback bind)`); - resolve(); + if (err.code === 'EADDRINUSE') { + // Already bound another loopback family (dual-stack) -> soft-skip. + // Nothing bound yet -> another window won the port; yield to retry as worker. + if (this.httpServers.length > 0) { + logger.warn(`Skipping bind on ${host}:${this.port} (already covered by another loopback bind)`); + resolve(true); + } else { + logger.info(`Another window won the DebugMCP router port ${this.port}; staying worker-only`); + resolve(false); + } return; } reject(err); }); }); + if (!bound && this.httpServers.length === 0) { + // Lost the race before binding anything — not the router. + return false; + } } - logger.info(`DebugMCP server started successfully on ${this.hosts.join(', ')}:${this.port}`); + logger.info(`DebugMCP router started successfully on ${this.hosts.join(', ')}:${this.port}`); + return true; } catch (error) { logger.error(`Failed to start DebugMCP server`, error); @@ -561,10 +585,11 @@ export class DebugMCPServer { } /** - * Get the debugging handler (for testing purposes) + * Get a debugging handler instance (for testing purposes). Builds one via + * the same per-session factory the MCP layer uses. */ getDebuggingHandler(): IDebuggingHandler { - return this.debuggingHandler; + return this.handlerFactory(); } /** diff --git a/src/debuggingExecutor.ts b/src/debuggingExecutor.ts index 1c7d169..c2d2cd8 100644 --- a/src/debuggingExecutor.ts +++ b/src/debuggingExecutor.ts @@ -48,6 +48,37 @@ export interface IDebuggingExecutor { */ export class DebuggingExecutor implements IDebuggingExecutor { + // Cap each DAP request so an unresponsive adapter can't hang the caller. + // Kept small relative to the router/tool backstops so it fails fast. + private static readonly DAP_REQUEST_TIMEOUT_MS = 30_000; + + /** + * Issue a DAP request with an upper time bound, rejecting if the adapter + * doesn't respond in time. + */ + private async dapRequest( + session: vscode.DebugSession, + command: string, + args: unknown + ): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error( + `Debug adapter did not respond to '${command}' within ` + + `${Math.round(DebuggingExecutor.DAP_REQUEST_TIMEOUT_MS / 1000)}s (it may be unresponsive).` + )); + }, DebuggingExecutor.DAP_REQUEST_TIMEOUT_MS); + }); + try { + return await Promise.race([session.customRequest(command, args), timeout]); + } finally { + if (timer) { + clearTimeout(timer); + } + } + } + /** * Start a debugging session */ @@ -386,7 +417,7 @@ export class DebuggingExecutor implements IDebuggingExecutor { state: DebugState ): Promise<{ path?: string; line?: number; column?: number } | undefined> { try { - const stackTraceResponse = await session.customRequest('stackTrace', { + const stackTraceResponse = await this.dapRequest(session, 'stackTrace', { threadId: state.threadId, startFrame: 0, levels: 50 @@ -471,7 +502,7 @@ export class DebuggingExecutor implements IDebuggingExecutor { throw new Error('No active debug session'); } - const response = await activeSession.customRequest('scopes', { frameId }); + const response = await this.dapRequest(activeSession, 'scopes', { frameId }); if (!response || !response.scopes || response.scopes.length === 0) { return { scopes: [] }; @@ -488,7 +519,7 @@ export class DebuggingExecutor implements IDebuggingExecutor { // Get variables for each scope for (const scopeItem of filteredScopes) { try { - const variablesResponse = await activeSession.customRequest('variables', { + const variablesResponse = await this.dapRequest(activeSession, 'variables', { variablesReference: scopeItem.variablesReference }); scopeItem.variables = variablesResponse.variables || []; @@ -514,7 +545,7 @@ export class DebuggingExecutor implements IDebuggingExecutor { throw new Error('No active debug session'); } - const response = await activeSession.customRequest('evaluate', { + const response = await this.dapRequest(activeSession, 'evaluate', { expression: expression, frameId: frameId, context: 'repl' diff --git a/src/extension.ts b/src/extension.ts index 0579607..a33c180 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,12 +1,25 @@ // Copyright (c) Microsoft Corporation. import * as vscode from 'vscode'; +import { randomUUID } from 'node:crypto'; import { DebugMCPServer } from './debugMCPServer'; +import { DebuggingExecutor, ConfigurationManager, DebuggingHandler } from '.'; +import { ControlServer } from './controlServer'; +import { RoutingDebuggingHandler } from './routingDebuggingHandler'; +import { WorkspaceRegistry } from './utils/workspaceRegistry'; import { AgentConfigurationManager } from './utils/agentConfigurationManager'; import { logger, LogLevel } from './utils/logger'; let mcpServer: DebugMCPServer | null = null; let agentConfigManager: AgentConfigurationManager | null = null; +let controlServer: ControlServer | null = null; +let registry: WorkspaceRegistry | null = null; +let heartbeatTimer: NodeJS.Timeout | null = null; +let routerRetryTimer: NodeJS.Timeout | null = null; + +/** Interval (ms) for registry heartbeat and router-takeover retries. */ +const HEARTBEAT_INTERVAL_MS = 15_000; +const ROUTER_RETRY_INTERVAL_MS = 5_000; export async function activate(context: vscode.ExtensionContext) { // Initialize logging first @@ -46,18 +59,69 @@ export async function activate(context: vscode.ExtensionContext) { // Initialize MCP Server try { logger.info('Starting MCP server initialization...'); - - mcpServer = new DebugMCPServer(serverPort, timeoutInSeconds, bindHosts); + + // Every window runs a local debug stack + loopback control server and + // advertises its workspace folders. The window that wins the public port + // becomes the router and proxies each session to the control server of + // the window owning the requested workspace. + const executor = new DebuggingExecutor(); + const configManager = new ConfigurationManager(); + const localHandler = new DebuggingHandler(executor, configManager, timeoutInSeconds); + const controlToken = randomUUID(); + + controlServer = new ControlServer(localHandler, controlToken); + const controlPort = await controlServer.start(); + + registry = new WorkspaceRegistry(); + const registerSelf = () => { + registry?.register({ + controlPort, + controlToken, + workspaceFolders: (vscode.workspace.workspaceFolders ?? []).map(f => f.uri.fsPath), + name: vscode.workspace.name ?? 'unknown' + }); + }; + registerSelf(); + heartbeatTimer = setInterval(() => registry?.heartbeat(), HEARTBEAT_INTERVAL_MS); + context.subscriptions.push( + vscode.workspace.onDidChangeWorkspaceFolders(() => registerSelf()) + ); + + mcpServer = new DebugMCPServer( + serverPort, + timeoutInSeconds, + bindHosts, + () => new RoutingDebuggingHandler(registry!, timeoutInSeconds) + ); await mcpServer.initialize(); - await mcpServer.start(); - - const endpoint = mcpServer.getEndpoint(); - logger.info(`DebugMCP server running at: ${endpoint}`); - - const hasShownRunningMessage = context.globalState.get('serverRunningMessageShown', false); - if (!hasShownRunningMessage) { - vscode.window.showInformationMessage(`DebugMCP server running on ${endpoint}`); - await context.globalState.update('serverRunningMessageShown', true); + + const isRouter = await mcpServer.start(); + if (isRouter) { + const endpoint = mcpServer.getEndpoint(); + logger.info(`DebugMCP router running at: ${endpoint}`); + + const hasShownRunningMessage = context.globalState.get('serverRunningMessageShown', false); + if (!hasShownRunningMessage) { + vscode.window.showInformationMessage(`DebugMCP server running on ${endpoint}`); + await context.globalState.update('serverRunningMessageShown', true); + } + } else { + // Another window is the router. Keep retrying so this window can take + // over if that window later closes and frees the port. + logger.info('DebugMCP running as a worker window; another window owns the router port.'); + routerRetryTimer = setInterval(async () => { + try { + if (mcpServer && await mcpServer.start()) { + logger.info('This window has taken over as the DebugMCP router.'); + if (routerRetryTimer) { + clearInterval(routerRetryTimer); + routerRetryTimer = null; + } + } + } catch (error) { + logger.error('Router takeover attempt failed', error); + } + }, ROUTER_RETRY_INTERVAL_MS); } } catch (error) { logger.error('Failed to initialize MCP server', error); @@ -125,8 +189,31 @@ function registerCommands(context: vscode.ExtensionContext) { export async function deactivate() { logger.info('DebugMCP extension deactivating...'); - - // Clean up MCP server + + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + if (routerRetryTimer) { + clearInterval(routerRetryTimer); + routerRetryTimer = null; + } + + // Remove this window from the shared registry so other windows stop routing to it. + if (registry) { + registry.unregister(); + registry = null; + } + + // Stop the per-window control server. + if (controlServer) { + controlServer.stop().catch(error => { + logger.error('Error stopping control server', error); + }); + controlServer = null; + } + + // Clean up MCP server (only bound in the router window). if (mcpServer) { mcpServer.stop().catch(error => { logger.error('Error stopping MCP server', error); diff --git a/src/routingDebuggingHandler.ts b/src/routingDebuggingHandler.ts new file mode 100644 index 0000000..efb7171 --- /dev/null +++ b/src/routingDebuggingHandler.ts @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. + +import * as http from 'http'; +import { IDebuggingHandler } from './debuggingHandler'; +import { WorkspaceRegistry, WindowRegistration } from './utils/workspaceRegistry'; +import { logger } from './utils/logger'; + +/** + * Router-window handler (one instance per MCP session) that forwards every + * operation to the ControlServer of the window owning the requested workspace. + * + * The target is resolved from a path hint (`workingDirectory`/`fileFullPath`) + * and cached for the session so later hint-less calls (step/continue/inspect) + * reach the same window. Per-session instances let concurrent agent sessions + * drive debuggers in different repos at once. + */ +export class RoutingDebuggingHandler implements IDebuggingHandler { + private target: WindowRegistration | undefined; + + // Bound the router->worker round-trip so a hung worker can't hang the + // forward forever. Kept above the worker's own timeout to avoid preempting + // slow-but-progressing operations. + private readonly forwardTimeoutMs: number; + + constructor( + private readonly registry: WorkspaceRegistry, + timeoutInSeconds: number = 180 + ) { + this.forwardTimeoutMs = timeoutInSeconds * 1000 + 15_000; + } + + /** + * Resolve (and cache) the target from an optional path hint; a hint always + * re-resolves, otherwise the cached target is reused. + */ + private resolveTarget(pathHint?: string): WindowRegistration { + if (pathHint) { + const found = this.registry.findByPath(pathHint); + const candidates = this.registry + .list() + .map((w) => `pid=${w.pid} port=${w.controlPort} folders=[${w.workspaceFolders.join(', ') || 'none'}]`) + .join(' | '); + logger.info( + `Routing hint "${pathHint}" -> ${found ? `pid=${found.pid} port=${found.controlPort}` : 'no match'}. ` + + `Registered windows: ${candidates || '(none)'}` + ); + if (found) { + this.target = found; + } + } + if (!this.target) { + throw new Error(this.noTargetMessage(pathHint)); + } + return this.target; + } + + private noTargetMessage(pathHint?: string): string { + const windows = this.registry.list(); + const openList = windows + .map((w) => (w.workspaceFolders.length ? w.workspaceFolders.join(', ') : '(no folder)')) + .join('; '); + if (pathHint) { + return ( + `DebugMCP could not find an open VS Code window whose workspace contains "${pathHint}". ` + + (openList + ? `Open the correct folder in VS Code. Currently registered workspaces: ${openList}.` + : 'No DebugMCP-enabled VS Code windows are currently registered.') + ); + } + return ( + 'DebugMCP has no active debug target for this session. ' + + 'Call start_debugging (or add_breakpoint) with a file path first so DebugMCP can route to the right VS Code window.' + ); + } + + private async forward(op: string, args: unknown, pathHint?: string): Promise { + const target = this.resolveTarget(pathHint); + try { + return await this.post(target, op, args); + } catch (error) { + // Failed round-trip usually means the window closed; drop the cache + // so the next path-bearing call re-resolves. + this.target = undefined; + throw error; + } + } + + private post(target: WindowRegistration, op: string, args: unknown): Promise { + return new Promise((resolve, reject) => { + const payload = JSON.stringify({ op, args }); + let settled = false; + const req = http.request( + { + hostname: '127.0.0.1', + port: target.controlPort, + path: '/op', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + 'x-debugmcp-token': target.controlToken + } + }, + (res) => { + let body = ''; + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + if (settled) { + return; + } + settled = true; + try { + const parsed = JSON.parse(body || '{}') as { result?: string; error?: string }; + if (res.statusCode === 200 && typeof parsed.result === 'string') { + resolve(parsed.result); + } else { + reject(new Error(parsed.error || `Window control server returned HTTP ${res.statusCode}`)); + } + } catch { + reject(new Error(`Invalid response from window control server: ${body}`)); + } + }); + } + ); + // Abort if the worker accepts the connection but never responds; + // otherwise the forward (and its MCP request) would hang forever. + req.setTimeout(this.forwardTimeoutMs, () => { + if (settled) { + return; + } + settled = true; + logger.warn( + `Control server for window pid ${target.pid} did not respond within ${this.forwardTimeoutMs}ms; aborting.` + ); + req.destroy(); + reject( + new Error( + `The VS Code window (pid ${target.pid}) hosting this workspace did not respond within ` + + `${Math.round(this.forwardTimeoutMs / 1000)}s. Its debugger may be unresponsive. ` + + 'Try stop_debugging and retry, or reload that window.' + ) + ); + }); + req.on('error', (err) => { + if (settled) { + return; + } + settled = true; + logger.warn(`Failed to reach control server for window pid ${target.pid}: ${err.message}`); + reject( + new Error( + `Failed to reach the VS Code window (pid ${target.pid}) hosting this workspace. ` + + 'It may have been closed. Re-run start_debugging in the intended window.' + ) + ); + }); + req.write(payload); + req.end(); + }); + } + + public handleStartDebugging(args: { + fileFullPath: string; + workingDirectory: string; + testName?: string; + configurationName?: string; + }): Promise { + return this.forward('handleStartDebugging', args, args.workingDirectory || args.fileFullPath); + } + + public handleStopDebugging(): Promise { + return this.forward('handleStopDebugging', {}); + } + + public handleStepOver(): Promise { + return this.forward('handleStepOver', {}); + } + + public handleStepInto(): Promise { + return this.forward('handleStepInto', {}); + } + + public handleStepOut(): Promise { + return this.forward('handleStepOut', {}); + } + + public handleContinue(): Promise { + return this.forward('handleContinue', {}); + } + + public handleRestart(): Promise { + return this.forward('handleRestart', {}); + } + + public handleAddBreakpoint(args: { fileFullPath: string; lineContent: string; condition?: string }): Promise { + return this.forward('handleAddBreakpoint', args, args.fileFullPath); + } + + public handleRemoveBreakpoint(args: { fileFullPath: string; line: number }): Promise { + return this.forward('handleRemoveBreakpoint', args, args.fileFullPath); + } + + public handleClearAllBreakpoints(): Promise { + return this.forward('handleClearAllBreakpoints', {}); + } + + public handleListBreakpoints(): Promise { + return this.forward('handleListBreakpoints', {}); + } + + public handleGetVariables(args: { scope?: 'local' | 'global' | 'all' }): Promise { + return this.forward('handleGetVariables', args); + } + + public handleEvaluateExpression(args: { expression: string }): Promise { + return this.forward('handleEvaluateExpression', args); + } +} diff --git a/src/test/routing.test.ts b/src/test/routing.test.ts new file mode 100644 index 0000000..cec6a3b --- /dev/null +++ b/src/test/routing.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ControlServer } from '../controlServer'; +import { RoutingDebuggingHandler } from '../routingDebuggingHandler'; +import { WorkspaceRegistry } from '../utils/workspaceRegistry'; +import { IDebuggingHandler } from '../debuggingHandler'; + +/** Records every op it receives and echoes a tagged response. */ +class RecordingHandler implements IDebuggingHandler { + public calls: Array<{ op: string; args: unknown }> = []; + constructor(private readonly tag: string) {} + + private record(op: string, args: unknown): Promise { + this.calls.push({ op, args }); + return Promise.resolve(`${this.tag}:${op}`); + } + handleStartDebugging(args: any) { return this.record('start', args); } + handleStopDebugging() { return this.record('stop', {}); } + handleStepOver() { return this.record('stepOver', {}); } + handleStepInto() { return this.record('stepInto', {}); } + handleStepOut() { return this.record('stepOut', {}); } + handleContinue() { return this.record('continue', {}); } + handleRestart() { return this.record('restart', {}); } + handleAddBreakpoint(args: any) { return this.record('addBp', args); } + handleRemoveBreakpoint(args: any) { return this.record('removeBp', args); } + handleClearAllBreakpoints() { return this.record('clearBp', {}); } + handleListBreakpoints() { return this.record('listBp', {}); } + handleGetVariables(args: any) { return this.record('vars', args); } + handleEvaluateExpression(args: any) { return this.record('eval', args); } +} + +suite('Multi-window routing', () => { + let dir: string; + const servers: ControlServer[] = []; + + setup(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'debugmcp-routing-test-')); + }); + + teardown(async () => { + await Promise.all(servers.splice(0).map(s => s.stop())); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + async function startWindow( + fileName: string, + workspaceFolders: string[], + handler: RecordingHandler, + token = 'secret' + ): Promise { + const server = new ControlServer(handler, token); + servers.push(server); + const port = await server.start(); + fs.writeFileSync( + path.join(dir, fileName), + JSON.stringify({ + pid: process.pid, + controlPort: port, + controlToken: token, + workspaceFolders, + name: fileName, + updatedAt: Date.now() + }), + 'utf8' + ); + } + + test('routes start_debugging to the window owning the workingDirectory', async () => { + const repoA = path.join(dir, 'repoA'); + const repoB = path.join(dir, 'repoB'); + const handlerA = new RecordingHandler('A'); + const handlerB = new RecordingHandler('B'); + await startWindow('a.json', [repoA], handlerA); + await startWindow('b.json', [repoB], handlerB); + + const routing = new RoutingDebuggingHandler(new WorkspaceRegistry(process.pid, dir)); + const result = await routing.handleStartDebugging({ + fileFullPath: path.join(repoB, 'src', 'x.py'), + workingDirectory: repoB + }); + + assert.strictEqual(result, 'B:start'); + assert.strictEqual(handlerB.calls.length, 1); + assert.strictEqual(handlerA.calls.length, 0, 'the other window must not be touched'); + }); + + test('remembers the target for hint-less follow-up calls (session affinity)', async () => { + const repoA = path.join(dir, 'repoA'); + const handlerA = new RecordingHandler('A'); + await startWindow('a.json', [repoA], handlerA); + + const routing = new RoutingDebuggingHandler(new WorkspaceRegistry(process.pid, dir)); + await routing.handleStartDebugging({ fileFullPath: path.join(repoA, 'm.py'), workingDirectory: repoA }); + const stepped = await routing.handleStepOver(); + const vars = await routing.handleGetVariables({ scope: 'local' }); + + assert.strictEqual(stepped, 'A:stepOver'); + assert.strictEqual(vars, 'A:vars'); + assert.deepStrictEqual(handlerA.calls.map(c => c.op), ['start', 'stepOver', 'vars']); + }); + + test('add_breakpoint routes by fileFullPath before any start_debugging', async () => { + const repoA = path.join(dir, 'repoA'); + const repoB = path.join(dir, 'repoB'); + const handlerA = new RecordingHandler('A'); + const handlerB = new RecordingHandler('B'); + await startWindow('a.json', [repoA], handlerA); + await startWindow('b.json', [repoB], handlerB); + + const routing = new RoutingDebuggingHandler(new WorkspaceRegistry(process.pid, dir)); + const result = await routing.handleAddBreakpoint({ + fileFullPath: path.join(repoA, 'src', 'y.py'), + lineContent: 'return 1' + }); + + assert.strictEqual(result, 'A:addBp'); + assert.strictEqual(handlerB.calls.length, 0); + }); + + test('throws a helpful error when no window owns the path', async () => { + const repoA = path.join(dir, 'repoA'); + const repoB = path.join(dir, 'repoB'); + await startWindow('a.json', [repoA], new RecordingHandler('A')); + await startWindow('b.json', [repoB], new RecordingHandler('B')); + + const routing = new RoutingDebuggingHandler(new WorkspaceRegistry(process.pid, dir)); + await assert.rejects( + () => routing.handleStartDebugging({ + fileFullPath: path.join(dir, 'repoC', 'z.py'), + workingDirectory: path.join(dir, 'repoC') + }), + /could not find an open VS Code window/i + ); + }); + + test('hint-less call without an established target throws', async () => { + const routing = new RoutingDebuggingHandler(new WorkspaceRegistry(process.pid, dir)); + await assert.rejects(() => routing.handleStepOver(), /no active debug target/i); + }); + + test('control server rejects requests with a wrong token', async () => { + const repoA = path.join(dir, 'repoA'); + await startWindow('a.json', [repoA], new RecordingHandler('A'), 'right-token'); + + // Overwrite the registry entry with a bad token to simulate a mismatch. + const file = path.join(dir, 'a.json'); + const entry = JSON.parse(fs.readFileSync(file, 'utf8')); + entry.controlToken = 'wrong-token'; + fs.writeFileSync(file, JSON.stringify(entry), 'utf8'); + + const routing = new RoutingDebuggingHandler(new WorkspaceRegistry(process.pid, dir)); + await assert.rejects( + () => routing.handleStartDebugging({ fileFullPath: path.join(repoA, 'm.py'), workingDirectory: repoA }), + /HTTP 403/ + ); + }); +}); diff --git a/src/test/workspaceRegistry.test.ts b/src/test/workspaceRegistry.test.ts new file mode 100644 index 0000000..bd9179d --- /dev/null +++ b/src/test/workspaceRegistry.test.ts @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { WorkspaceRegistry, WindowRegistration } from '../utils/workspaceRegistry'; + +/** + * Unit tests for the shared multi-window registry: registration, listing, + * pruning of dead/stale windows, and workspace -> window resolution. + */ +suite('WorkspaceRegistry', () => { + let dir: string; + + setup(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'debugmcp-registry-test-')); + }); + + teardown(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const folder = (name: string) => path.join(dir, name); + + function seed(pid: number, workspaceFolders: string[], controlPort: number): void { + const reg = new WorkspaceRegistry(pid, dir); + reg.register({ controlPort, controlToken: `token-${pid}`, workspaceFolders, name: `w${pid}` }); + } + + // Write a second live entry under a distinct filename (reuses this live pid + // so list() keeps it) to simulate a second open window. + function writeExtraEntry(fileName: string, workspaceFolders: string[], controlPort: number): void { + const entry: WindowRegistration = { + pid: process.pid, + controlPort, + controlToken: `t-${controlPort}`, + workspaceFolders, + name: fileName, + updatedAt: Date.now() + }; + fs.writeFileSync(path.join(dir, fileName), JSON.stringify(entry), 'utf8'); + } + + test('register then list returns the live entry', () => { + const reg = new WorkspaceRegistry(process.pid, dir); + reg.register({ controlPort: 1234, controlToken: 't', workspaceFolders: [folder('repoA')], name: 'A' }); + + const entries = reg.list(); + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0].controlPort, 1234); + assert.strictEqual(entries[0].pid, process.pid); + }); + + test('unregister removes the entry', () => { + const reg = new WorkspaceRegistry(process.pid, dir); + reg.register({ controlPort: 1, controlToken: 't', workspaceFolders: [], name: 'A' }); + reg.unregister(); + assert.strictEqual(reg.list().length, 0); + }); + + test('list prunes entries whose process is dead', () => { + // A pid that is virtually certain not to exist. + seed(2147483646, [folder('ghost')], 9999); + const reg = new WorkspaceRegistry(process.pid, dir); + assert.strictEqual(reg.list().length, 0); + // The stale file should have been removed. + assert.strictEqual(fs.readdirSync(dir).length, 0); + }); + + test('findByPath resolves a file inside a workspace folder', () => { + seed(process.pid, [folder('repoA')], 5001); + + const reg = new WorkspaceRegistry(process.pid, dir); + const found = reg.findByPath(path.join(folder('repoA'), 'src', 'main.py')); + assert.ok(found); + assert.strictEqual(found!.controlPort, 5001); + }); + + test('findByPath falls back only to a sole window that declares no folders', () => { + writeExtraEntry('window-empty.json', [], 5001); + const reg = new WorkspaceRegistry(process.pid, dir); + const found = reg.findByPath(path.join(dir, 'unrelated', 'file.ts')); + assert.ok(found, 'a sole no-folder window is used as fallback'); + assert.strictEqual(found!.controlPort, 5001); + }); + + test('findByPath does NOT guess a folder-bearing window for a path it does not contain', () => { + // Regression: a single unrelated window must not capture a foreign path, + // which previously opened files in the wrong workspace. + seed(process.pid, [folder('repoA')], 5001); + const reg = new WorkspaceRegistry(process.pid, dir); + const found = reg.findByPath(path.join(dir, 'repoB', 'file.ts')); + assert.strictEqual(found, undefined); + }); + + test('findByPath returns undefined when no folder matches and multiple windows are open', () => { + seed(process.pid, [folder('repoA')], 5001); + writeExtraEntry('window-extra.json', [folder('repoB')], 5002); + + const reg = new WorkspaceRegistry(process.pid, dir); + const found = reg.findByPath(path.join(dir, 'repoC', 'file.ts')); + assert.strictEqual(found, undefined); + }); + + test('findByPath picks the deepest matching folder across windows', () => { + seed(process.pid, [folder('outer')], 6001); + writeExtraEntry('window-extra.json', [path.join(folder('outer'), 'nested')], 6002); + + const reg = new WorkspaceRegistry(process.pid, dir); + const found = reg.findByPath(path.join(folder('outer'), 'nested', 'deep', 'x.ts')); + assert.ok(found); + assert.strictEqual(found!.controlPort, 6002, 'should pick the deepest (nested) window'); + }); +}); diff --git a/src/utils/workspaceRegistry.ts b/src/utils/workspaceRegistry.ts new file mode 100644 index 0000000..14c8d5b --- /dev/null +++ b/src/utils/workspaceRegistry.ts @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { logger } from './logger'; + +/** One VS Code window's advertisement in the shared registry. */ +export interface WindowRegistration { + pid: number; + controlPort: number; + controlToken: string; + workspaceFolders: string[]; + name: string; + updatedAt: number; +} + +/** Default directory holding one JSON file per live window. */ +const DEFAULT_REGISTRY_DIR = path.join(os.tmpdir(), 'debugmcp-registry'); + +/** Entries not refreshed within this window are considered stale. */ +const STALE_MS = 60_000; + +/** Best-effort pid liveness check (EPERM means the process exists). */ +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err: unknown) { + return !!(err && (err as NodeJS.ErrnoException).code === 'EPERM'); + } +} + +/** Resolve, strip trailing separators, and lower-case on Windows. */ +function normalizePath(p: string): string { + let normalized = path.resolve(p); + if (process.platform === 'win32') { + normalized = normalized.toLowerCase(); + } + return normalized.replace(/[\\/]+$/, ''); +} + +/** True when `target` is `folder` or a descendant of it. */ +function isInside(target: string, folder: string): boolean { + return target === folder || target.startsWith(folder + path.sep) || target.startsWith(folder + '/'); +} + +/** + * File-based registry of DebugMCP-enabled VS Code windows on this machine. + * One file per window (named by pid); reads prune dead/stale entries. + */ +export class WorkspaceRegistry { + private readonly registryDir: string; + private readonly filePath: string; + + constructor(private readonly pid: number = process.pid, registryDir: string = DEFAULT_REGISTRY_DIR) { + this.registryDir = registryDir; + this.filePath = path.join(this.registryDir, `window-${this.pid}.json`); + } + + /** Write (or overwrite) this window's registration. */ + public register(reg: Omit): void { + try { + fs.mkdirSync(this.registryDir, { recursive: true }); + const entry: WindowRegistration = { ...reg, pid: this.pid, updatedAt: Date.now() }; + fs.writeFileSync(this.filePath, JSON.stringify(entry), 'utf8'); + } catch (error) { + logger.error('Failed to write DebugMCP registry entry', error); + } + } + + /** Refresh `updatedAt` so other windows don't prune this one. */ + public heartbeat(): void { + try { + const entry = JSON.parse(fs.readFileSync(this.filePath, 'utf8')) as WindowRegistration; + entry.updatedAt = Date.now(); + fs.writeFileSync(this.filePath, JSON.stringify(entry), 'utf8'); + } catch { + // Entry missing — caller re-registers on change. + } + } + + /** Remove this window's registration (on deactivate). */ + public unregister(): void { + this.tryUnlink(this.filePath); + } + + /** All live windows, pruning dead-pid and stale entries as a side effect. */ + public list(): WindowRegistration[] { + let files: string[]; + try { + files = fs.readdirSync(this.registryDir); + } catch { + return []; + } + const result: WindowRegistration[] = []; + for (const file of files) { + if (!file.endsWith('.json')) { + continue; + } + const full = path.join(this.registryDir, file); + try { + const entry = JSON.parse(fs.readFileSync(full, 'utf8')) as WindowRegistration; + const isStale = Date.now() - entry.updatedAt > STALE_MS; + if (!isProcessAlive(entry.pid) || (isStale && entry.pid !== this.pid)) { + this.tryUnlink(full); + continue; + } + result.push(entry); + } catch { + this.tryUnlink(full); + } + } + return result; + } + + /** + * Find the window whose workspace folder best (deepest) contains `targetPath`. + * + * Returns undefined when no window's folders contain the path — the router + * turns that into an actionable error rather than guessing. The only fallback + * is a sole window that declares NO folders (an "empty" window that can't be + * matched by prefix); a folder-bearing window is never chosen for a path it + * doesn't actually contain, which previously caused files to open in the + * wrong workspace. + */ + public findByPath(targetPath: string): WindowRegistration | undefined { + const target = normalizePath(targetPath); + const entries = this.list(); + let best: WindowRegistration | undefined; + let bestLen = -1; + for (const entry of entries) { + for (const folder of entry.workspaceFolders) { + const normalizedFolder = normalizePath(folder); + if (isInside(target, normalizedFolder) && normalizedFolder.length > bestLen) { + bestLen = normalizedFolder.length; + best = entry; + } + } + } + if (!best && entries.length === 1 && entries[0].workspaceFolders.length === 0) { + return entries[0]; + } + return best; + } + + private tryUnlink(full: string): void { + try { + fs.unlinkSync(full); + } catch { + // Another window may have pruned it first — ignore. + } + } +} From 5e0d4c9a2ee5bcf5bc50836b274678dfda15c7ff Mon Sep 17 00:00:00 2001 From: ozzafar <48795672+ozzafar@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:50:35 +0300 Subject: [PATCH 2/2] Update version to 2.2.0 and enhance README with new features --- README.md | 5 +++-- package.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e4084cc..97fb0ad 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 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.0.1-green.svg)](https://github.com/microsoft/DebugMCP) +[![Version](https://img.shields.io/badge/version-2.2.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. @@ -17,10 +17,11 @@ Let AI agents debug your code inside VS Code - set breakpoints, step through exe

-## ✨ What's New in 2.0.0 +## ✨ What's New in 2.2.0 - **`/debug-live` Agent Skill** — DebugMCP now ships a companion [Agent Skill](./skills/debug-live/SKILL.md) that is auto-installed into each configured harness's personal skills directory (e.g. `~/.copilot/skills/debug-live/`). Invoke it with `/debug-live` in supporting agents to load the systematic debugging workflow and trigger DebugMCP tools with the right context. - **Robust debugging via the VS Code Testing API** — `start_debugging` with a `testName` now uses the VS Code Testing API to discover and launch the test, replacing the previous best-effort path. This works reliably across language test runners that integrate with the Testing API (pytest, Jest/Vitest, Java, .NET, Go, etc.) and produces consistent breakpoint hits inside individual test cases. +- **Concurrent debugging** - supports multiple concurrent debug sessions, allowing effective parallel agentic debugging. ## 🚀 Quick Install diff --git a/package.json b/package.json index 067a4a5..3872e82 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "debugmcpextension", "displayName": "DebugMCP", "description": "Let AI agents debug your code inside VS Code — breakpoints, step-through execution, variable inspection, and expression evaluation. Automatically exposes itself as an MCP (Model Context Protocol) server for seamless integration with AI assistants.", - "version": "2.1.0", + "version": "2.2.0", "publisher": "ozzafar", "author": { "name": "Oz Zafar",