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
2 changes: 2 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export type {
AdapterModes,
AttachMechanism,
AdapterInfo,
AdapterManifestEntry,
FactoryLoadResult,
AdapterRegistryConfig,

// Validation
Expand Down
93 changes: 91 additions & 2 deletions packages/shared/src/interfaces/adapter-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface IAdapterRegistry {
* @param factory Factory to create adapter instances
* @throws Error if language is already registered
*/
register(language: string, factory: IAdapterFactory): void;
register(language: string, factory: IAdapterFactory): Promise<void>;

/**
* Unregister an adapter factory
Expand Down Expand Up @@ -55,6 +55,42 @@ export interface IAdapterRegistry {
*/
isLanguageSupported(language: string): boolean;

/**
* List every known language, registered or dynamically loadable.
*/
listLanguages(): Promise<string[]>;

/**
* List every known adapter with its install state and attach mechanism.
*/
listAvailableAdapters(): Promise<AdapterManifestEntry[]>;

/**
* Get the factory for a language, dynamically loading it when enabled.
* Returns undefined when no factory can be produced (never throws) — use
* getFactoryResult when the failure reason matters.
*
* These four members are the typed surface behind issue #435 part 4: the
* availability probe and launch gate used to reach them through
* `as unknown as` duck-typing, so a rename on the concrete registry
* compiled clean and silently degraded every language to fail-open.
*/
getFactory(language: string): Promise<IAdapterFactory | undefined>;

/**
* Get a language's metadata without registering or instantiating anything.
*/
getFactoryMetadata(language: string): Promise<AdapterMetadata | undefined>;

/**
* Diagnostics variant of getFactory: never throws, and carries the load
* failure so doctor can report the real import error instead of "the
* registry returned no factory". Optional so minimal registry doubles
* that stub only getFactory keep working; consumers must treat absence
* as "use getFactory".
*/
getFactoryResult?(language: string): Promise<FactoryLoadResult>;

/**
* Get metadata about a registered adapter
* @param language Language identifier
Expand Down Expand Up @@ -226,6 +262,43 @@ export interface FactoryValidationResult {
details?: Record<string, unknown>;
}

/**
* A known-adapter manifest entry, as reported by
* IAdapterRegistry.listAvailableAdapters(): the static facts about an
* adapter package independent of whether its factory has been loaded.
*/
export interface AdapterManifestEntry {
/** Language identifier, e.g. 'python' */
name: string;

/** npm package name, e.g. '@debugmcp/adapter-python' */
packageName: string;

/** Human-readable description */
description?: string;

/** Whether the adapter package is installed in this runtime */
installed: boolean;

/** How the adapter implements attach; absent means unknown (treat as 'none') */
attach?: AttachMechanism;
}

/**
* Outcome of a non-throwing factory-load attempt
* (IAdapterRegistry.getFactoryResult). Exactly one of the fields is set.
*/
export interface FactoryLoadResult {
/** The loaded factory, when one could be produced */
factory?: IAdapterFactory;

/** Set when a dynamic load was attempted and failed */
loadError?: Error;

/** Set when nothing is registered/cached and dynamic loading is disabled */
dynamicLoadingDisabled?: boolean;
}

/**
* One resolved toolchain component (a doctor table cell): the runtime a
* debuggee needs (Python, Node.js, a C++ compiler) or the debug backend that
Expand Down Expand Up @@ -316,6 +389,15 @@ export interface AdapterRegistryConfig {
* enables it.
*/
enableDynamicLoading?: boolean;

/**
* Sink for discovery-fallback warnings (loader failures, malformed factory
* metadata). Injected by the DI container so registry breadcrumbs follow
* the configured log level/file instead of a per-instance logger piping
* into the process-lifetime transport (issue #404 leak class). Absent →
* warnings are dropped.
*/
logger?: { warn?: (message: string) => void };
}

// ===== Error Types =====
Expand Down Expand Up @@ -380,7 +462,14 @@ export function isAdapterRegistry(obj: unknown): obj is IAdapterRegistry {
obj !== null &&
'register' in obj &&
'create' in obj &&
'getSupportedLanguages' in obj
'getSupportedLanguages' in obj &&
// The typed discovery surface (issue #435 part 4): certifying a legacy
// three-method registry would hand callers of these required members a
// runtime TypeError with no compile-time warning.
'listLanguages' in obj &&
'listAvailableAdapters' in obj &&
'getFactory' in obj &&
'getFactoryMetadata' in obj
);
}

