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: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ The codebase follows a **layered architecture with dependency injection** and **

1. **MCP Server Layer** (`src/server.ts`, `src/server/`, `src/index.ts`)
- Entry point for MCP protocol communication
- `src/server.ts` is the composition root (`DebugMcpServer`: dependencies, lifecycle, session validation, breakpoint gating, the public facade methods); it implements `ToolContext` and passes itself to the handlers
- `src/server.ts` is the composition root and the shared core (`DebugMcpServer`: dependencies, lifecycle, session validation, breakpoint gating, the public facade methods, the `NotificationHub`); it implements `ToolContext` and passes itself to the handlers. It owns no SDK `Server`: `src/server/protocol-facade.ts` (`createProtocolServer(core, { era, deliver })`) builds one cheap SDK `Server` per connection or request over the core, and `src/server/notification-hub.ts` fans the coalesced `resources/updated` pings and `resources/list_changed` out to every facade's sink (issue #1025)
- `src/server/` holds the tool schemas (`tool-schemas.ts`), argument coercion (`tool-arguments.ts`), the tools/call dispatch wrapper (`tool-dispatch.ts` over `handlers/index.ts`), argument validation (`tool-validation.ts`), result envelopes and the session-error catch helpers (`tool-result.ts`), one handler module per tool family (`handlers/*.ts`) over their shared helpers (`handlers/shared.ts`), and the resource (`output-resources.ts`) and prompt (`prompts.ts`) handlers
- Supports STDIO and Streamable HTTP transport modes
- Dynamically discovers available language adapters
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture/system-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ graph TB
### 1. MCP Server Layer (`src/server.ts`, `src/server/`, `src/index.ts`)
- **Purpose**: Entry point for MCP protocol communication
- **Key Files**:
- `src/server.ts` - `DebugMcpServer`, the composition root: dependencies, lifecycle, session validation, breakpoint gating and the public facade methods; implements `ToolContext`
- `src/server.ts` - `DebugMcpServer`, the composition root and the shared core: dependencies, lifecycle, session validation, breakpoint gating, the public facade methods and the `NotificationHub`; implements `ToolContext`. It owns no SDK `Server`
- `src/server/protocol-facade.ts` - `createProtocolServer(core, { era, deliver })`: one cheap SDK `Server` per connection or request over the core (the eight MCP handlers, the error and initialized hooks, one notification sink that leaves with the facade); `src/server/notification-hub.ts` - the process-wide fan-out of coalesced `resources/updated` pings and `resources/list_changed` to every facade's sink (issue #1025)
- `src/server/` - tool schemas (`tool-schemas.ts`), argument coercion (`tool-arguments.ts`), the tools/call dispatch wrapper (`tool-dispatch.ts` over `handlers/index.ts`), argument validation (`tool-validation.ts`), result envelopes and the session-error catch helpers (`tool-result.ts`), one handler module per tool family (`handlers/*.ts`) over their shared helpers (`handlers/shared.ts`), and the resource (`output-resources.ts`) and prompt (`prompts.ts`) handlers
- `src/index.ts` - the `dist/index.js` entry: the prologue (`src/cli/entry-prologue.ts`, console silencing and argv cleanup, evaluated before anything else), then `main()` from `src/main.ts` — the side-effect-free module the npm bundle's `cli-entry.ts` imports directly (#732); the four subcommands are wired in `src/cli/setup.ts`: `stdio` (default), `http` (Streamable HTTP, recommended, `-p/--port`, default 3001), `doctor` (toolchain self-check), `check-rust-binary`
- `src/server/output-resources.ts` - the MCP resource handlers: `debug://sessions/{id}/output` (subscribable plain-text transcript of captured debuggee output, coalesced `resources/updated` notifications) and `debug://sessions/{id}/proxy-log` (a sanitized, bounded tail of the session's proxy log — 64 KiB / 80 lines, on-demand snapshot, not subscribable, and listed only once the session has a log directory)
Expand Down
18 changes: 16 additions & 2 deletions src/cli/http-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import type { Express, Request, Response, NextFunction } from 'express';
import express from 'express';
import { randomUUID } from 'crypto';
import { IncomingMessage, ServerResponse } from 'http';
import { isInitializeRequest, ProtocolErrorCode } from '@modelcontextprotocol/server';
import { isInitializeRequest, ProtocolErrorCode, type Server } from '@modelcontextprotocol/server';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import { DebugMcpServer, type DebugMcpServerOptions } from '../server.js';
import { createProtocolServer } from '../server/protocol-facade.js';
import { attachSharedFileTransport } from '../utils/logger.js';
import type { HttpOptions } from './setup.js';
import {
Expand Down Expand Up @@ -68,6 +69,8 @@ export function describeForeignDebugSession(
export interface HttpCommandDependencies {
logger: WinstonLoggerType;
serverFactory: (options: ServerFactoryOptions) => DebugMcpServer;
/** Builds the SDK Server facade over a core (issue #1025, step 2); defaults to createProtocolServer. */
protocolServerFactory?: typeof createProtocolServer;
exitProcess?: (code: number) => void;
/** Injectable stdin for tests; defaults to proc.stdin. */
stdin?: NodeJS.ReadStream;
Expand All @@ -78,6 +81,8 @@ export interface HttpCommandDependencies {
interface SessionData {
transport: NodeStreamableHTTPServerTransport;
server: DebugMcpServer;
/** The SDK Server facade connected to this session's transport (issue #1025, step 2). */
facade: Server;
/** Timestamp of the last routed request (or stream close) for this session. */
lastActivity: number;
/** Open SSE (GET) streams; a session with a live stream is never idle. */
Expand Down Expand Up @@ -181,6 +186,7 @@ export function createHttpApp(
dependencies: HttpCommandDependencies
): Express {
const { logger, serverFactory } = dependencies;
const protocolServerFactory = dependencies.protocolServerFactory ?? createProtocolServer;
const proc = dependencies.proc ?? process;

// The app is built here rather than with the SDK's createMcpExpressApp()
Expand Down Expand Up @@ -346,6 +352,9 @@ export function createHttpApp(
describeForeignSession,
});
await newDebugServer.start();
// The SDK Server this session speaks through: a legacy-era facade over
// the core (issue #1025, step 2).
const facade = protocolServerFactory(newDebugServer, { era: 'legacy', deliver: 'instance' });

// Forward declarations so the closures below can refer to the transport.
// The SDK assigns its own internal sessionId before invoking onsessioninitialized.
Expand All @@ -358,6 +367,7 @@ export function createHttpApp(
httpSessions.set(sid, {
transport: createdTransport,
server: newDebugServer,
facade,
lastActivity: Date.now(),
openStreams: 0,
hadStream: false,
Expand All @@ -368,6 +378,10 @@ export function createHttpApp(
});
createdTransport = newTransport;

// Set BEFORE facade.connect(): the SDK wraps a transport.onclose that
// is already in place and then runs its own teardown, which is what
// reaches server.onclose and detaches the facade's notification sink.
// Assigning after connect() would replace that wrapper (issue #1025).
newTransport.onclose = () => {
const sid = newTransport.sessionId;
if (!sid) return;
Expand All @@ -385,7 +399,7 @@ export function createHttpApp(
logger.error(`HTTP transport error for session ${sid}`, error);
};

await newDebugServer.server.connect(newTransport);
await facade.connect(newTransport);
transport = newTransport;
} else if (sessionId) {
logger.warn('Rejecting MCP request: unknown session ID', { method: req.method });
Expand Down
10 changes: 8 additions & 2 deletions src/cli/stdio-command.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Logger as WinstonLoggerType } from 'winston';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import { DebugMcpServer } from '../server.js';
import { createProtocolServer } from '../server/protocol-facade.js';
import type { StdioOptions } from './setup.js';
import type { ProcessLike } from '../interfaces/process-interfaces.js';

Expand All @@ -12,6 +13,8 @@ export interface ServerFactoryOptions {
export interface StdioCommandDependencies {
logger: WinstonLoggerType;
serverFactory: (options: ServerFactoryOptions) => DebugMcpServer;
/** Builds the SDK Server facade over the core (issue #1025, step 2); defaults to createProtocolServer. */
protocolServerFactory?: typeof createProtocolServer;
exitProcess?: (code: number) => void;
/** Injectable stdin for tests; defaults to proc.stdin. */
stdin?: NodeJS.ReadStream;
Expand Down Expand Up @@ -46,9 +49,12 @@ export async function handleStdioCommand(
// Cleared on transport close or signals.
const keepAlive = setInterval(() => {}, 60000);

// Connect MCP server to transport
// Connect a legacy-era protocol facade over the core to the transport
// (issue #1025, step 2): the core owns no SDK Server of its own.
logger.info('[MCP] Connecting server to stdio transport...');
await debugMcpServer.server.connect(transport);
const protocolServerFactory = dependencies.protocolServerFactory ?? createProtocolServer;
const facade = protocolServerFactory(debugMcpServer, { era: 'legacy', deliver: 'instance' });
await facade.connect(transport);
logger.info('[MCP] Server connected to stdio transport successfully');

// Every exit path must tear down the debug sessions first (issue #337):
Expand Down
114 changes: 51 additions & 63 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* the sibling directory src/server/ (NodeNext resolves ./server.js and
* ./server/x.js without conflict).
*/
import { Server, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/server';
import { ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/server';
import { buildServerInstructions } from './skill-content.js';
import { getVersion } from './cli/version.js';
import {
Expand All @@ -29,6 +29,7 @@ import {
isSessionTerminated,
SessionState,
IEnvironment,
IFileSystem,
ILogger,
ExceptionBreakMode
} from '@debugmcp/shared';
Expand All @@ -49,15 +50,14 @@ import {
import { isRedactionEnabled } from './utils/redaction-mode.js';
import { getVariableAccessMode } from './utils/variable-access.js';
import { assertLineContent, resolveStatement, stripTrailingComment } from './utils/breakpoint-resolver.js';
import { OutputResourceNotifier, registerResourceHandlers } from './server/output-resources.js';
import { registerPromptHandlers } from './server/prompts.js';
import { outputResourceUri } from './server/output-resources.js';
import { NotificationHub } from './server/notification-hub.js';
import {
discoverSupportedLanguages,
buildLanguageMetadata,
LanguageMetadata
} from './server/language-discovery.js';
import type { ToolContext, SetBreakpointRequest } from './server/tool-context.js';
import { registerToolHandlers } from './server/tool-dispatch.js';

export { coerceToolArguments } from './server/tool-arguments.js';
export type { SetBreakpointRequest };
Expand All @@ -79,10 +79,13 @@ export interface DebugMcpServerOptions {
}

/**
* Main Debug MCP Server class
* The debug core (issue #1025, step 2): the dependency container, the
* SessionManager, the per-process services and the notification hub, shared by
* every protocol facade in the process. It owns no SDK Server: the CLI
* commands build one per connection or request with
* `createProtocolServer(core, …)` (src/server/protocol-facade.ts).
*/
export class DebugMcpServer implements ToolContext {
public server: Server;
/** @internal ToolContext dependency; read live by the tool handlers. */
public readonly sessionManager: SessionManager;
/** @internal ToolContext dependency; read live by the tool handlers. */
Expand All @@ -97,10 +100,26 @@ export class DebugMcpServer implements ToolContext {
public readonly buildStaleness: BuildStalenessChecker;
/** @internal ToolContext dependency; read live by the tool handlers. */
public readonly environment: IEnvironment;
/** The file system the resource handlers read proxy-log tails through. */
public readonly fileSystem: IFileSystem;
/**
* The release version every facade advertises (clients and the MCP Registry
* show it; the Docker bundle reads it baked in, issue #1054). Read once here:
* the helper walks the file system, and a facade is built per request.
*/
public readonly version: string;
/**
* The MCP `instructions` every facade advertises. Mode-gated (issue #271):
* the handshake must not teach restricted addressing features. Env is
* process-stable, so construction time is fine. Redaction state rides along
* (issue #237) so agents aren't surprised by <redacted:...> placeholders.
*/
public readonly instructions: string;

// Debuggee-output resource subscriptions (issue #218).
// Debuggee-output resource notifications (issue #218), fanned out to every
// facade's sink by the one hub (issue #1025, step 2).
/** @internal ToolContext dependency; read live by the tool handlers. */
public readonly outputResources: OutputResourceNotifier;
public readonly notifications: NotificationHub;
/** @internal ToolContext dependency; read live by the tool handlers. */
public readonly validationCache = new ValidationResultCache();

Expand Down Expand Up @@ -731,73 +750,42 @@ export class DebugMcpServer implements ToolContext {
this.logger
);

// The release version, not a literal: clients and the MCP Registry show this
// string, and the Docker bundle reads it baked in (issue #1054).
this.server = new Server(
{ name: 'debug-mcp-server', version: getVersion() },
{
capabilities: { tools: {}, resources: { subscribe: true, listChanged: true }, prompts: {} },
// Mode-gated (issue #271): the handshake must not teach restricted
// addressing features. Env is process-stable, so constructor-time is fine.
// Redaction state rides along (issue #237) so agents aren't surprised
// by <redacted:...> placeholders.
instructions: buildServerInstructions(getBpAddressingMode(this.environment), {
redactionEnabled: isRedactionEnabled(this.environment),
variableAccessMode: getVariableAccessMode(this.environment)
})
}
);
// The protocol version each client negotiates, so the move to the
// 2026-07-28 revision can be timed from real traffic (issue #1025).
this.server.oninitialized = () => {
const client = this.server.getClientVersion();
this.logger.info('[MCP Server] Client initialized', {
protocolVersion: this.server.getNegotiatedProtocolVersion(),
client: client ? `${client.name} ${client.version}` : undefined
});
};
this.fileSystem = dependencies.fileSystem;
this.version = getVersion();
this.instructions = buildServerInstructions(getBpAddressingMode(this.environment), {
redactionEnabled: isRedactionEnabled(this.environment),
variableAccessMode: getVariableAccessMode(this.environment)
});

const sessionManagerConfig: SessionManagerConfig = {
logDirBase: containerConfig.sessionLogDirBase
};

this.sessionManager = new SessionManager(sessionManagerConfig, dependencies);
this.outputResources = new OutputResourceNotifier(this.server, this.logger);

registerToolHandlers(this.server, this);
// Tool handlers read every dependency live through `ctx` (=== this) at call
// time; the resource and prompt layers capture `sessionManager` and
// `environment` here instead. Both fields are readonly, so production never
// observes the difference — but a test that swaps `sessionManager` after
// construction sees the swap in tool handlers only, not in resource handlers.
registerResourceHandlers(
this.server,
this.sessionManager,
this.outputResources,
dependencies.fileSystem
);
registerPromptHandlers(this.server, this.environment);
this.sessionManager.on('output-captured', this.outputResources.handleOutputCaptured);
this.notifications = new NotificationHub(this.logger);

this.sessionManager.on('output-captured', this.handleOutputCaptured);
// A child session adopted by the server (the session tree, issue #1081)
// is a new output resource: subscribed clients learn of it as they do of
// a session created or closed through the tools.
this.sessionManager.on('child-session-adopted', this.handleChildSessionAdopted);
// Removals also happen during relaunch and terminating detach, outside
// close_debug_session. Clean up each resource at the removal itself.
this.sessionManager.on('session-removed', this.handleSessionRemoved);
this.server.onerror = (error) => {
this.logger.error('Server error', { error });
};
}

/** One listener instance, so stop() can remove exactly what start wired (issue #1081). */
/** One listener instance each, so stop() can remove exactly what the constructor wired (issue #1081). */
private readonly handleOutputCaptured = (sessionId: string): void => {
this.notifications.resourceUpdated(outputResourceUri(sessionId));
};

private readonly handleChildSessionAdopted = (): void => {
this.outputResources.notifyListChanged();
this.notifications.resourcesChanged();
};

private readonly handleSessionRemoved = (sessionId: string): void => {
this.outputResources.forgetSession(sessionId);
this.outputResources.notifyListChanged();
this.notifications.forgetUri(outputResourceUri(sessionId));
this.notifications.resourcesChanged();
};

/**
Expand Down Expand Up @@ -828,16 +816,16 @@ export class DebugMcpServer implements ToolContext {
public async start(): Promise<void> {
// For MCP servers, start is handled by transport
const buildTime = new Date().toISOString();
this.logger.info(`[MCP Server] Started at ${buildTime}, version: ${getVersion()}`);
this.logger.info(`[MCP Server] Started at ${buildTime}, version: ${this.version}`);
}

public async stop(): Promise<void> {
await this.sessionManager.closeAllSessions();
// Tear down output-resource bookkeeping (issue #218): pending debounce
// timers and the SessionManager listener must not outlive the server
// (the test suite runs with a strict leak guard).
this.outputResources.dispose();
this.sessionManager.removeListener('output-captured', this.outputResources.handleOutputCaptured);
// Tear down notification bookkeeping (issue #218): pending debounce
// timers, the sinks and the SessionManager listeners must not outlive the
// core (the test suite runs with a strict leak guard).
this.notifications.dispose();
this.sessionManager.removeListener('output-captured', this.handleOutputCaptured);
this.sessionManager.removeListener('child-session-adopted', this.handleChildSessionAdopted);
this.sessionManager.removeListener('session-removed', this.handleSessionRemoved);
this.logger.info('Debug MCP Server stopped');
Expand Down
4 changes: 2 additions & 2 deletions src/server/handlers/session-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ export const createDebugSessionTool: ToolHandler = async (ctx, args) => {
timestamp: Date.now()
});

// A new output resource is now listable (issue #218)
ctx.outputResources.notifyListChanged();
// A new output resource is now listable (issue #218), for every connected client
ctx.notifications.resourcesChanged();

if (attachRequest) {
// Attach mode: immediately attach to the running process
Expand Down
Loading
Loading