Skip to content
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ The codebase follows a **layered architecture with dependency injection** and **
- Entry point for MCP protocol communication
- `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. One core per process in both: on `http` every MCP session is a facade over the same debug sessions (the id is the capability; one process per agent is the isolation), a debug session outlives the MCP session that created it and ends by its own close, its program ending, or the server exiting. The per-debug-session idle policy is the operator's opt-in (`MCP_DEBUG_SESSION_IDLE_MS`, off by default on every transport; sweep `MCP_DEBUG_SESSION_IDLE_SWEEP_MS`): when set, an abandoned session tree is closed unless a client is attending the process (an open SSE stream), `list_debug_sessions` rows show `idleMs`/`expiresInMs`, and a call naming a session the policy closed answers `closedBy: "idle-policy"`. A session outside the caller's owner scope is hidden from the call's lookups (`src/session/session-scope.ts`), so every tool answers it as an unknown id. The HTTP reapers (`MCP_HTTP_STALE_SESSION_MS`, `MCP_HTTP_STREAM_LOST_SESSION_MS`) reap abandoned MCP sessions only (issue #1025)
- Supports STDIO and Streamable HTTP transport modes. `stdio` is served through the SDK's `serveStdio(factory, { transport })` and speaks both protocol eras (2026-07-28 for a client that probes with `server/discover`, 2025-11-25 and earlier for one that opens with `initialize`); `http` speaks the 2025 era (the 2026 era follows in #1025 step 2, PR 4). One core per process in both: on `http` every MCP session is a facade over the same debug sessions (the id is the capability; one process per agent is the isolation), a debug session outlives the MCP session that created it and ends by its own close, its program ending, or the server exiting. The per-debug-session idle policy is the operator's opt-in (`MCP_DEBUG_SESSION_IDLE_MS`, off by default on every transport; sweep `MCP_DEBUG_SESSION_IDLE_SWEEP_MS`): when set, an abandoned session tree is closed unless a client is attending the process (an open SSE stream), `list_debug_sessions` rows show `idleMs`/`expiresInMs`, and a call naming a session the policy closed answers `closedBy: "idle-policy"`. A session outside the caller's owner scope is hidden from the call's lookups (`src/session/session-scope.ts`), so every tool answers it as an unknown id. The HTTP reapers (`MCP_HTTP_STALE_SESSION_MS`, `MCP_HTTP_STREAM_LOST_SESSION_MS`) reap abandoned MCP sessions only (issue #1025)
- Dynamically discovers available language adapters

2. **Adapter System** (NEW)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ If your agent runs in a terminal, a pipeline, or a cloud sandbox — or needs to
- 💥 **Crash-state debugging by default** – Launch sessions pause on uncaught exceptions with stack and locals live (`breakOnExceptions`; exception class/message surfaced via `lastStop`)
- 🪞 **Read-only IDE mirror** – `expose_session` opens a loopback, token-gated DAP endpoint so a human's IDE can inspect the agent's live session without taking control
- ☸️ **Kubernetes debugging** – port-forward attach for interpreted runtimes, `kubectl debug --target` + attach-by-PID for native processes ([recipe](docs/kubernetes.md), [turnkey manifests](examples/kubernetes/))
- 🔌 **STDIO and Streamable HTTP transports** – Works with any MCP client
- 🔌 **STDIO and Streamable HTTP transports** – Works with any MCP client. `stdio` speaks both protocol eras — the current 2026-07-28 revision (a probing client such as Claude Code's v2 runtime lands on it) and 2025-11-25 and earlier (a client that opens with `initialize`); `http` speaks 2025-11-25 and earlier, with 2026-07-28 next
- 📦 **Zero-runtime dependencies** – Self-contained bundles via esbuild + tsup
- ⚡ **npx ready** – Run directly with `npx @debugmcp/mcp-debugger` - no installation needed
- 🐳 **Docker and npm packages** – Deploy anywhere
Expand Down
1 change: 1 addition & 0 deletions changelog.d/1025-protocol-2026-stdio.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**`stdio` speaks the current MCP protocol revision, 2026-07-28, beside 2025-11-25 and earlier.** The stdio transport is served through the SDK's own serving entry, which answers a client's `server/discover` probe and pins the connection to whichever era its opening message chose: a probing client — Claude Code's v2 runtime probes its stdio servers, and until now recorded mcp-debugger as a legacy peer — lands on 2026-07-28, with `subscriptions/listen` for resource notifications and no `initialize` handshake; a client that opens with `initialize` gets the 2025 era exactly as before, `resources/subscribe` included. The 29 tools, the resources and the prompt are the same in both. The server log names each 2026-07-28 client once, on its first handled request (`[MCP Server] Client identified`, with its self-reported name and version and its protocol revision), as `Client initialized` does for a 2025 client; and the stdio exit line says what happened — the client left (stdin ended), or the transport closed after an error it names. Claude Code may keep a stdio server it once saw answer as legacy pinned that way for up to seven days; restart it after upgrading, or wait it out. Step 2 of #1025; `http` follows.
7 changes: 7 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ Because `stdio` is the default subcommand, bare `mcp-debugger` starts in stdio m
explicitly in a config file is still worth doing: it documents the intent. Console output is
silenced in every mode so nothing can corrupt the JSON-RPC stream.

Both eras of the MCP protocol are served on `stdio`: a client that probes with `server/discover`
(Claude Code's v2 runtime does) gets the current 2026-07-28 revision, and a
client that opens with `initialize` gets 2025-11-25 or earlier, as before. Claude Code may keep a
stdio server it once saw answer as legacy pinned that way for up to seven days; restarting Claude
Code after upgrading the server, or waiting it out, lets it probe again. `http` speaks 2025-11-25
and earlier today; the 2026-07-28 era follows there.

### Claude Desktop, or any client with a JSON config

```json
Expand Down
212 changes: 149 additions & 63 deletions src/cli/stdio-command.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,61 @@
import type { Logger as WinstonLoggerType } from 'winston';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import { serveStdio, StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import { DebugMcpServer, type DebugMcpServerOptions } from '../server.js';
import { createProtocolServer } from '../server/protocol-facade.js';
import type { StdioOptions } from './setup.js';
import type { ProcessLike } from '../interfaces/process-interfaces.js';

export type ServerFactoryOptions = DebugMcpServerOptions;

/** The whole teardown's bound on exit: a wedged proxy cannot block the process from leaving. */
const SHUTDOWN_GUARD_MS = 5000;
/**
* The serving handle's own bound inside that guard. Closing the handle ends an
* open subscriptions/listen stream, which a consumer that stopped reading can
* hold open; bounded on its own, the core's closeAllSessions() always gets its
* turn (review of PR #1116).
*/
const HANDLE_CLOSE_TIMEOUT_MS = 1000;

function unrefDelay(ms: number): Promise<void> {
return new Promise<void>((resolve) => {
const timer = setTimeout(resolve, ms);
timer.unref?.();
});
}

/**
* The SDK's stdio transport closes itself — on stdin's EOF, but also on a
* stdout error (the client's end of the pipe is gone) or an oversized frame,
* with stdin still open — and tells only its own `onclose`, which `serveStdio`
* owns. This subclass lets the command hear every close of the wire, whatever
* ended it, so the process never lives on with a dead instance (issue #1025,
* step 2; review of PR #1116).
*/
class ObservedStdioServerTransport extends StdioServerTransport {
private notified = false;
private readonly onWireClosed: () => void;

constructor(onWireClosed: () => void) {
super();
this.onWireClosed = onWireClosed;
}

override async close(): Promise<void> {
await super.close();
if (this.notified) return;
this.notified = true;
this.onWireClosed();
}
}

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;
/** The SDK's stdio serving entry (issue #1025, step 2); defaults to the real one. */
serveStdio?: typeof serveStdio;
exitProcess?: (code: number) => void;
/** Injectable stdin for tests; defaults to proc.stdin. */
stdin?: NodeJS.ReadStream;
Expand All @@ -25,111 +69,153 @@ export async function handleStdioCommand(
): Promise<void> {
const proc = dependencies.proc ?? process;
const { logger, serverFactory, exitProcess = (code: number) => proc.exit(code) } = dependencies;

if (options.logLevel) {
logger.level = options.logLevel;
}

logger.info('Starting Debug MCP Server in stdio mode');

try {

const debugMcpServer = serverFactory({
logLevel: options.logLevel,
logFile: options.logFile
});

// Create stdio transport
logger.info('[MCP] Creating StdioServerTransport...');
const transport = new StdioServerTransport();

// Keep the event loop alive even if stdin closes (e.g., detached containers).
// Cleared on transport close or signals.
// Cleared on shutdown.
const keepAlive = setInterval(() => {}, 60000);

// 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...');
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):
// exiting without DebugMcpServer.stop() → closeAllSessions() leaves the
// proxy chains — and, for attach sessions, lldb-server's ptrace claim on
// the target — running with no owner. Bounded so a wedged proxy cannot
// block exit (ProxyManager.stop() is internally bounded but runs
// serially per session).
// the target — running with no owner. The serving handle goes first so
// an open subscriptions/listen stream ends gracefully, bounded on its own
// so the core always gets its turn; the whole teardown is bounded so a
// wedged proxy cannot block exit (ProxyManager.stop() is internally
// bounded but runs serially per session). `serving.handle` is set once the
// serving entry below has started; shutdown reads it at call time.
const serving: { handle?: { close(): Promise<void> } } = {};
let shutdownStarted = false;
const shutdownAndExit = (code: number, why: string) => {
if (shutdownStarted) return;
shutdownStarted = true;
try { clearInterval(keepAlive); } catch { /* already cleared */ }
logger.warn(`[MCP] ${why} — stopping debug server and exiting.`);
const guard = new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 5000);
timer.unref?.();
});
void Promise.race([
debugMcpServer.stop().catch((err) => {
const guard = unrefDelay(SHUTDOWN_GUARD_MS);
const teardown = (async () => {
if (serving.handle) {
try {
const closing = serving.handle.close();
// A rejection after the bound below has passed has nowhere to go.
closing.catch(() => {});
const closed = await Promise.race([
closing.then(() => true),
unrefDelay(HANDLE_CLOSE_TIMEOUT_MS).then(() => false)
]);
if (!closed) {
logger.debug(`[MCP] The stdio serving handle did not close within ${HANDLE_CLOSE_TIMEOUT_MS} ms; stopping the debug server anyway.`, { timeoutMs: HANDLE_CLOSE_TIMEOUT_MS });
}
} catch (err) {
logger.debug('[MCP] Error closing the stdio serving handle during exit:', { err });
}
}
try {
await debugMcpServer.stop();
} catch (err) {
logger.error('[MCP] Error stopping debug server during exit:', { err });
}),
guard
]).finally(() => exitProcess(code));
}
})();
void Promise.race([teardown, guard]).finally(() => exitProcess(code));
};

