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
[](https://opensource.org/licenses/MIT)
[](https://code.visualstudio.com/)
-[](https://github.com/microsoft/DebugMCP)
+[](https://github.com/microsoft/DebugMCP)
[](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/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/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",
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.
+ }
+ }
+}