Skip to content

Latest commit

 

History

History
490 lines (376 loc) · 16.1 KB

File metadata and controls

490 lines (376 loc) · 16.1 KB

Debug Adapter Development Guide

How to build, test, and wire a new language adapter for mcp-debugger. Reference implementation: packages/adapter-go/ (Go/Delve adapter).

What is an Adapter?

An adapter is a language-specific package that implements the Debug Adapter Protocol (DAP) behind a common TypeScript interface (IDebugAdapter). The mcp-debugger core discovers adapters dynamically at runtime using the package naming convention @debugmcp/adapter-<language> and a named export of a factory class named <Language>AdapterFactory.

Benefits:

  • Pluggable language support — install only what you need
  • Small core with lazy loading — faster startup
  • Clean separation of concerns — core vs language details

Prerequisites

  • Node.js 22+
  • pnpm (not npm) — the monorepo uses workspace:* protocol
  • TypeScript 5.9+
  • Familiarity with the Debug Adapter Protocol (DAP)
  • A working debugger for the target language (e.g., Delve for Go, debugpy for Python)

Package Structure

packages/adapter-<language>/
  package.json
  tsconfig.json
  src/
    <language>-adapter-factory.ts    # IAdapterFactory implementation
    <language>-debug-adapter.ts      # IDebugAdapter implementation
    index.ts                         # Package entry point (exports)
    utils/                           # Optional: language-specific helpers
      <language>-utils.ts
  dist/                              # Build output (gitignored)

Naming conventions:

  • Package name: @debugmcp/adapter-<language>
  • Factory class: <Language>AdapterFactory (e.g., GoAdapterFactory)
  • File names: kebab-case (e.g., go-debug-adapter.ts, not GoDebugAdapter.ts)

Step-by-Step Implementation

1. Create the package directory

mkdir -p packages/adapter-<language>/src

2. package.json

Based on packages/adapter-go/package.json:

{
  "name": "@debugmcp/adapter-<language>",
  "version": "0.1.0",
  "description": "<Language> debugging adapter for mcp-debugger using <debugger>",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "build:ci": "tsc -p tsconfig.json --noEmitOnError",
    "clean": "rimraf dist",
    "lint": "eslint src/**/*.ts",
    "test": "vitest run"
  },
  "dependencies": {
    "@debugmcp/shared": "workspace:*",
    "@vscode/debugprotocol": "^1.68.0"
  },
  "devDependencies": {
    "@types/node": "^25.5.0",
    "rimraf": "^6.1.3",
    "typescript": "^5.9.3",
    "vitest": "^4.1.0"
  },
  "keywords": ["mcp", "debugger", "<language>", "dap"],
  "author": "mcp-debugger team",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/debugmcp/mcp-debugger.git",
    "directory": "packages/adapter-<language>"
  }
}

Notes:

  • No peerDependencies — real adapters don't use them.
  • Build uses tsc -p (project mode), not tsc -b (build mode).

3. tsconfig.json

Based on packages/adapter-go/tsconfig.json:

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "composite": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"],
  "references": [
    { "path": "../shared" }
  ]
}

4. Implement IAdapterFactory

The factory creates adapter instances, provides metadata, and validates the environment. Implement the IAdapterFactory interface from @debugmcp/shared — do not extend a base class.

Interface (3 required methods):

interface IAdapterFactory {
  createAdapter(dependencies: AdapterDependencies): IDebugAdapter;
  getMetadata(): AdapterMetadata;
  validate(): Promise<FactoryValidationResult>;
}

Real examplepackages/adapter-go/src/go-adapter-factory.ts:

import { IDebugAdapter } from '@debugmcp/shared';
import { IAdapterFactory, AdapterDependencies, AdapterMetadata, FactoryValidationResult } from '@debugmcp/shared';
import { GoDebugAdapter } from './go-debug-adapter.js';
import { DebugLanguage } from '@debugmcp/shared';
import { findGoExecutable, findDelveExecutable, getGoVersion, getDelveVersion, checkDelveDapSupport } from './utils/go-utils.js';

export class GoAdapterFactory implements IAdapterFactory {
  createAdapter(dependencies: AdapterDependencies): IDebugAdapter {
    return new GoDebugAdapter(dependencies);
  }

  getMetadata(): AdapterMetadata {
    return {
      language: DebugLanguage.GO,
      displayName: 'Go',
      version: '0.1.0',
      author: 'mcp-debugger team',
      description: 'Debug Go applications using Delve (dlv)',
      documentationUrl: 'https://github.com/debugmcp/mcp-debugger/docs/go',
      minimumDebuggerVersion: '0.17.0',
      fileExtensions: ['.go'],
    };
  }