Expand Down
49 changes: 49 additions & 0 deletions packages/shared/tests/unit/adapter-registry-guards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { isAdapterRegistry, isAdapterFactory } from '../../src/index.js';

const noop = () => undefined;

describe('isAdapterRegistry', () => {
const fullRegistry = {
register: noop,
unregister: noop,
create: noop,
getSupportedLanguages: noop,
isLanguageSupported: noop,
listLanguages: noop,
listAvailableAdapters: noop,
getFactory: noop,
getFactoryMetadata: noop,
getAdapterInfo: noop,
getAllAdapterInfo: noop,
disposeAll: noop,
getActiveAdapterCount: noop
};

it('accepts a registry with the full typed surface', () => {
expect(isAdapterRegistry(fullRegistry)).toBe(true);
});

it('rejects a legacy registry missing the typed discovery surface (issue #435 part 4)', () => {
// Pre-part-4 registries had only register/create/getSupportedLanguages;
// certifying one would reintroduce guard-shaped duck-typing: callers of
// the new required members would TypeError at runtime.
expect(
isAdapterRegistry({ register: noop, create: noop, getSupportedLanguages: noop })
).toBe(false);
});

it('rejects non-objects', () => {
expect(isAdapterRegistry(null)).toBe(false);
expect(isAdapterRegistry(undefined)).toBe(false);
expect(isAdapterRegistry('registry')).toBe(false);
});
});

describe('isAdapterFactory', () => {
it('accepts a factory without the optional describeToolchain member', () => {
expect(
isAdapterFactory({ createAdapter: noop, getMetadata: noop, validate: noop })
).toBe(true);
});
});
14 changes: 8 additions & 6 deletions src/adapters/adapter-loader.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IAdapterFactory, AttachMechanism } from '@debugmcp/shared';
import { IAdapterFactory, AttachMechanism, AdapterManifestEntry } from '@debugmcp/shared';
import type { Logger as WinstonLogger } from 'winston';
import { createLogger } from '../utils/logger.js';
import { createRequire } from 'module';
Expand Down Expand Up @@ -53,11 +53,13 @@ export function createDefaultPackageResolver(io: {
};
}

export interface AdapterMetadata {
name: string;
packageName: string;
description?: string;
installed: boolean;
/**
* The loader's manifest entry — the shared AdapterManifestEntry with attach
* required (the known-adapter list always declares it). Kept as a distinct
* name because shared's AdapterMetadata is the factory-declared metadata, a
* different shape entirely.
*/
export interface AdapterMetadata extends AdapterManifestEntry {
/** How the adapter implements attach mode (static knowledge; kept in sync with each factory's declaration) */
attach: AttachMechanism;
}
Expand Down
106 changes: 77 additions & 29 deletions src/adapters/adapter-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
* @since 2.0.0
*/
import { EventEmitter } from 'events';
import {
IAdapterRegistry,
IAdapterFactory,
import {
IAdapterRegistry,
IAdapterFactory,
AdapterDependencies,
AdapterInfo,
AdapterNotFoundError,
Expand All @@ -17,7 +17,7 @@ import {
ActiveAdapterMap
} from '@debugmcp/shared';
import { IDebugAdapter, AdapterConfig } from '@debugmcp/shared';
import type { AdapterMetadata as SharedAdapterMetadata } from '@debugmcp/shared';
import type { AdapterMetadata as SharedAdapterMetadata, AdapterManifestEntry, FactoryLoadResult } from '@debugmcp/shared';
import { AdapterLoader } from './adapter-loader.js';
import type { AdapterMetadata } from './adapter-loader.js';

Expand All @@ -31,6 +31,11 @@ const DEFAULT_CONFIG: Required<AdapterRegistryConfig> = {
autoDispose: true,
autoDisposeTimeout: 300000, // 5 minutes
enableDynamicLoading: false,
// No injected sink → discovery warnings are dropped. Deliberately NOT a
// per-instance createLogger(): HTTP mode builds a registry per session and
// a per-instance winston logger pipes each into the process-lifetime
// shared transport with no detach path (the issue-#404 leak class).
logger: {},
};

/**
Expand All @@ -46,6 +51,10 @@ export class AdapterRegistry extends EventEmitter implements IAdapterRegistry {
// Dynamic loading is opt-in via constructor config or MCP_CONTAINER=true env var
private readonly dynamicEnabled: boolean;

private warn(message: string): void {
this.config.logger.warn?.(message);
}

constructor(config: AdapterRegistryConfig = {}) {
super();
this.config = { ...DEFAULT_CONFIG, ...config };
Expand Down Expand Up @@ -259,8 +268,14 @@ export class AdapterRegistry extends EventEmitter implements IAdapterRegistry {
installed.add(adapter.name);
}
}
} catch {
// Ignore loader errors in bundled environments where adapters are embedded.
} catch (error) {
// Fall back to registered adapters (bundled environments embed them),
// but leave a breadcrumb — a broken loader should not be silent.
this.warn(
`[AdapterRegistry] listLanguages: loader discovery failed, falling back to registered adapters: ${
error instanceof Error ? error.message : String(error)
}`
);
}

// Always include statically registered adapters so bundled builds expose them.
Expand All @@ -274,16 +289,31 @@ export class AdapterRegistry extends EventEmitter implements IAdapterRegistry {
/**
* List detailed adapter metadata (known + install status)
*/
async listAvailableAdapters(): Promise<AdapterMetadata[]> {
async listAvailableAdapters(): Promise<AdapterManifestEntry[]> {
const registered = new Set(this.getSupportedLanguages());

const buildEntry = (language: string): AdapterMetadata => ({
name: language,
packageName: `@debugmcp/adapter-${language}`,
description: undefined,
installed: true,
attach: this.factories.get(language)?.getMetadata().modes?.attach ?? 'none'
});
const buildEntry = (language: string): AdapterMetadata => {
// A registered plain-JS factory can throw from getMetadata(); one bad
// factory must not reject the whole listing (doctor would lose every
// verdict). Same defense probeLanguageEntry applies per entry.
let attach: AdapterMetadata['attach'] = 'none';
try {
attach = this.factories.get(language)?.getMetadata().modes?.attach ?? 'none';
} catch (error) {
this.warn(
`[AdapterRegistry] getMetadata() threw for registered '${language}'; listing it with attach 'none'. ${
error instanceof Error ? error.message : String(error)
}`
);
}
return {
name: language,
packageName: `@debugmcp/adapter-${language}`,
description: undefined,
installed: true,
attach
};
};

if (!this.dynamicEnabled) {
// Provide minimal metadata from registered factories
Expand All @@ -298,8 +328,13 @@ export class AdapterRegistry extends EventEmitter implements IAdapterRegistry {
results.set(adapter.name, { ...adapter, installed });
registered.delete(adapter.name);
}
} catch {
// Ignore loader failures and fall back to registered adapters.
} catch (error) {
// Fall back to registered adapters, but leave a breadcrumb.
this.warn(
`[AdapterRegistry] listAvailableAdapters: loader discovery failed, falling back to registered adapters: ${
error instanceof Error ? error.message : String(error)
}`
);
}

for (const language of registered) {
Expand All @@ -310,27 +345,40 @@ export class AdapterRegistry extends EventEmitter implements IAdapterRegistry {
}

/**
* Get the factory for a language without creating an adapter instance.
* Checks registered factories first, then the loader cache, then attempts
* a dynamic load (when enabled). Returns undefined if unavailable.
* Get the factory for a language without creating an adapter instance,
* with the load failure preserved (issue #435 part 4): checks registered
* factories first, then the loader cache, then attempts a dynamic load
* (when enabled). Never throws — a failed load comes back as loadError so
* the availability probe can surface the real import error instead of
* "the registry returned no factory".
*/
async getFactory(language: string): Promise<IAdapterFactory | undefined> {
async getFactoryResult(language: string): Promise<FactoryLoadResult> {
const registered = this.factories.get(language);
if (registered) {
return registered;
return { factory: registered };
}
const cached = this.loader.getCachedFactory(language);
if (cached) {
return cached;
return { factory: cached };
}
if (this.dynamicEnabled) {
try {
return await this.loader.loadAdapter(language);
} catch {
return undefined;
}
if (!this.dynamicEnabled) {
return { dynamicLoadingDisabled: true };
}
return undefined;
try {
return { factory: await this.loader.loadAdapter(language) };
} catch (error) {
return { loadError: error instanceof Error ? error : new Error(String(error)) };
}
}

/**
* Get the factory for a language without creating an adapter instance.
* Fail-open contract: returns undefined whenever no factory can be
* produced, whatever the reason — use getFactoryResult when the reason
* matters.
*/
async getFactory(language: string): Promise<IAdapterFactory | undefined> {
return (await this.getFactoryResult(language)).factory;
}

/**
Expand Down
Loading
Loading