diff --git a/package-lock.json b/package-lock.json index 4d0f493..69e90a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,13 @@ "@types/express": "^5.0.3", "express": "^5.2.1", "jsonc-parser": "^3.3.1", + "ps-list": "^9.0.0", "zod": "^3.25.76" }, "devDependencies": { "@types/mocha": "^10.0.10", "@types/node": "^25.3.0", + "@types/ps-list": "^6.0.0", "@types/vscode": "^1.104.0", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", @@ -425,6 +427,13 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/ps-list": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/ps-list/-/ps-list-6.0.0.tgz", + "integrity": "sha512-FuoItqYGkFXGrrlTraXZ5d4b7bNc9MBRWLAusHZWI9j8+LFoF+z938cizoetAQ3ui6K0BomAw5sFQYS2NVbV3Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -3165,6 +3174,18 @@ "node": ">= 0.10" } }, + "node_modules/ps-list": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/ps-list/-/ps-list-9.0.0.tgz", + "integrity": "sha512-lxMEoIL/BQlk2KunFzxwUPwMvjFH7x7cmvzSLsSHpyMXl9FFfLUlfKrYwFc4wx/ZaIxxuXC4n8rjQ1CX/tkXVQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index dabd13b..0edb056 100644 --- a/package.json +++ b/package.json @@ -93,11 +93,13 @@ "@types/express": "^5.0.3", "express": "^5.2.1", "jsonc-parser": "^3.3.1", + "ps-list": "^9.0.0", "zod": "^3.25.76" }, "devDependencies": { "@types/mocha": "^10.0.10", "@types/node": "^25.3.0", + "@types/ps-list": "^6.0.0", "@types/vscode": "^1.104.0", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", diff --git a/src/debugMCPServer.ts b/src/debugMCPServer.ts index 40e33f8..457f902 100644 --- a/src/debugMCPServer.ts +++ b/src/debugMCPServer.ts @@ -11,6 +11,7 @@ import { DebuggingHandler, IDebuggingHandler } from '.'; +import { TestHostAutoAttacher } from './testHostAutoAttacher'; import { logger } from './utils/logger'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; @@ -25,12 +26,16 @@ export class DebugMCPServer { private port: number; private initialized: boolean = false; private debuggingHandler: IDebuggingHandler; + private transports: Map = new Map(); + private testHostAutoAttacher: TestHostAutoAttacher; + private allowNextTransport = true; constructor(port: number, timeoutInSeconds: number) { // Initialize the debugging components with dependency injection const executor = new DebuggingExecutor(); const configManager = new ConfigurationManager(); this.debuggingHandler = new DebuggingHandler(executor, configManager, timeoutInSeconds); + this.testHostAutoAttacher = new TestHostAutoAttacher(); this.port = port; } @@ -347,16 +352,28 @@ export class DebugMCPServer { app.post('/mcp', async (req: any, res: any) => { logger.info('New MCP request received'); - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, // Stateless mode - no session management - }); - res.on('close', () => { - transport.close(); - logger.info('MCP transport closed'); - }); - try { + let waitCnt = 0; + while (!this.allowNextTransport && waitCnt <= 15) { + waitCnt++; + await new Promise(resolve => setTimeout(resolve, 200)); + } + + if (!this.allowNextTransport) { + throw new Error("Error wait for transport release"); + } + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // Stateless mode - no session management + }); + res.on('close', () => { + transport.close(); + this.allowNextTransport = true; + logger.info('MCP transport closed'); + }); + await this.mcpServer!.connect(transport); + this.allowNextTransport = false; await transport.handleRequest(req, res, req.body); } catch (error) { logger.error('Error while handling MCP request', error); @@ -424,6 +441,15 @@ export class DebugMCPServer { * Stop the MCP server */ async stop() { + // Note: With stateless StreamableHTTPServerTransport, transports are closed per-request + // No need to track and close them manually + this.transports.clear(); + + // Dispose the testhost auto attacher + if (this.testHostAutoAttacher) { + this.testHostAutoAttacher.dispose(); + } + // Close the HTTP server if (this.httpServer) { await new Promise((resolve) => { diff --git a/src/testHostAutoAttacher.ts b/src/testHostAutoAttacher.ts new file mode 100644 index 0000000..59e7a3a --- /dev/null +++ b/src/testHostAutoAttacher.ts @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. + +import * as vscode from 'vscode'; +import { logger } from './utils/logger'; + +/** + * Handles auto-attach to testhost process for .NET test debugging. + * Listens for coreclr launch sessions and automatically attaches to testhost. + */ +export class TestHostAutoAttacher { + private psList?: (options?: any | undefined) => Promise; + private readonly disposables: vscode.Disposable[] = []; + private readonly pidToSession = new Map(); + + + constructor() { + this.disposables.push(vscode.debug.onDidStartDebugSession(async (session) => { + + if (session.type !== 'coreclr' || !session.name.includes('DebugMCP .NET Test')) { + return; + } + + try { + const pid = await this.waitForTestHost(); + if (!vscode.debug.activeDebugSession || vscode.debug.activeDebugSession.id !== session.id) { + return; + } + + if (this.pidToSession.has(pid)) { + logger.error(`PID has been attached before (PID: ${pid})`); + return; + } + + logger.info(`Found testhost PID=${pid}, attaching...`); + logger.info(`Session=${session.id}, Name=${session.name}`); + this.pidToSession.set(pid, session.id); + await this.attachToTestHost(pid, session); + logger.info(`Successfully attached to testhost (PID: ${pid})`); + } catch (err) { + logger.error('Failed to auto attach testhost', err); + } + })); + + this.disposables.push(vscode.debug.onDidTerminateDebugSession(async (session) => { + for (const [pid, sid] of this.pidToSession) { + if (sid === session.id) { + this.pidToSession.delete(pid); + } + } + })); + } + + + /** + * Wait for testhost process to appear + */ + public async waitForTestHost(timeoutMs: number = 15000): Promise { + this.psList = this.psList ?? await import('ps-list').then(m => m.default); + + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (!vscode.debug.activeDebugSession) { + break; + } + + const processes = await this.psList(); + + const testhost = processes + .filter(p => + p.name?.includes('testhost') + && !this.pidToSession.has(p.pid) + ) + .sort((a, b) => b.pid - a.pid) + .at(0); + + if (testhost?.pid) { + return testhost.pid; + } + + await new Promise(resolve => setTimeout(resolve, 500)); + } + + throw new Error('testhost process not found'); + } + + /** + * Attach debugger to testhost process + */ + public async attachToTestHost(pid: number, session: vscode.DebugSession): Promise { + try { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + await vscode.debug.startDebugging(workspaceFolder, { + type: 'coreclr', + request: 'attach', + name: 'DebugMCP Attach testhost', + processId: pid.toString() + }); + } catch (error) { + throw new Error(`Failed to attach to testhost: ${error}`); + } + } + + /** + * Dispose the listener + */ + public dispose(): void { + for (const disposable of this.disposables) { + disposable.dispose(); + } + } +} diff --git a/src/utils/debugConfigurationManager.ts b/src/utils/debugConfigurationManager.ts index ef37563..fa48adb 100644 --- a/src/utils/debugConfigurationManager.ts +++ b/src/utils/debugConfigurationManager.ts @@ -110,8 +110,8 @@ export class DebugConfigurationManager implements IDebugConfigurationManager { const cwd = path.dirname(fileFullPath); // Build test-specific configurations based on language - if (testName && detectedLanguage !== 'coreclr') { - return await this.createTestDebugConfig(detectedLanguage, fileFullPath, cwd, testName); + if (testName) { + return await this.createTestDebugConfig(detectedLanguage, workingDirectory, fileFullPath, cwd, testName); } const configs: { [key: string]: vscode.DebugConfiguration } = { @@ -267,6 +267,7 @@ export class DebugConfigurationManager implements IDebugConfigurationManager { */ private async createTestDebugConfig( language: string, + workingDirectory: string, fileFullPath: string, cwd: string, testName: string @@ -356,13 +357,16 @@ export class DebugConfigurationManager implements IDebugConfigurationManager { name: `DebugMCP .NET Test: ${testName}`, program: 'dotnet', args: [ - 'test', - '--filter', `FullyQualifiedName~${testName}`, - '--no-build' + 'test', + '--no-build', + '--filter', `FullyQualifiedName~${testName}` ], console: 'integratedTerminal', - cwd: cwd, - stopAtEntry: false + cwd: workingDirectory, + stopAtEntry: false, + env: { + VSTEST_HOST_DEBUG: '1' + } }; default: