Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 34 additions & 8 deletions src/debugMCPServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,12 +26,16 @@ export class DebugMCPServer {
private port: number;
private initialized: boolean = false;
private debuggingHandler: IDebuggingHandler;
private transports: Map<string, StreamableHTTPServerTransport> = 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;
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<void>((resolve) => {
Expand Down
111 changes: 111 additions & 0 deletions src/testHostAutoAttacher.ts
Original file line number Diff line number Diff line change
@@ -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<any[]>;
private readonly disposables: vscode.Disposable[] = [];
private readonly pidToSession = new Map<number, string>();


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<number> {
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<void> {
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();
}
}
}
18 changes: 11 additions & 7 deletions src/utils/debugConfigurationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {
Expand Down Expand Up @@ -267,6 +267,7 @@ export class DebugConfigurationManager implements IDebugConfigurationManager {
*/
private async createTestDebugConfig(
language: string,
workingDirectory: string,
fileFullPath: string,
cwd: string,
testName: string
Expand Down Expand Up @@ -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:
Expand Down