  async validate(): Promise<FactoryValidationResult> {
    const errors: string[] = [];
    const warnings: string[] = [];

    try {
      const goPath = await findGoExecutable();
      const goVersion = await getGoVersion(goPath) || undefined;
      if (goVersion) {
        const [major, minor] = goVersion.split('.').map(Number);
        if (major < 1 || (major === 1 && minor < 18)) {
          errors.push(`Go 1.18 or higher required. Current version: ${goVersion}`);
        }
      }

      try {
        const dlvPath = await findDelveExecutable();
        const dapCheck = await checkDelveDapSupport(dlvPath);
        if (!dapCheck.supported) {
          errors.push('Delve does not support DAP mode.');
        }
      } catch {
        errors.push('Delve (dlv) not found. Install with: go install github.com/go-delve/delve/cmd/dlv@latest');
      }
    } catch (error) {
      errors.push(error instanceof Error ? error.message : 'Go executable not found');
    }

    return { valid: errors.length === 0, errors, warnings };
  }
}

5. Implement IDebugAdapter

The adapter is the core component that manages the debugger lifecycle. It extends EventEmitter and implements IDebugAdapter from @debugmcp/shared.

Key method groups (see packages/shared/src/interfaces/debug-adapter.ts for full interface):

Category Methods
Lifecycle initialize(), dispose()
State getState(), isReady(), getCurrentThreadId()
Validation validateEnvironment(), getRequiredDependencies()
Executable resolveExecutablePath(), getDefaultExecutableName(), getExecutableSearchPaths()
Configuration buildAdapterCommand(), getAdapterModuleName(), getAdapterInstallCommand(), transformLaunchConfig(), getDefaultLaunchConfig()
DAP sendDapRequest(), handleDapEvent(), handleDapResponse()
Connection connect(), disconnect(), isConnected()
Error handling getInstallationInstructions(), getMissingExecutableError(), translateErrorMessage()
Features supportsFeature(), getFeatureRequirements(), getCapabilities()

Key patterns from packages/adapter-go/src/go-debug-adapter.ts:

import { EventEmitter } from 'events';
import { IDebugAdapter, AdapterState, DebugLanguage, AdapterDependencies } from '@debugmcp/shared';

export class GoDebugAdapter extends EventEmitter implements IDebugAdapter {
  readonly language = DebugLanguage.GO;
  readonly name = 'Go Debug Adapter (Delve)';

  private state: AdapterState = AdapterState.UNINITIALIZED;
  private dependencies: AdapterDependencies;

  constructor(dependencies: AdapterDependencies) {
    super();
    this.dependencies = dependencies;
  }

  async initialize(): Promise<void> {
    this.transitionTo(AdapterState.INITIALIZING);
    const validation = await this.validateEnvironment();
    if (!validation.valid) {
      this.transitionTo(AdapterState.ERROR);
      throw new AdapterError(validation.errors[0]?.message, AdapterErrorCode.ENVIRONMENT_INVALID);
    }
    this.transitionTo(AdapterState.READY);
    this.emit('initialized');
  }

  private transitionTo(newState: AdapterState): void {
    const oldState = this.state;
    this.state = newState;
    this.emit('stateChanged', oldState, newState);
  }

  // ... implement all IDebugAdapter methods
}

State machine: UNINITIALIZEDINITIALIZINGREADYCONNECTEDDEBUGGINGDISCONNECTED | ERROR

See packages/adapter-go/src/go-debug-adapter.ts for the complete ~500-line implementation.

6. Package entry point (index.ts)

Based on packages/adapter-go/src/index.ts:

export { GoDebugAdapter } from './go-debug-adapter.js';
export { GoAdapterFactory } from './go-adapter-factory.js';
export * from './utils/go-utils.js';

// Optional default export (not used by the dynamic loader, but included by some adapters)
export default {
  name: 'go',
  factory: (await import('./go-adapter-factory.js')).GoAdapterFactory
};

The dynamic loader resolves adapters by named export — it looks for <Language>AdapterFactory in the module's named exports. The default export is not required.


Monorepo Wiring

After creating the package, wire it into the monorepo. These steps are required for the adapter to be discovered.

1. Root package.json — optionalDependencies

Add your adapter to the optionalDependencies section:

"optionalDependencies": {
  "@debugmcp/adapter-<language>": "workspace:*"
}

2. vitest.config.ts — alias

Add an alias entry in resolve.alias so tests can import your package:

{ find: '@debugmcp/adapter-<language>', replacement: path.resolve(__dirname, './packages/adapter-<language>/src/index.ts') }

3. DebugLanguage enum

Add a new value in packages/shared/src/models/index.ts:

export enum DebugLanguage {
  // ... existing entries
  <LANGUAGE> = '<language>',
}

4. Known adapters list

Add an entry to the known array in src/adapters/adapter-loader.tslistAvailableAdapters():

{ name: '<language>', packageName: '@debugmcp/adapter-<language>', description: '<Language> debugger using <debugger>' },

5. Update adapter count assertions

Grep for toHaveLength assertions in tests that reference adapter/language counts and increment them. Known files:

  • tests/unit/adapters/adapter-loader.test.ts
  • tests/core/unit/session/models.test.ts
  • tests/core/unit/adapters/debug-adapter-interface.test.ts

6. Run pnpm install

Link the new workspace package:

pnpm install

Adapter Policy

