Skip to content
Merged
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
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,9 +313,10 @@ To add support for a new language:
3. **Export Factory**: Export a factory class named `{Language}AdapterFactory`
4. **Register in root `package.json`**: Add `"@debugmcp/adapter-{language}": "workspace:*"` to `optionalDependencies`
5. **Add Vitest alias**: Add `{ find: '@debugmcp/adapter-{language}', replacement: path.resolve(__dirname, './packages/adapter-{language}/src/index.ts') }` to `resolve.alias` in `vitest.config.ts`
6. **Update adapter count**: Update hardcoded adapter counts in tests (`adapter-loader.test.ts`, `models.test.ts`)
7. **Add Tests**: Include unit and integration tests in the package
8. **Run `pnpm install`**: To link the new workspace package
6. **Update adapter count**: Update hardcoded adapter counts in tests (`adapter-loader.test.ts`, `models.test.ts`, `tests/e2e/doctor-smoke.test.ts`)
7. **Wire the doctor command**: Add the language's runtime/backend column mapping to `presentLanguage` in `src/cli/commands/doctor/presenters.ts` (and `collectDoctorExtras` if it has doctor-only probes)
8. **Add Tests**: Include unit and integration tests in the package
9. **Run `pnpm install`**: To link the new workspace package

Example structure:
```
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ The server also serves condensed guidance in-band: MCP `instructions` on connect

## 🚀 Quick Start

> **Requirements:** Node.js 22+ for the server. Each language you debug also needs its own toolchain installed (Python + debugpy, Ruby + the `debug` gem / `rdbg`, Node.js, Go + Delve, JDK 21+, .NET SDK, the Rust toolchain, or a C/C++ compiler — g++/clang++, only needed for source-file launch).
> **Requirements:** Node.js 22+ for the server. Each language you debug also needs its own toolchain installed (Python + debugpy, Ruby + the `debug` gem / `rdbg`, Node.js, Go + Delve, JDK 21+, .NET SDK, the Rust toolchain, or a C/C++ compiler — g++/clang++, only needed for source-file launch). Not sure what's installed? Run `npx @debugmcp/mcp-debugger doctor` for a per-adapter toolchain report.
>
> **CodeLLDB platform note (npx/npm installs):** the CodeLLDB debug engine ships as per-platform optional dependencies (`@debugmcp/codelldb-win32-x64`, `-darwin-x64`, `-darwin-arm64`, `-linux-x64`, `-linux-arm64`) — npm installs exactly the one matching your platform, so Rust and C/C++ debugging work out of the box everywhere npm serves. If you install with `--omit=optional`, set `CODELLDB_PATH` to a [CodeLLDB release](https://github.com/vadimcn/codelldb/releases) binary instead, or use the Docker image.

Expand Down
9 changes: 6 additions & 3 deletions packages/adapter-cpp/src/cpp-adapter-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { IDebugAdapter } from '@debugmcp/shared';
import { IAdapterFactory, AdapterDependencies, AdapterMetadata, FactoryValidationResult } from '@debugmcp/shared';
import { CppDebugAdapter } from './cpp-debug-adapter.js';
import { DebugLanguage } from '@debugmcp/shared';
import { resolveCodeLLDBExecutable, getCodeLLDBVersion } from '@debugmcp/codelldb-common';
import { resolveCodeLLDBExecutableWithSource, getCodeLLDBVersion } from '@debugmcp/codelldb-common';
import { findAnyCompiler } from './utils/compile-utils.js';

/**
Expand Down Expand Up @@ -49,14 +49,16 @@ export class CppAdapterFactory implements IAdapterFactory {
const warnings: string[] = [];
let codelldbPath: string | undefined;
let codelldbVersion: string | undefined;
let codelldbSource: string | undefined;
let compiler: string | undefined;

// Check CodeLLDB — the only hard requirement
const resolvedCodelldb = await resolveCodeLLDBExecutable();
const resolvedCodelldb = await resolveCodeLLDBExecutableWithSource();
if (!resolvedCodelldb) {
errors.push('CodeLLDB not found. It normally ships via the @debugmcp/codelldb-* optional dependencies; set CODELLDB_PATH, or in a repo checkout run: npm run build:adapter');
} else {
codelldbPath = resolvedCodelldb;
codelldbPath = resolvedCodelldb.path;
codelldbSource = resolvedCodelldb.source;
codelldbVersion = await getCodeLLDBVersion() || undefined;
}

Expand All @@ -75,6 +77,7 @@ export class CppAdapterFactory implements IAdapterFactory {
details: {
codelldbPath,
codelldbVersion,
codelldbSource,
compiler,
platform: process.platform,
arch: process.arch,
Expand Down
2 changes: 2 additions & 0 deletions packages/adapter-cpp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ export {
dialectForSource,
findCompiler,
findAnyCompiler,
getCompilerInfo,
getDefaultOutputPath,
needsRecompile,
compileSourceFile
} from './utils/compile-utils.js';
export type { CompilerInfo } from './utils/compile-utils.js';
55 changes: 55 additions & 0 deletions packages/adapter-cpp/src/utils/compile-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,61 @@ export async function findAnyCompiler(): Promise<string | null> {
return (await findCompiler('cpp')) ?? (await findCompiler('c'));
}

export interface CompilerInfo {
command: string;
version: string | null;
}

/** Upper bound for a single toolchain probe child before it is killed. */
const PROBE_KILL_TIMEOUT_MS = 10_000;

/**
* The available compiler and its version banner (the first line of
* `--version` output, e.g. "g++ (MinGW-w64 ...) 13.2.0"). Pass an already
* discovered `command` (e.g. validate()'s details.compiler) to skip the
* candidate scan. Doctor-only probe (issue #423) — validate() keeps its
* cheaper presence-only findAnyCompiler.
*/
export async function getCompilerInfo(command?: string): Promise<CompilerInfo | null> {
const resolved = command ?? (await findAnyCompiler());
if (!resolved) {
return null;
}
return { command: resolved, version: await captureVersionLine(resolved) };
}

function captureVersionLine(command: string): Promise<string | null> {
return new Promise((resolve) => {
try {
const child = spawn(command, ['--version'], {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
});
let output = '';
child.stdout?.on('data', (data) => { output += data.toString(); });
child.stderr?.on('data', (data) => { output += data.toString(); });

const killTimer = setTimeout(() => {
try { child.kill(); } catch { /* already gone */ }
}, PROBE_KILL_TIMEOUT_MS);
killTimer.unref?.();

child.on('error', () => {
clearTimeout(killTimer);
resolve(null);
});
// 'close' (not 'exit') so stdio is fully drained before reading output
child.on('close', (code) => {
clearTimeout(killTimer);
const firstLine = output.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0);
resolve(code === 0 && firstLine ? firstLine : null);
});
} catch {
resolve(null);
}
});
}

/**
* Deterministic output location for a compiled single-file program:
* `<sourceDir>/.debug-mcp/<basename>[.exe]`. Git-ignorable and stable, so
Expand Down
95 changes: 94 additions & 1 deletion packages/adapter-cpp/tests/compile-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,38 @@ import {
dialectForSource,
findCompiler,
findAnyCompiler,
getCompilerInfo,
getDefaultOutputPath,
needsRecompile,
compileSourceFile
} from '../src/utils/compile-utils.js';

function fakeProcess(exitCode: number, stderr = ''): EventEmitter & { stdout: EventEmitter; stderr: EventEmitter } {
function fakeProcess(exitCode: number, stderr = '', stdout = ''): EventEmitter & { stdout: EventEmitter; stderr: EventEmitter } {
const proc = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
setImmediate(() => {
if (stderr) {
proc.stderr.emit('data', Buffer.from(stderr));
}
if (stdout) {
proc.stdout.emit('data', Buffer.from(stdout));
}
proc.emit('exit', exitCode);
// Real children emit 'close' after 'exit' once stdio drains
proc.emit('close', exitCode);
});
return proc;
}

function erroringProcess(): EventEmitter & { stdout: EventEmitter; stderr: EventEmitter } {
const proc = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
setImmediate(() => proc.emit('error', new Error('ENOENT')));
return proc;
}

describe('compile-utils', () => {
beforeEach(() => {
spawnMock.mockReset();
Expand Down Expand Up @@ -131,6 +145,85 @@ describe('compile-utils', () => {
});
});

describe('getCompilerInfo (issue #423)', () => {
it('returns the discovered command and the first line of its --version output', async () => {
spawnMock
.mockImplementationOnce(() => fakeProcess(0)) // findAnyCompiler probe: g++ answers
.mockImplementationOnce(() =>
fakeProcess(0, '', 'g++ (MinGW-w64 x86_64-posix-seh) 13.2.0\nCopyright (C) 2023 Free Software Foundation\n')
);

await expect(getCompilerInfo()).resolves.toEqual({
command: 'g++',
version: 'g++ (MinGW-w64 x86_64-posix-seh) 13.2.0'
});
});

it('returns null when no compiler is installed', async () => {
spawnMock.mockImplementation(() => fakeProcess(1)); // every candidate probe fails

await expect(getCompilerInfo()).resolves.toBeNull();
});

it('returns a null version when the --version re-run fails after discovery', async () => {
spawnMock
.mockImplementationOnce(() => fakeProcess(0)) // probe succeeds
.mockImplementationOnce(() => erroringProcess()); // version capture fails

await expect(getCompilerInfo()).resolves.toEqual({ command: 'g++', version: null });
});

it('skips candidate discovery when the command is already known', async () => {
spawnMock.mockImplementationOnce(() =>
fakeProcess(0, '', 'clang++ version 17.0.1\n')
);

await expect(getCompilerInfo('clang++')).resolves.toEqual({
command: 'clang++',
version: 'clang++ version 17.0.1'
});
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0][0]).toBe('clang++');
});

it("still sees stdout that arrives between 'exit' and 'close' (stdio drain race)", async () => {
spawnMock.mockImplementationOnce(() => {
const proc = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; kill: () => void };
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = vi.fn();
setImmediate(() => {
proc.emit('exit', 0);
proc.stdout.emit('data', Buffer.from('g++ 13.2.0\n'));
proc.emit('close', 0);
});
return proc;
});

await expect(getCompilerInfo('g++')).resolves.toEqual({ command: 'g++', version: 'g++ 13.2.0' });
});

it('kills a hung version capture after the guard timeout', async () => {
vi.useFakeTimers();
const kill = vi.fn();
let proc!: EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; kill: typeof kill };
spawnMock.mockImplementationOnce(() => {
proc = new EventEmitter() as typeof proc;
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = kill;
return proc;
});

const pending = getCompilerInfo('g++');
await vi.advanceTimersByTimeAsync(10_100);
expect(kill).toHaveBeenCalled();
proc.emit('close', null);
await expect(pending).resolves.toEqual({ command: 'g++', version: null });
vi.useRealTimers();
});
});

describe('getDefaultOutputPath', () => {
it('places the binary under .debug-mcp next to the source, with .exe on win32', () => {
const src = path.join('C:', 'work', 'demo', 'hello.cpp');
Expand Down
19 changes: 15 additions & 4 deletions packages/adapter-cpp/tests/cpp-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { AdapterConfig, AdapterDependencies } from '@debugmcp/shared';
vi.mock('@debugmcp/codelldb-common', async (importOriginal) => ({
...(await importOriginal<object>()),
resolveCodeLLDBExecutable: vi.fn(),
resolveCodeLLDBExecutableWithSource: vi.fn(),
resolveCodeLLDBExecutableSyncImpl: vi.fn(),
getCodeLLDBVersion: vi.fn(),
detectBinaryFormat: vi.fn(),
Expand All @@ -29,6 +30,7 @@ vi.mock('../src/utils/compile-utils.js', async (importOriginal) => ({

import {
resolveCodeLLDBExecutable,
resolveCodeLLDBExecutableWithSource,
resolveCodeLLDBExecutableSyncImpl,
getCodeLLDBVersion,
detectBinaryFormat,
Expand Down Expand Up @@ -420,6 +422,7 @@ describe('CppDebugAdapter', () => {
describe('CppAdapterFactory', () => {
beforeEach(() => {
vi.mocked(resolveCodeLLDBExecutable).mockReset();
vi.mocked(resolveCodeLLDBExecutableWithSource).mockReset();
vi.mocked(getCodeLLDBVersion).mockReset();
vi.mocked(findAnyCompiler).mockReset();
});
Expand All @@ -436,9 +439,12 @@ describe('CppAdapterFactory', () => {
expect(adapter.language).toBe(DebugLanguage.CPP);
});

it('validate reports the discovered compiler in details when everything is present', async () => {
it('validate reports the discovered compiler and CodeLLDB source in details when everything is present', async () => {
const factory = new CppAdapterFactory();
vi.mocked(resolveCodeLLDBExecutable).mockResolvedValue('/vendor/codelldb');
vi.mocked(resolveCodeLLDBExecutableWithSource).mockResolvedValue({
path: '/vendor/codelldb',
source: 'vendored'
});
vi.mocked(getCodeLLDBVersion).mockResolvedValue('1.11.8');
vi.mocked(findAnyCompiler).mockResolvedValue('clang++');

Expand All @@ -449,24 +455,29 @@ describe('CppAdapterFactory', () => {
expect(result.details).toMatchObject({
codelldbPath: '/vendor/codelldb',
codelldbVersion: '1.11.8',
codelldbSource: 'vendored',
compiler: 'clang++'
});
});

it('validate errors without CodeLLDB and warns without a compiler', async () => {
const factory = new CppAdapterFactory();

vi.mocked(resolveCodeLLDBExecutable).mockResolvedValue(null);
vi.mocked(resolveCodeLLDBExecutableWithSource).mockResolvedValue(null);
vi.mocked(findAnyCompiler).mockResolvedValue(null);
let result = await factory.validate();
expect(result.valid).toBe(false);
expect(result.errors[0]).toMatch(/CodeLLDB/);

vi.mocked(resolveCodeLLDBExecutable).mockResolvedValue('/vendor/codelldb');
vi.mocked(resolveCodeLLDBExecutableWithSource).mockResolvedValue({
path: '/vendor/codelldb',
source: 'env:CODELLDB_PATH'
});
vi.mocked(getCodeLLDBVersion).mockResolvedValue('1.11.8');
vi.mocked(findAnyCompiler).mockResolvedValue(null);
result = await factory.validate();
expect(result.valid).toBe(true);
expect(result.warnings[0]).toMatch(/compiler/i);
expect(result.details).toMatchObject({ codelldbSource: 'env:CODELLDB_PATH' });
});
});
2 changes: 2 additions & 0 deletions packages/adapter-dotnet/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export { DotnetDebugAdapter } from './DotnetDebugAdapter.js';
export {
findNetcoredbgExecutable,
findDotnetBackend,
getNetcoredbgVersion,
getDotnetSdkVersion,
listDotnetProcesses,
isPortablePdb,
findPdb2PdbExecutable,
Expand Down
Loading
Loading