// Ensure deterministic shutdown on transport close (the Transport
// contract's own hook; the v1 SDK left it undocumented)
transport.onclose = () => {
shutdownAndExit(0, 'Transport closed');
};

// Start the debug server
await debugMcpServer.start();
logger.info('Server started successfully in stdio mode');

// Add transport error handling
transport.onerror = (error) => {
logger.error('[MCP] Transport error:', { error });
};

// Keep the process alive
const stdin: NodeJS.ReadableStream = dependencies.stdin ?? proc.stdin;
stdin.resume();

// Stdin is the MCP transport here, so any byte on it proves a client was
// speaking to us — which makes a later EOF mean that client is gone.
// Nothing else will reap us: on Windows a dying parent delivers no signal,
// the SDK transport never notices EOF, and `keepAlive` above holds the
// event loop open, so without this the process runs forever (issue #122).
// and `keepAlive` above holds the event loop open, so without this the
// process runs forever (issue #122).
let sawClientTraffic = false;
// Additive: Node broadcasts each chunk to every 'data' listener and
// stdin.resume() above already put the stream in flowing mode, so this
// cannot starve StdioServerTransport's own reader.
stdin.on('data', () => { sawClientTraffic = true; });

// Container mode keeps one exception, and only one: `docker run` WITHOUT
// -i, where stdin is already closed before any client speaks and the
// server must stay alive anyway (c251b3ff, named in fa827ec2/#130).
// Once traffic has been seen that case is ruled out, so EOF is a real
// disconnect and the container must exit — otherwise `docker run --rm`
// never fires and every session leaks a container (issue #633).
const onStdinGone = (reason: string): void => {
// never fires and every session leaks a container (issue #633). The
// transport closing the wire by itself goes through the same gate.
const onWireGone = (reason: string, clientLeft: boolean): void => {
// Reads MCP_CONTAINER directly on purpose: `proc` is the injected
// ProcessLike this command is tested against, not an IEnvironment.
if (proc.env.MCP_CONTAINER === 'true' && !sawClientTraffic) {
logger.warn('[MCP] Stdin closed before any client traffic; detached container, staying alive.');
logger.warn(`[MCP] ${reason} before any client traffic; detached container, staying alive.`);
return;
}
shutdownAndExit(0, reason);
shutdownAndExit(0, clientLeft ? `${reason}; MCP client disconnected` : reason);
};

// The exit line is what an operator reads to learn why the server went
// away, so it says what is true rather than which listener ran first: only
// a stdin that ended means the client left. The transport also closes the
// wire by itself after a stdout error or an oversized frame, with the
// client possibly still there — then the line names that error instead.
const stdin: NodeJS.ReadableStream = dependencies.stdin ?? proc.stdin;
let lastTransportError: Error | undefined;
const onTransportClosed = (): void => {
const { readableEnded, destroyed } = stdin as { readableEnded?: boolean; destroyed?: boolean };
if (readableEnded || destroyed) {
onWireGone('Stdin ended', true);
} else if (lastTransportError) {
onWireGone(`Stdio transport closed after an error (${lastTransportError.message})`, false);
} else {
onWireGone('Stdio transport closed', false);
}
};

// Create stdio transport
logger.info('[MCP] Creating StdioServerTransport...');
const transport = new ObservedStdioServerTransport(onTransportClosed);

// The SDK's stdio serving entry owns the wire and speaks both protocol
// eras (issue #1025, step 2): the opening message pins one facade for the
// connection — a 2026-07-28 client's server/discover lands on a modern
// instance, a 2025 client's initialize on a legacy one — and the entry
// itself answers server/discover and serves subscriptions/listen. The
// factory may run twice: once for a discover probe that a client then
// abandons for initialize, and once for the pinned instance.
logger.info('[MCP] Serving stdio in both protocol eras...');
const protocolServerFactory = dependencies.protocolServerFactory ?? createProtocolServer;
const serve = dependencies.serveStdio ?? serveStdio;
serving.handle = serve(
({ era }) => protocolServerFactory(debugMcpServer, { era, deliver: 'instance' }),
{
transport,
onerror: (error: Error) => {
lastTransportError = error;
logger.error('[MCP] Transport error:', { error });
}
}
);
logger.info('[MCP] Server connected to stdio transport successfully');

// Start the debug server
await debugMcpServer.start();
logger.info('Server started successfully in stdio mode');

// Keep the process alive
stdin.resume();

// Additive: Node broadcasts each chunk to every 'data' listener and
// stdin.resume() above already put the stream in flowing mode, so this
// cannot starve StdioServerTransport's own reader.
stdin.on('data', () => { sawClientTraffic = true; });

// 'end' alone misses an abruptly dropped pipe. shutdownAndExit is
// idempotent, so the usual end-then-close pair costs nothing.
stdin.on('end', () => onStdinGone('Stdin ended; MCP client disconnected'));
stdin.on('close', () => onStdinGone('Stdin closed; MCP client disconnected'));
stdin.on('error', (error: Error) =>
onStdinGone(`Stdin error (${error.message}); MCP client disconnected`));
// idempotent, so the usual end-then-close pair — and the transport's own
// close on the same EOF — cost nothing.
stdin.on('end', () => onWireGone('Stdin ended', true));
stdin.on('close', () => onWireGone('Stdin closed', true));
stdin.on('error', (error: Error) => onWireGone(`Stdin error (${error.message})`, true));

// Add robust exit/signal diagnostics (logged to file; console output is silenced for protocol safety)
proc.on('SIGTERM', () => {
Expand Down
Loading
Loading