Every adapter that runs real debug sessions needs an adapter policy. The policy encodes debugger-specific behaviors for the DAP proxy and session manager.

Creating the policy

File: packages/shared/src/interfaces/adapter-policy-<language>.ts

The policy implements the AdapterPolicy interface (from adapter-policy.ts). Key decisions:

Setting What it controls Example
childSessionStrategy Whether the debugger spawns child DAP sessions 'none' for most adapters
defaultStopOnEntry Whether to pause at program entry false for Go (Delve quirk)
requiresCommandQueueing Whether commands need sequential queueing false for most adapters

See packages/shared/src/interfaces/adapter-policy-go.ts for a minimal, clean policy example.

Wiring the policy (3 locations)

  1. DAP proxysrc/proxy/dap-proxy-worker.tsselectAdapterPolicy() method: add a new else if branch matching your adapter command.

  2. Session managersrc/session/session-manager-data.tsselectPolicy() method: add a new case branch for your DebugLanguage value.

  3. Export — Add the policy to packages/shared/src/index.ts so both locations can import it.


Testing

Test location

Tests live at the project root, not inside the package:

tests/adapters/<language>/
  unit/
    <language>-adapter-factory.test.ts
    <language>-debug-adapter.test.ts
    <language>-utils.test.ts           # if you have utils
  integration/
    <language>-session-smoke.test.ts

Adapter policy tests go in: tests/unit/shared/adapter-policy-<language>.test.ts

Unit test pattern

From tests/adapters/go/unit/go-adapter-factory.test.ts:

import { describe, it, expect, beforeEach } from 'vitest';
import { GoAdapterFactory } from '@debugmcp/adapter-go';

describe('GoAdapterFactory', () => {
  let factory: GoAdapterFactory;

  beforeEach(() => {
    factory = new GoAdapterFactory();
  });

  it('creates an adapter', () => {
    const adapter = factory.createAdapter({
      fileSystem: {} as any,
      logger: {} as any,
      environment: {} as any,
    });
    expect(adapter).toBeDefined();
    expect(adapter.language).toBe('go');
  });

  it('returns correct metadata', () => {
    const meta = factory.getMetadata();
    expect(meta.language).toBe('go');
    expect(meta.displayName).toBe('Go');
    expect(meta.fileExtensions).toContain('.go');
  });
});

Integration smoke test

Verify end-to-end adapter discovery:

  1. Build: npm run build
  2. Call list_supported_languages — your language should appear with installed: true
  3. Call create_debug_session with "language": "<language>" — should succeed

Dynamic Loading

The AdapterLoader (src/adapters/adapter-loader.ts) resolves adapters in this order:

  1. Package import: import('@debugmcp/adapter-<language>')
  2. Fallback 1: node_modules/@debugmcp/adapter-<language>/dist/index.js
  3. Fallback 2: packages/adapter-<language>/dist/index.js (monorepo dev)

The loader:

  • Converts the language name to a factory class name: capitalize first letter + AdapterFactory (e.g., goGoAdapterFactory)
  • Looks for that class as a named export in the loaded module
  • Instantiates it with new FactoryClass()
  • Caches the factory by language name for subsequent requests

Troubleshooting

Error Cause Fix
MODULE_NOT_FOUND / ERR_MODULE_NOT_FOUND Package not installed pnpm install or npm install @debugmcp/adapter-<language>
Factory class <Language>AdapterFactory not found Export mismatch Ensure your factory is a named export, not just a default export
Adapter not in list_supported_languages Not wired Check: package installed, exports correct, type: "module" set, dist exists
Immediate disconnect on stdio stdout pollution Ensure no console.log on import/initialize — use the logger instead

Debugging dynamic loading: Set --log-file <path> to capture verbose loader logs (don't use DEBUG=mcp:* in STDIO mode — console output is silenced to protect JSON-RPC framing).


Performance Tips

  • Do minimal work in constructors — defer to initialize()
  • Cache executable discovery results (see GoDebugAdapter's 1-minute path cache)
  • The loader caches factories in-memory — repeated session creation is fast

Checklist

  • Package created under packages/adapter-<language>/
  • IAdapterFactory implemented with createAdapter(), getMetadata(), validate()
  • IDebugAdapter fully implemented
  • index.ts exports the named factory class
  • DebugLanguage enum updated in packages/shared/src/models/index.ts
  • Adapter policy created in packages/shared/src/interfaces/adapter-policy-<language>.ts
  • Policy exported from packages/shared/src/index.ts
  • Policy wired into selectAdapterPolicy() in src/proxy/dap-proxy-worker.ts
  • Policy wired into selectPolicy() in src/session/session-manager-data.ts
  • Registered in root package.json optionalDependencies
  • Added to known adapters list in src/adapters/adapter-loader.ts
  • Vitest alias added in vitest.config.ts
  • Adapter count assertions updated in tests
  • Unit and integration tests written under tests/adapters/<language>/
  • pnpm install run to link workspace
  • TypeScript builds to dist/ (ESM)
  • Adapter discovers and loads via list_supported_languages
  • No stdout pollution in stdio mode