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
4 changes: 2 additions & 2 deletions 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, both in both protocol eras (issue #1025): `stdio` is served through the SDK's `serveStdio(factory, { transport })`, which pins a connection to the era its opening message chose (2026-07-28 for a `server/discover` probe, 2025-11-25 and earlier for `initialize`); `http` routes each request with the SDK's `isLegacyRequest` — a POST carrying the 2026 per-request envelope goes to a strict `createMcpHandler(factory, { legacy: 'reject' })` behind `toNodeHandler` (one facade per request, sessionless), everything 2025-shaped to the sessionful app as before. 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 (a 2025 session's open SSE stream, or a 2026-07-28 client's open `subscriptions/listen` 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, both in both protocol eras (issue #1025): `stdio` is served through the SDK's `serveStdio(factory, { transport })`, which pins a connection to the era its opening message chose (2026-07-28 for a `server/discover` probe, 2025-11-25 and earlier for `initialize`); `http` routes each request with the SDK's `isLegacyRequest` — a POST carrying the 2026 per-request envelope goes to a strict `createMcpHandler(factory, { legacy: 'reject' })` behind `toNodeHandler` (one facade per request, sessionless), everything 2025-shaped to the sessionful app as before. 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 (a 2025 session's open SSE stream, or a 2026-07-28 client's open `subscriptions/listen` stream), `list_debug_sessions` rows show `idleMs`/`expiresInMs`, and a call naming a session the policy closed answers `closedBy: "idle-policy"`. A call in flight holds its tree (issue #1127): `SessionManagerCore.holdForCall` stamps the tree's clock, holds the tree, and its release stamps again, so the window starts over when the call answers; the dispatch (`src/server/tool-dispatch.ts` `holdForTheCall`) takes it for every tool that names a session and makes a call that arrives during the sweep's close wait for it, and `create_debug_session` takes it around its inline attach. 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 All @@ -248,7 +248,7 @@ The codebase follows a **layered architecture with dependency injection** and **
- Collaborators see the facade through `OperationsContext` (`operations-context.ts`), whose members are late-bound arrows and getters — never captured references — so reassigning a facade method or writing a tunable on a live instance is visible to them; each takes the narrowest `Pick<>` slice it uses. Collaborator-to-collaborator dependencies are constructor arguments (`DebugLauncher(ctx, proxyLauncher, breakpoints, inFlight)`, `AttachController(ctx, proxyLauncher, breakpoints, pauseCoordinator, inFlight)`) held as captured instance references: their methods resolve at call time, so `vi.spyOn(internals(ops).proxyLauncher, 'start')` intercepts, but reassigning a collaborator field after construction is not observed. The facade owns one shared `InFlightGuard` (`in-flight-guard.ts`, issue #711) that the launcher and the attach controller both take: `startDebugging`, `restartDebugging`, `attachToProcess` and `detachFromProcess` each claim the session before their first await and release it in `finally`, so one launch-shaped call per session at a time; `restartDebugging` replays through the launcher's private `launch()` under its own claim, never through the facade's `startDebugging`. Shared per-request DAP helpers live in `dap-request-helpers.ts`
- Coordinates ProxyManager instances (one per session)
- Handles breakpoint management and queuing
- The session tree (issue #1081, `docs/architecture/session-tree.md`): a child the adapter reports (debugpy and js-debug; on by default for launches and verified-local JavaScript attaches, `dapLaunchArgs: { subProcess: false }` or `{ autoAttachChildProcesses: false }` opts a launch out, `adapterConfig: { autoAttachChildProcesses: false }` a JavaScript attach) is adopted by `SessionManagerCore.adoptReportedChild` as a session of its own — `parentSessionId`, the parent's language and breakpoints, a child proxy view over the parent's worker wired with the same handlers — under the per-tree cap of `child-session-cap.ts` (`DEBUG_MCP_MAX_CHILD_SESSIONS`, default 32, `0` off; beyond it the child is released); `closeSession` closes a subtree deepest first, a root that ends with live children defers its worker's reap until the last ends (`stopOrDeferWorker`), and the launch-shaped tools and the mirror refuse a child id by name (`handlers/shared.ts` `childSessionRefusal`). The tree-aware wait: `ExecutionController.waitForStop` runs on `waitForSubtreeSettle` (`execution/session-state-wait.ts`, the `waitForSettle` primitive shared with `waitForSessionState`) over the core's subtree listeners (`onSubtreeStateChange`, fired for a session and each ancestor on every transition and removal) and `selectSettledInTree` (`execution/tree-settle.ts`, the four-rule read: the session itself when paused or errored, else the first paused or errored descendant depth first in adoption order, else the session itself when ended, else keep waiting; `describeTreeAnswer` builds `children`/`childrenReleased`). The answer fields: `sessionId` (the selected session; set by the wait handler in `handlers/execution-tools.ts`), `children`/`childrenReleased`/`childrenClosed` (hoisted beside `pending` by `handlers/shared.ts` `hoistTreeFields` on the wait, launch, restart and detach answers) and `processEnded`/`released` (spread by the `close_debug_session` handler from the `closeSessionReporting` outcome — the close outcome it answers from, `childrenClosed` included); `releaseDescendants` is the detach cascade (children detached deepest first with `terminateDebuggee: false`, their rows kept as `stopped` with `programEnd.data.released`, then the parent)
- The session tree (issue #1081, `docs/architecture/session-tree.md`): a child the adapter reports (debugpy and js-debug; on by default for launches and verified-local JavaScript attaches, `dapLaunchArgs: { subProcess: false }` or `{ autoAttachChildProcesses: false }` opts a launch out, `adapterConfig: { autoAttachChildProcesses: false }` a JavaScript attach) is adopted by `SessionManagerCore.adoptReportedChild` as a session of its own — `parentSessionId`, the parent's language and breakpoints, a child proxy view over the parent's worker wired with the same handlers — under the per-tree cap of `child-session-cap.ts` (`DEBUG_MCP_MAX_CHILD_SESSIONS`, default 32, `0` off; beyond it the child is released); `closeSession` closes a subtree deepest first, a root that ends with live children defers its worker's reap until the last ends (`stopOrDeferWorker`), and the launch-shaped tools and the mirror refuse a child id by name (`handlers/shared.ts` `childSessionRefusal`). The tree-aware wait: `ExecutionController.waitForStop` runs on `waitForSubtreeSettle` (`execution/session-state-wait.ts`, the `waitForSettle` primitive shared with `waitForSessionState`) over the core's subtree listeners (`onSubtreeStateChange`, fired for a session and each ancestor on every transition and removal) and `selectSettledInTree` (`execution/tree-settle.ts`, the four-rule read: the session itself when paused or errored, else the first paused or errored descendant depth first in adoption order, else the session itself when ended, else keep waiting; `describeTreeAnswer` builds `children`/`childrenReleased`; `describeReleasedChildren` words what the number means from the causes recorded with it, and every launch, restart and wait message that carries the number ends with that sentence, issue #1137). A descendant's user-visible stop is counted on every ancestor (`ManagedSession.descendantStops`, per run), so the end summary of a session whose tree stopped does not say it hit nothing (`summarizeProgramEnd`, issue #1128). The answer fields: `sessionId` (the selected session; set by the wait handler in `handlers/execution-tools.ts`), `children`/`childrenReleased`/`childrenClosed` (hoisted beside `pending` by `handlers/shared.ts` `hoistTreeFields` on the wait, launch, restart and detach answers) and `processEnded`/`released` (spread by the `close_debug_session` handler from the `closeSessionReporting` outcome — the close outcome it answers from, `childrenClosed` included); `releaseDescendants` is the detach cascade (children detached deepest first with `terminateDebuggee: false`, their rows kept as `stopped` with `programEnd.data.released`, then the parent)

4. **ProxyManager** (`src/proxy/proxy-manager.ts`)
- Manages communication with debug proxy process
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,9 @@ with `MCP_DEBUG_SESSION_IDLE_MS` set, a session tree that no tool call has named
closed (a launched program is ended, an attached one released), unless a client is attending the
process — while any client holds a stream open (a 2025 session's SSE stream, a 2026-07-28
client's `subscriptions/listen` stream), nothing expires. A 2026 client that only calls tools is
not attending: with the policy on, its sessions live by tool activity (and an in-flight
`wait_for_stop`) alone, so choose a window longer than such a client's pauses.
not attending: with the policy on, its sessions live by tool activity alone — a call in flight
holds its session, and the window starts over when it answers — so choose a window longer than
such a client's pauses between calls.
`list_debug_sessions` shows each session's idle time, so a forgotten one is easy to find and
close. Abandoned MCP
sessions themselves are reaped separately (`MCP_HTTP_STREAM_LOST_SESSION_MS`,
Expand Down
2 changes: 1 addition & 1 deletion changelog.d/1025-idle-policy.added.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
**An opt-in per-debug-session idle policy; nothing expires by default.** A debug session ends by its own close, its program ending, or the server exiting, on every transport — no timer ends one unless the operator asks. With `MCP_DEBUG_SESSION_IDLE_MS` set, a debug-session tree that no tool call has named for that long is closed through the normal close cascade — a launch root closes its subtree and ends its program, an attach root releases its targets — unless a client is attending the process (an open SSE stream), in which case nothing expires; a tool call naming any session in a tree refreshes the whole tree, an in-flight `wait_for_stop` holds it, and children never expire on a clock of their own. The sweep runs every `MCP_DEBUG_SESSION_IDLE_SWEEP_MS` (default 60 s). The agent can see it: `list_debug_sessions` rows carry `idleMs` (and `expiresInMs` when a policy is on), a tool call naming a session the policy closed answers what happened — when, under which window, and whether its program was ended, released, or was not running by then (`closedBy: "idle-policy"`) — instead of "Session not found", and the server instructions mention the policy when it is on. The HTTP reapers (`MCP_HTTP_STALE_SESSION_MS`, `MCP_HTTP_STREAM_LOST_SESSION_MS`) still reap abandoned MCP sessions, but reaping one no longer closes any debug session: **a crashed client's debug sessions stay until someone closes them, the idle window (if set) runs out, or the server exits.** Set the variable on a shared or long-lived `http` server, and wherever an attached process must not stay paused (the Kubernetes recipe sets 5 minutes). Step 2 of #1025.
**An opt-in per-debug-session idle policy; nothing expires by default.** A debug session ends by its own close, its program ending, or the server exiting, on every transport — no timer ends one unless the operator asks. With `MCP_DEBUG_SESSION_IDLE_MS` set, a debug-session tree that no tool call has named for that long is closed through the normal close cascade — a launch root closes its subtree and ends its program, an attach root releases its targets — unless a client is attending the process (an open SSE stream), in which case nothing expires; a tool call naming any session in a tree refreshes the whole tree and holds it for as long as the call runs (the window starts over when it answers), and children never expire on a clock of their own. The sweep runs every `MCP_DEBUG_SESSION_IDLE_SWEEP_MS` (default 60 s). The agent can see it: `list_debug_sessions` rows carry `idleMs` (and `expiresInMs` when a policy is on), a tool call naming a session the policy closed answers what happened — when, under which window, and whether its program was ended, released, or was not running by then (`closedBy: "idle-policy"`) — instead of "Session not found", and the server instructions mention the policy when it is on. The HTTP reapers (`MCP_HTTP_STALE_SESSION_MS`, `MCP_HTTP_STREAM_LOST_SESSION_MS`) still reap abandoned MCP sessions, but reaping one no longer closes any debug session: **a crashed client's debug sessions stay until someone closes them, the idle window (if set) runs out, or the server exits.** Set the variable on a shared or long-lived `http` server, and wherever an attached process must not stay paused (the Kubernetes recipe sets 5 minutes). Step 2 of #1025.
1 change: 1 addition & 0 deletions changelog.d/1127.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**The idle policy no longer closes a session underneath its own call.** With `MCP_DEBUG_SESSION_IDLE_MS` set, a tool call counted as activity only at the moment it was dispatched, so a call that ran longer than the window — a launch that builds first, a slow launch on a busy host, an `attach_to_process` with a long `verifyTimeout` — had its session closed while it was still in flight and answered a generic launch failure. Every tool call that names a session now holds that session's tree for as long as it runs (the hold was `wait_for_stop`'s alone), `create_debug_session` with an attach target holds the session it creates while it attaches, and the idle clock starts over when the call answers, so a slow call no longer uses up the window it was made in. `list_debug_sessions` shows `idleMs: 0` for a session with a call in flight, and a call that arrives while the policy is closing a session waits for that close and answers `closedBy: "idle-policy"`. (#1127)
1 change: 1 addition & 0 deletions changelog.d/1128.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**A parent's end summary no longer says the run hit no breakpoint after a child session stopped on one.** With child sessions (Python and JavaScript), the documented flow — a breakpoint in the child's code, start the parent, wait — ended with the parent answering "The program ran to completion (exit code 0) without hitting any breakpoint. Verified but never hit: child.py:12", although a child session had stopped on that line (JavaScript reported the breakpoint as never bound as well). The summary was read off the launched process alone. A stop on any descendant is now counted for its ancestors, and a session whose tree stopped answers "Its child sessions stopped 2 times during this run." with no breakpoint listed as unhit — also when the child was closed before the parent ended. (#1128)
1 change: 1 addition & 0 deletions changelog.d/1129.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**The server's own idle-policy settings are no longer displayed as redacted secrets.** The environment sanitizer masked any variable whose name contained `session` followed by `id`, and `SESSION_IDLE` contains `SESSION_ID`: the dev proxy's `dev_server_status` showed `MCP_DEBUG_SESSION_IDLE_MS` and `MCP_DEBUG_SESSION_IDLE_SWEEP_MS` as `[REDACTED]`, so an operator could not read back which idle window was in force. The rule still masks whatever follows `session…id` and now knows one exception, the word "idle" (`SESSION_IDLE_MS`, `sessionIdleTimeout`); every other name it masked before is still masked. `session.id` and `SESSION ID`, with a separator the old pattern did not know, are now masked too. (#1129)
1 change: 1 addition & 0 deletions changelog.d/1137.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**`childrenReleased` is explained in the answer that carries it.** When a child process was released to run undebugged — child sessions turned off with `DEBUG_MCP_MAX_CHILD_SESSIONS=0`, the per-tree cap reached, or an adoption that failed — the launch and `wait_for_stop` answers carried only the number, while a pending answer went on promising a pause at a breakpoint that only the released child could reach. Every launch, restart and wait answer with a `childrenReleased` count now ends its message with one sentence saying how many children were released, that they run undebugged, why (naming the setting when it decided), and that a breakpoint only they reach will not hit. (#1137)
Loading
Loading