An orientation map of the multi-language architecture: the language-agnostic core, the
IDebugAdaptercontract, and the nine shipped adapters.
The mcp-debugger has undergone a major architectural transformation, evolving from a Python-specific debugging tool into a multi-language debugging platform. This refactoring introduced a clean adapter pattern that separates language-agnostic core functionality from language-specific implementations.
The core components handle session management, process lifecycle, and DAP communication without any language-specific knowledge:
- SessionManager - Thin public facade over the session manager hierarchy (
session-manager-core.ts→session-manager-data.ts→session-manager-operations.ts→session-manager.ts) - Operation collaborators -
session-manager-operations.tsis itself a facade of one-line delegates over per-slice collaborators undersrc/session/{launch,attach,breakpoints,execution,inspection,jvm,mirror}/, wired throughOperationsContext. See component-design.md for the collaborator table - ProxyManager - Manages DAP proxy processes
- AdapterLease - Explicit ownership of one adapter instance for the duration of a launch or attach setup, so a throw anywhere in that window gives the registry slot back (issue #557)
- SessionStore - In-memory session storage
- session-log-layout - The naming contract for a session's on-disk logs (
proxyLogPathFor,sessionRunDirectoryFor,adapterLogPathFor,dapTracePathFor)
The IDebugAdapter interface defines the contract that all language adapters must implement:
export interface IDebugAdapter extends EventEmitter {
readonly language: DebugLanguage;
readonly name: string;
// Lifecycle
initialize(): Promise<void>;
dispose(): Promise<void>;
// Environment validation
validateEnvironment(executablePath?: string): Promise<ValidationResult>;
resolveExecutablePath(preferredPath?: string): Promise<string>;
// DAP operations
buildAdapterCommand(config: AdapterConfig): AdapterCommand;
sendDapRequest<T>(command: string, args?: unknown): Promise<T>;
handleDapEvent(event: DebugProtocol.Event): void;
// ... 30+ methods total
}Each supported language implements the IDebugAdapter interface:
- MockDebugAdapter - Reference implementation for testing
- PythonDebugAdapter - Python/debugpy support
- RubyDebugAdapter - Ruby/rdbg support
- JavascriptDebugAdapter - JavaScript/Node.js support
- RustDebugAdapter - Rust/CodeLLDB support
- GoDebugAdapter - Go/Delve support
- JavaDebugAdapter - Java/JDI support
- DotnetDebugAdapter - .NET/netcoredbg support
- CppDebugAdapter - C/C++/CodeLLDB support
The AdapterRegistry manages available adapters through two mechanisms: explicit registration and dynamic loading. Factories can be pre-registered at startup, or loaded on demand via AdapterLoader when enableDynamicLoading is enabled (or MCP_CONTAINER=true):
// Explicit registration (register is async and returns Promise<void>)
await registry.register('python', new PythonAdapterFactory());
await registry.register('mock', new MockAdapterFactory());
// Dynamic loading happens automatically when create() is called
// for a language without a pre-registered factory
const adapter = await registry.create('go', config); // loads @debugmcp/adapter-go on demandsequenceDiagram
participant Client as MCP Client
participant Server as MCP Server
participant SM as SessionManager
participant AR as AdapterRegistry
participant Adapter as Language Adapter
participant PM as ProxyManager
participant DAP as Debug Adapter Process
Client->>Server: create_debug_session(language='python')
Server->>Server: Validate language via getSupportedLanguagesAsync()
Server->>SM: createSession(language)
SM->>SM: SessionStore.createSession(params)
SM-->>Client: sessionInfo (adapter instance not yet created)
Client->>Server: start_debugging(sessionId)
Server->>SM: startDebugging(sessionId, scriptPath, ...)
SM->>AR: create(language, config) via AdapterLease.acquire()
AR->>Adapter: factory.create(config)
SM->>Adapter: resolveExecutablePath()
SM->>Adapter: buildAdapterCommand()
SM->>PM: lease.transferTo(proxyManagerFactory)
PM->>Adapter: validateEnvironment()
PM->>DAP: spawn proxy worker, which spawns the debug adapter
DAP-->>PM: DAP events
PM-->>Adapter: handleDapEvent()
Adapter-->>SM: emit events
SM-->>Client: debugging updates
The existing ProxyManager provides excellent process management. Rather than rewriting it, we inject adapters to handle language-specific concerns:
// Before: ProxyManager had Python-specific logic
class ProxyManager {
private async spawnPythonDebugger() { /* ... */ }
}
// After: ProxyManager delegates to adapters
class ProxyManager {
constructor(
private adapter: IDebugAdapter | null,
private proxyProcessLauncher: IProxyProcessLauncher,
private fileSystem: IFileSystem,
private logger: ILogger,
runtimeEnv?: ProxyRuntimeEnvironment,
options?: ProxyManagerOptions
) {}
async start(config: ProxyConfig) {
// adapter.buildAdapterCommand() provides the spawn command
// this.prepareSpawnContext() resolves the executable, proxy script and env (ProxyManager's own private method, proxy-manager.ts:703)
// ProxyManager spawns the proxy worker process, which in turn
// spawns the debug adapter using the adapter-provided command
}
}The core remains stable while adapters handle all language-specific variations:
- Core handles: Process lifecycle, IPC, session state, DAP transport
- Adapters handle: Executable discovery, command building, environment validation, language-specific quirks
Adapters extend EventEmitter for loose coupling:
adapter.on('stopped', (event) => {
// Update UI, notify client
});
adapter.on('stateChanged', (oldState, newState) => {
// Track adapter state transitions
});Theory: Adapters handle path translation cleanly
Reality: Path handling is centralized in utility modules rather than delegated to individual debug adapters. SimpleFileChecker and resolvePathForRuntime in src/utils/ handle path resolution, file existence checks, and container-mode prefix logic. Path handling also occurs in proxy script discovery and absolute path resolution for breakpoints. The approach is to use centralized path utilities to avoid duplicating cross-platform logic, while leaving runtime-specific path interpretation to the OS and debug adapter.
Theory: Clean state transitions following VALID_TRANSITIONS
Reality: Some adapters use simpler or looser state models than earlier expectations. The mock adapter policy still maintains explicit initialized/configuration state transitions via createInitialState(), updateStateOnCommand(), and updateStateOnEvent().
Theory: Simple event flow
Reality: Each debugger has quirks. See DAP Sequence Reference for real-world examples:
- Node.js waits for debugger detach
- Python handles sys.exit() specially
- Event ordering matters:
exited→terminated
The adapter pattern adds minimal overhead:
| Operation | Before | After | Impact |
|---|---|---|---|
| Session Creation | 100ms | 105ms | +5% |
| Breakpoint Setting | <10ms | <10ms | None |
| Step Operations | <50ms | <50ms | None |
| Memory per Session | Baseline | +~1MB | Adapter instance |
The refactoring improved testability:
- Unit, integration, and end-to-end suites across three Vitest projects (
vitest.config.ts) - Mock adapter enables integration testing without external dependencies
- Type safety throughout with TypeScript strict mode
- Comprehensive test coverage for all components
Adapters must properly handle DAP events:
// ❌ Wrong: Forgetting to update state
handleDapEvent(event: DebugProtocol.Event): void {
this.emit(event.event, event.body);
}
// ✅ Correct: Update internal state
// Note: Paused execution is primarily modeled at the session/proxy
// orchestration layer. At the adapter level, DEBUGGING indicates
// an active debug session (which may be paused or running).
handleDapEvent(event: DebugProtocol.Event): void {
if (event.event === 'stopped') {
this.currentThreadId = event.body?.threadId;
this.transitionTo(AdapterState.DEBUGGING);
}
this.emit(event.event, event.body);
}The stopped event means PAUSED, not terminated:
// ❌ Wrong: Confusing stopped with terminated
if (event.event === 'stopped') {
this.state = AdapterState.DISCONNECTED; // NO!
}
// ✅ Correct: Stopped = paused for debugging
if (event.event === 'stopped') {
this.state = AdapterState.DEBUGGING; // Paused at breakpoint
}Adapters that need to synthesize session-end events (e.g., a mock adapter or one whose underlying runtime does not emit terminated) should send both exited and terminated. When the real debug adapter process sends these events over DAP, ProxyManager forwards them automatically — adapters in that case do not need to emit them manually.
// ❌ Wrong: Only sending exited (when synthesizing events manually)
async endSession() {
this.emit('exited', { exitCode: 0 });
}
// ✅ Correct: Send both exited and terminated (when synthesizing events manually)
async endSession(exitCode: number) {
this.emit('exited', { exitCode });
this.emit('terminated');
}- Read the Adapter Development Guide to create your own adapter
- Check the API Reference for detailed interface documentation
- Review the Mock Adapter as a working example
- See the CHANGELOG for release history; the historical v0.10-v0.15
migration notes are archived at
docs/archive/migration-guide.md
- v0.24.0 - C/C++ adapter, 9 adapters total; shared
@debugmcp/codelldb-commonCodeLLDB vendoring for Rust and C/C++ - v0.22.0 - Ruby adapter, 8 adapters total
- v0.19.0 - .NET/C# adapter, 7 language adapters total
- v0.18.0 - Go adapter, Java adapter
- v0.17.0 - Rust adapter
- v0.16.0 - JavaScript adapter
- v0.10.0 - Multi-language architecture with adapter pattern
- v0.9.x - Python-specific implementation
- v0.8.x - Initial MCP server implementation
For release-by-release history, see the CHANGELOG.