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: 3 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,14 +309,13 @@ When debugging issues:
To add support for a new language:

1. **Create Package**: Add new package under `packages/adapter-{language}/`
2. **Implement Interfaces**: Implement `IAdapterFactory` and `IDebugAdapter` from `@debugmcp/shared`
2. **Implement Interfaces**: Implement `IAdapterFactory` and `IDebugAdapter` from `@debugmcp/shared`. Optionally implement `describeToolchain()` on the factory (using `toolchainComponent` from `@debugmcp/shared`) so `mcp-debugger doctor` renders the adapter's runtime/backend row — without it the doctor table shows empty cells
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`, `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
7. **Add Tests**: Include unit and integration tests in the package
8. **Run `pnpm install`**: To link the new workspace package

Example structure:
```
Expand Down
73 changes: 62 additions & 11 deletions packages/adapter-cpp/src/cpp-adapter-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,27 @@
* Implements the adapter factory interface for dependency injection.
*/
import { IDebugAdapter } from '@debugmcp/shared';
import { IAdapterFactory, AdapterDependencies, AdapterMetadata, FactoryValidationResult } from '@debugmcp/shared';
import { IAdapterFactory, AdapterDependencies, AdapterMetadata, FactoryValidationResult, ToolchainDescription, DescribeToolchainOptions } from '@debugmcp/shared';
import { toolchainComponent, probeWithinBudget } from '@debugmcp/shared';
import { CppDebugAdapter } from './cpp-debug-adapter.js';
import { DebugLanguage } from '@debugmcp/shared';
import { resolveCodeLLDBExecutableWithSource, getCodeLLDBVersion } from '@debugmcp/codelldb-common';
import { findAnyCompiler } from './utils/compile-utils.js';
import { findAnyCompiler, getCompilerInfo } from './utils/compile-utils.js';

/**
* The details shape validate() emits and describeToolchain() reads — keeping
* producer and consumer on one alias makes key renames compiler-checked
* within this package (issue #435).
*/
type CppToolchainDetails = {
codelldbPath?: string;
codelldbVersion?: string;
codelldbSource?: string;
compiler?: string;
platform: string;
arch: string;
timestamp: string;
};

/**
* Factory for creating C/C++ debug adapters
Expand Down Expand Up @@ -70,19 +86,54 @@ export class CppAdapterFactory implements IAdapterFactory {
compiler = foundCompiler;
}

const details: CppToolchainDetails = {
codelldbPath,
codelldbVersion,
codelldbSource,
compiler,
platform: process.platform,
arch: process.arch,
timestamp: new Date().toISOString()
};
return {
valid: errors.length === 0,
errors,
warnings,
details: {
codelldbPath,
codelldbVersion,
codelldbSource,
compiler,
platform: process.platform,
arch: process.arch,
timestamp: new Date().toISOString()
}
details
};
}

/**
* Doctor row (issue #435): reuses the validate()-discovered compiler
* command instead of re-probing the whole candidate list; the --version
* banner already names the command, so the bare command only shows when no
* banner was captured (including when the probe fails or outlives the
* advisory budget — probeWithinBudget guarantees this method resolves
* before the caller's hard timeout would blank the row).
*/
async describeToolchain(
validation: FactoryValidationResult,
options?: DescribeToolchainOptions
): Promise<ToolchainDescription> {
const details = (validation.details ?? {}) as Partial<CppToolchainDetails>;
const compiler = details.compiler;
let compilerVersion: string | undefined;
if (compiler) {
const info = await probeWithinBudget(options?.timeoutMs, () => getCompilerInfo(compiler));
compilerVersion = info?.version ?? undefined;
}
return {
runtime: toolchainComponent({
label: 'C/C++ compiler',
path: compilerVersion ? undefined : details.compiler,
version: compilerVersion
}),
backend: toolchainComponent({
label: 'CodeLLDB',
path: details.codelldbPath,
version: details.codelldbVersion,
source: details.codelldbSource
})
};
}
}
143 changes: 143 additions & 0 deletions packages/adapter-cpp/tests/unit/cpp-adapter-factory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { DebugLanguage } from '@debugmcp/shared';
import { CppAdapterFactory } from '../../src/cpp-adapter-factory.js';
import { getCompilerInfo } from '../../src/utils/compile-utils.js';

vi.mock('../../src/utils/compile-utils.js', async (importOriginal) => ({
// Spread the real module so unrelated exports (used by CppDebugAdapter)
// stay defined if this file ever grows adapter-level tests.
...(await importOriginal<typeof import('../../src/utils/compile-utils.js')>()),
findAnyCompiler: vi.fn(),
getCompilerInfo: vi.fn()
}));

const getCompilerInfoMock = vi.mocked(getCompilerInfo);

const validation = (details: Record<string, unknown>) => ({
valid: true,
errors: [],
warnings: [],
details
});

describe('CppAdapterFactory', () => {
it('returns accurate adapter metadata', () => {
const metadata = new CppAdapterFactory().getMetadata();

expect(metadata).toMatchObject({
language: DebugLanguage.CPP,
displayName: 'C/C++',
modes: { launch: true, attach: 'spawn' }
});
});
});

describe('CppAdapterFactory.describeToolchain', () => {
beforeEach(() => {
vi.clearAllMocks();
getCompilerInfoMock.mockReset();
});

it('reuses the validate()-discovered compiler command and shows its version banner', async () => {
getCompilerInfoMock.mockResolvedValue({ command: 'g++', version: 'g++ (GCC) 13.2.0' });

const description = await new CppAdapterFactory().describeToolchain(
validation({
codelldbPath: '/opt/codelldb/adapter/codelldb',
codelldbVersion: '1.11.5',
codelldbSource: 'platform-package',
compiler: 'g++',
platform: 'linux',
arch: 'x64',
timestamp: 'now'
})
);

expect(getCompilerInfoMock).toHaveBeenCalledWith('g++');
expect(description).toEqual({
runtime: { label: 'C/C++ compiler', version: 'g++ (GCC) 13.2.0' },
backend: {
label: 'CodeLLDB',
path: '/opt/codelldb/adapter/codelldb',
version: '1.11.5',
source: 'platform-package'
}
});
});

it('falls back to the bare command when no version banner was captured', async () => {
getCompilerInfoMock.mockResolvedValue({ command: 'g++', version: null });

const description = await new CppAdapterFactory().describeToolchain(
validation({ compiler: 'g++' })
);

expect(description).toEqual({
runtime: { label: 'C/C++ compiler', path: 'g++' }
});
});

it('falls back to the bare command when the banner probe fails outright', async () => {
getCompilerInfoMock.mockRejectedValue(new Error('spawn failed'));

const description = await new CppAdapterFactory().describeToolchain(
validation({ compiler: 'g++' })
);

expect(description).toEqual({
runtime: { label: 'C/C++ compiler', path: 'g++' }
});
});

it('does not probe at all when validate() found no compiler', async () => {
const description = await new CppAdapterFactory().describeToolchain(
validation({
codelldbPath: '/opt/codelldb/adapter/codelldb',
codelldbVersion: '1.11.5',
codelldbSource: 'vendored'
})
);

expect(getCompilerInfoMock).not.toHaveBeenCalled();
expect(description).toEqual({
backend: {
label: 'CodeLLDB',
path: '/opt/codelldb/adapter/codelldb',
version: '1.11.5',
source: 'vendored'
}
});
});

it('renders empty cells when validate() produced no details', async () => {
expect(
await new CppAdapterFactory().describeToolchain({ valid: false, errors: [], warnings: [] })
).toEqual({});
});

it('still resolves with detail-derived cells when the banner probe hangs, inside the advisory budget', async () => {
getCompilerInfoMock.mockReturnValue(new Promise(() => undefined));

const description = await new CppAdapterFactory().describeToolchain(
validation({ compiler: 'g++', codelldbPath: '/opt/codelldb/adapter/codelldb', codelldbVersion: '1.11.5', codelldbSource: 'vendored' }),
{ timeoutMs: 300 }
);

expect(description).toEqual({
runtime: { label: 'C/C++ compiler', path: 'g++' },
backend: { label: 'CodeLLDB', path: '/opt/codelldb/adapter/codelldb', version: '1.11.5', source: 'vendored' }
});
});

it('skips the banner probe entirely when the advisory budget is exhausted', async () => {
const description = await new CppAdapterFactory().describeToolchain(
validation({ compiler: 'g++' }),
{ timeoutMs: 50 }
);

expect(getCompilerInfoMock).not.toHaveBeenCalled();
expect(description).toEqual({
runtime: { label: 'C/C++ compiler', path: 'g++' }
});
});
});
59 changes: 51 additions & 8 deletions packages/adapter-dotnet/src/DotnetAdapterFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,23 @@
* @since 0.2.0
*/
import { IDebugAdapter } from '@debugmcp/shared';
import { IAdapterFactory, AdapterDependencies, AdapterMetadata, FactoryValidationResult } from '@debugmcp/shared';
import { IAdapterFactory, AdapterDependencies, AdapterMetadata, FactoryValidationResult, ToolchainDescription, DescribeToolchainOptions } from '@debugmcp/shared';
import { toolchainComponent, probeWithinBudget } from '@debugmcp/shared';
import { DotnetDebugAdapter } from './DotnetDebugAdapter.js';
import { DebugLanguage } from '@debugmcp/shared';
import { findNetcoredbgExecutable } from './utils/dotnet-utils.js';
import { findNetcoredbgExecutable, getNetcoredbgVersion, getDotnetSdkVersion } from './utils/dotnet-utils.js';

/**
* The details shape validate() emits and describeToolchain() reads — keeping
* producer and consumer on one alias makes key renames compiler-checked
* within this package (issue #435).
*/
type DotnetToolchainDetails = {
debuggerPath?: string;
backend: string;
platform: string;
timestamp: string;
};

/**
* Factory for creating .NET debug adapters
Expand Down Expand Up @@ -56,16 +69,46 @@ export class DotnetAdapterFactory implements IAdapterFactory {
errors.push(error instanceof Error ? error.message : 'netcoredbg not found');
}

const details: DotnetToolchainDetails = {
debuggerPath,
backend: 'netcoredbg',
platform: process.platform,
timestamp: new Date().toISOString()
};
return {
valid: errors.length === 0,
errors,
warnings,
details: {
debuggerPath,
backend: 'netcoredbg',
platform: process.platform,
timestamp: new Date().toISOString()
}
details
};
}

/**
* Doctor row (issue #435): the version probes that used to live in the
* doctor CLI's extras path run here instead, in parallel and best-effort —
* a failed or over-budget probe just leaves its cell field empty, and the
* detail-derived cells always render (probeWithinBudget guarantees this
* method resolves before the caller's hard timeout would blank the row).
*/
async describeToolchain(
validation: FactoryValidationResult,
options?: DescribeToolchainOptions
): Promise<ToolchainDescription> {
const details = (validation.details ?? {}) as Partial<DotnetToolchainDetails>;
const debuggerPath = details.debuggerPath;
const [netcoredbgVersion, sdkVersion] = await Promise.all([
debuggerPath
? probeWithinBudget(options?.timeoutMs, () => getNetcoredbgVersion(debuggerPath))
: Promise.resolve(null),
probeWithinBudget(options?.timeoutMs, () => getDotnetSdkVersion())
]);
return {
runtime: toolchainComponent({ label: '.NET SDK', version: sdkVersion ?? undefined }),
backend: toolchainComponent({
label: 'netcoredbg',
path: debuggerPath,
version: netcoredbgVersion ?? undefined
})
};
}
}
Loading
Loading