diff --git a/.github/workflows/k8s-smoke.yml b/.github/workflows/k8s-smoke.yml index 4855d7ccd..ef553eefd 100644 --- a/.github/workflows/k8s-smoke.yml +++ b/.github/workflows/k8s-smoke.yml @@ -145,7 +145,7 @@ jobs: echo "CPP_POD=$POD" >> "$GITHUB_ENV" kubectl debug "$POD" --image="debugmcp/mcp-debugger:${K8S_SMOKE_VERSION}" \ --target=app --profile=general --container=debugger \ - --env=MCP_HTTP_STALE_SESSION_MS=300000 \ + --env=MCP_DEBUG_SESSION_IDLE_MS=300000 --env=MCP_HTTP_STALE_SESSION_MS=300000 \ -- /app/entry.sh http -p 3001 # kubectl debug returns before the image is pulled — wait for the # ephemeral container to actually run. diff --git a/CLAUDE.md b/CLAUDE.md index 478a6fe89..7be61135e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + - 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) - Dynamically discovers available language adapters 2. **Adapter System** (NEW) diff --git a/README.md b/README.md index 36c36a40b..ec8383741 100644 --- a/README.md +++ b/README.md @@ -224,11 +224,20 @@ and point the client at it: } ``` -Each HTTP client gets its own isolated server: debug sessions are never visible to another -client. A client that disconnects without `DELETE /mcp` keeps its debug sessions — and any -paused attach target — alive until the server reaps it: 2 minutes after its SSE stream -dropped (`MCP_HTTP_STREAM_LOST_SESSION_MS`), or 30 minutes idle if it never opened one -(`MCP_HTTP_STALE_SESSION_MS`). `GET /health` lists what each session is holding. +One `http` process is one debugger: every MCP client of it sees the same debug sessions, and a +session is addressable by whoever holds its id (the handle is the capability — run one process +per agent if agents must not see each other's work). A debug session outlives the MCP session +that created it, so a client that reconnects can list and resume or close what it left behind. +A debug session ends by its own close, by its program ending, or with the server: nothing expires +on a timer by default, so a quiet agent never loses a paused program. A server that is shared or +long-lived, or that attaches to something that must not stay paused, can turn the idle policy on: +with `MCP_DEBUG_SESSION_IDLE_MS` set, a session tree that no tool call has named for that long is +closed (a launched program is ended, an attached one released), unless a client is attending the +process — while any client holds its SSE stream open, nothing expires. `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`, +`MCP_HTTP_STALE_SESSION_MS`). `GET /health` lists the MCP sessions, the debug sessions with +their idle time, and whether anyone is attending. The port defaults to 3001, and the server listens on `127.0.0.1` only. `--bind 0.0.0.0` (or `MCP_HTTP_BIND=0.0.0.0`) listens on every interface — pair it with `--allowed-host` for the names other diff --git a/SECURITY.md b/SECURITY.md index 952e5a2b0..df167c274 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -68,7 +68,7 @@ If you want to constrain what the MCP client can reach, constrain the mcp-debugg - **Containers.** Run the published `debugmcp/mcp-debugger` image with the workspace mounted at `/workspace`. The container boundary is the filesystem boundary. - **OS permissions.** Run mcp-debugger as a user with read access only to the source you want it to debug, and no access to secrets, SSH keys, credentials, etc. The kernel enforces the limits. -- **Network exposure.** If running in http mode, treat the port as a remote shell endpoint — anyone who can reach it inherits the mcp-debugger process's privileges. Bind to loopback or put it behind authenticated network access. +- **Network exposure.** If running in http mode, treat the port as a remote shell endpoint — anyone who can reach it inherits the mcp-debugger process's privileges. Bind to loopback or put it behind authenticated network access. One http process is single-tenant: every client of it sees and can drive every debug session (a session id is the capability); run one process per agent where that matters. The server is built to scope sessions per principal once it verifies callers itself, but no authenticated http mode ships yet, and an authenticating reverse proxy in front of `/mcp` does not identify callers to the server — every caller still shares one scope. Nothing closes a debug session on a timer by default: on a shared or long-lived server set `MCP_DEBUG_SESSION_IDLE_MS`, so a client that vanishes cannot leave an attached process paused until the server exits. - **Remote debuggees.** When using `attach_to_process` to debug a process on another host, the MCP client inherits that debuggee's privileges too. Apply the same containment reasoning there. - **MCP client trust.** Do not expose mcp-debugger to an MCP client that you trust less than the OS user mcp-debugger runs under. - **DAP mirror endpoints.** `expose_session` opens a per-session DAP endpoint bound to `127.0.0.1` and gated by a random per-expose token. The mirror rejects execution control and breakpoint changes, but it forwards `evaluate` — and DAP evaluate can execute arbitrary code in the debuggee — so **treat the mirror token as a debuggee-execution capability, not a view-only credential**. Any local process that obtains the token gets that capability; the token is returned only in the `expose_session` result and is redacted from logs. "Read-only" means control is rejected, not that the debuggee is immutable. diff --git a/changelog.d/1025-idle-policy.added.md b/changelog.d/1025-idle-policy.added.md new file mode 100644 index 000000000..3cf883735 --- /dev/null +++ b/changelog.d/1025-idle-policy.added.md @@ -0,0 +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 or released (`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. diff --git a/changelog.d/1025-ownership.changed.md b/changelog.d/1025-ownership.changed.md new file mode 100644 index 000000000..38055808a --- /dev/null +++ b/changelog.d/1025-ownership.changed.md @@ -0,0 +1 @@ +**One debugger core per `http` process: every MCP client sees the same debug sessions.** A Streamable HTTP server used to build an isolated server per MCP client session, so `list_debug_sessions` listed only the caller's own sessions and another client's id was refused with "belongs to another MCP client session" (#939). The 2026-07-28 revision of the protocol has no client sessions to key that on, so the model is now the one the spec names — the handle is the capability: a debug session is addressable by whoever holds its id, `list_debug_sessions` is the whole process's list, and several agents against one `http` process see each other's sessions (one process per agent is the isolation, as `stdio` already gives). Debug sessions therefore outlive the MCP session that created them, and a client that reconnects can list and resume or close what it left behind. The seam for real isolation is in place: a session is created in its caller's scope — the authenticated principal when the request carried one, otherwise the shared anonymous scope — and a session outside the caller's scope does not exist for that caller — every tool answers it exactly as it answers an id nobody holds; no authenticated `http` mode ships yet (an authenticating reverse proxy does not identify callers to the server), so every caller is anonymous and sees everything, which is today's single-tenant behaviour. `GET /health`'s `details[]` no longer carries a per-client `debugSessions` list; the process-wide `debugSessions` array replaces it. `GET /health` now lists the process's debug sessions beside the MCP sessions, and says whether a client is attending. The ten-per-language adapter cap is per process now, as it always was on `stdio`, and its refusal says how to find and close the sessions holding the slots. Step 2 of #1025. diff --git a/docs/architecture/session-tree.md b/docs/architecture/session-tree.md index c159d6d75..f97c83d19 100644 --- a/docs/architecture/session-tree.md +++ b/docs/architecture/session-tree.md @@ -444,7 +444,7 @@ One row per measured fact; the "answer" column is what this design adds on top o | `detach_from_process` on an attach root with children | each child's `disconnect(terminateDebuggee: false)` resumes it undebugged (row 3) | each child keeps running (row 3) | children detached deepest-first (`released: true`), then the parent; the parent-side order with several children is measured by the M1 suite | | `restart_debugging` on a parent | the relaunch ends the previous run's processes (row 6) | same (row 6) | the previous run's children are closed and removed; `childrenClosed: n` | | grandchildren | reported on the child's connection, adopted there, `terminated` cascades bottom-up (row 7) | same (row 7) | `parentSessionId` = the child; the cap counts the whole tree | -| the HTTP client's MCP session ends (stale, stream lost, `DELETE /mcp`) | — | — | that client's server stops and closes all its sessions, trees included, as today | +| the HTTP client's MCP session ends (stale, stream lost, `DELETE /mcp`) | — | — | that client's SDK Server facade closes; its debug sessions, trees included, live on in the one core until they are closed, their program ends, or the server exits — or, where the operator set it, until the idle policy closes them (`MCP_DEBUG_SESSION_IDLE_MS`, off by default; #1025 step 2) | | the parent's worker dies | — | — | every session in the tree goes to `error` with the parent's diagnostics; the adapter process is reaped by the worker's exit path as today | Per-session machinery that stays per session: the `InFlightGuard` (a child never launches or @@ -464,23 +464,44 @@ story. Ownership never becomes a tool parameter. Two guardrails: -1. In the modern era (no protocol-level MCP session) `list_debug_sessions` is the whole process's - list; the session `name` is the agent's own namespace hint. Several agents against one `http` - process see each other's sessions: that is a single-tenant statement, and one server process - per agent is the isolation, as stdio and the dev proxy already give. The legacy era's per-client - server and foreign-session refusal ([#939](https://github.com/debugmcp/mcp-debugger/issues/939)) - are that era's behaviour, not a contract. -2. Lifetime without an MCP session (today the stale reaper's trigger, `MCP_HTTP_STALE_SESSION_MS`) - becomes a **per-debug-session idle policy**: `MCP_DEBUG_SESSION_IDLE_MS`, default thirty minutes, - `0` disables. A tool call naming a session refreshes that session and its ancestors, so - actively inspecting or stepping a child cannot expire its parent and cascade-close the child. - Activity does not refresh siblings or other descendants: each keeps its own clock. An in-flight - `wait_for_stop` protects the session, all descendants it observes (including ones adopted while - waiting), and its ancestors from idle expiry for the duration of the call; their idle intervals - restart when it settles, times out or is cancelled. Closing a parent that is idle under these - rules still cascades using the initiating operation's close policy above: idle expiry of an - attach root releases all of its external targets. Explicit close and debuggee exit end a - session sooner, as today. +1. `list_debug_sessions` is the whole process's list, in every era; the session `name` is the + agent's own namespace hint. Several agents against one `http` process see each other's sessions: + that is a single-tenant statement, and one server process per agent is the isolation, as stdio + and the dev proxy already give. The legacy era's per-client server and foreign-session refusal + ([#939](https://github.com/debugmcp/mcp-debugger/issues/939)) were that era's behaviour, not a + contract, and went with the one-core-per-process change (shipped 2026-11, #1025 step 2). The + seam for real isolation is in place: a session is created in its caller's scope — the + authenticated principal when the request carried one, otherwise the shared anonymous scope — + and a session outside the caller's scope does not exist for that caller: the dispatch hides it + from the call's lookups, so every tool answers it exactly as it answers an id nobody holds + (pinned tool by tool in `owner-scope-contract.test.ts`, for another principal and for an + anonymous caller); with no authentication every caller is anonymous. Two things the seam does + **not** scope yet, both acceptance criteria of the authenticated-`http` issue: a 2026-07-28 + `subscriptions/listen` (the SDK's entry serves it and publishes by URI, with no hook that sees + the listener — and its acknowledgement echoes the filter it honoured, so dropping a foreign URI + at request time would itself tell it from an unknown one), which lets a caller who already + holds a foreign session's id learn that it exists and when it prints; and `GET /health`, which + lists every debug session to whoever can reach the port. +2. Lifetime without an MCP session: a debug session ends by its own close, its program ending, or + the server exiting. A **per-debug-session idle policy** exists and is **off by default on every + transport** (decided with JF 2026-11-10, replacing the thirty-minute `http` default this section + first carried: a sessionless client gives the server no sign of life between its calls, so any + finite default ends some live agent's program, while a leaked session is visible in + `list_debug_sessions` and closable by anyone). An operator of a shared or long-lived server + turns it on with `MCP_DEBUG_SESSION_IDLE_MS`; the sweep runs every + `MCP_DEBUG_SESSION_IDLE_SWEEP_MS` (60 s). When it is on, a session's row shows `expiresInMs`, + and a later call naming a session the policy closed answers what happened, in its owner's scope. + Only roots have a clock: a tool call naming any session in a tree refreshes the tree's root, and + a child never expires on a clock of its own — the tree goes with its root (decided 2026-11-09, + amending the per-descendant clocks this section first proposed: a worker paused under a parent + the agent is driving must not be terminated by a timer). An in-flight `wait_for_stop` holds the + root for the duration of the call. The sweep is skipped entirely while a client is attending the + process — any open legacy SSE stream or modern `subscriptions/listen` stream — so a connected + client never loses a paused session to the clock; idle time still accrues from the last tool + call, so a vanished client's already-idle sessions end at the first unattended sweep. Expiry + cascades using the initiating operation's close policy above: idle expiry of an attach root + releases all of its external targets. Explicit close and debuggee exit end a session sooner, as + today. If real authentication ever arrives, the authenticated principal becomes the owner key with no schema change. @@ -554,9 +575,10 @@ Taken by the author of this document, marked so they can be overturned: - The child's connection lives in the parent's worker (the constraints above). - The per-tree cap defaults to 32 and is named `DEBUG_MCP_MAX_CHILD_SESSIONS`; `0` disables child-session adoption, excluding js-debug's mandatory first target. -- The idle policy knob is named `MCP_DEBUG_SESSION_IDLE_MS`. -- Activity on a descendant protects its ancestors from idle expiry; a tree-aware wait protects - the subtree it observes for the duration of the call. +- The idle policy knob is named `MCP_DEBUG_SESSION_IDLE_MS` (sweep: `MCP_DEBUG_SESSION_IDLE_SWEEP_MS`); it is off unless set (decided with JF, 2026-11-10). +- Only roots have an idle clock: activity on any session in a tree refreshes its root, a child never + expires alone, a tree-aware wait holds the root for the duration of the call, and the sweep is + skipped while a client attends the process (decided with JF, 2026-11-09). - The ordering rule for several settles before one wait (parent first, then adoption order). - Ended children stay listed until closed, and leave with their parent. - Closing any session closes its subtree, including when that session is itself a child. diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md index 61fda6c6b..405f69608 100644 --- a/docs/architecture/system-overview.md +++ b/docs/architecture/system-overview.md @@ -298,7 +298,7 @@ This architecture enables: - **Startup Time**: ~1-2s for session initialization - **Command Latency**: <100ms for most DAP commands - **Memory Usage**: ~50MB base + ~20MB per active session -- **Concurrent Sessions**: Limited by system resources. In Streamable HTTP mode, each MCP session gets an isolated `DebugMcpServer` instance routed by `Mcp-Session-Id`, so multiple clients run independently without serialization. STDIO mode supports a single client connection. +- **Concurrent Sessions**: Limited by system resources. One `DebugMcpServer` core per process in every mode (issue #1025): in Streamable HTTP mode each MCP session is one SDK `Server` facade over it, routed by `Mcp-Session-Id`, so several clients run concurrently against the same debug sessions — the id is the capability, one process per agent is the isolation. STDIO mode supports a single client connection. ## Error Handling Strategy diff --git a/docs/development/debugging-guide.md b/docs/development/debugging-guide.md index 1310b749e..dd1341532 100644 --- a/docs/development/debugging-guide.md +++ b/docs/development/debugging-guide.md @@ -93,14 +93,15 @@ request its tool list. The outer debugger can now inspect `req.body`, step throu handler, and resume the pending request with `continue_execution`. Keep the outer debugger's connection active while the nested server is paused. -Debug sessions on a Streamable HTTP server belong to the MCP client session that created them -(each `Mcp-Session-Id` has its own `DebugMcpServer`). Drive one debug session from one client -and keep that client's MCP session open: a one-shot driver script that opens a fresh MCP session -per run cannot reach the debug session its previous run created — it is answered -`Session belongs to another MCP client session: …` — and the idle reaper closes the -abandoned one about `MCP_HTTP_STREAM_LOST_SESSION_MS` (default 2 min) after the first run's SSE -stream dropped (an SDK client holds one; a pure-POST driver falls under -`MCP_HTTP_STALE_SESSION_MS`, default 30 min), which is what finally ends its program (issue #939). +Debug sessions on a Streamable HTTP server belong to the process, not to the MCP client session +that created them (one `DebugMcpServer` core per process, one SDK `Server` facade per MCP +session; issue #1025). A one-shot driver script that opens a fresh MCP session per run reaches +the debug session its previous run created by id, and `list_debug_sessions` lists it. Nothing +ends a debug session on a timer by default; with `MCP_DEBUG_SESSION_IDLE_MS` set, a session tree +no tool call has named for that long is closed, unless a client is attending the process — while +any client holds its SSE stream open, nothing expires. The abandoned MCP +sessions themselves are reaped separately (`MCP_HTTP_STREAM_LOST_SESSION_MS`, default 2 min after +the stream dropped; `MCP_HTTP_STALE_SESSION_MS`, default 30 min for a pure-POST client). Pausing a server's **proxy worker** (attach by PID to its `proxy-bootstrap.js` child; the inspector opens on 9229 unless `NODE_OPTIONS=--inspect-port=…` says otherwise) has one more @@ -542,8 +543,10 @@ Note: the HTTP command handler (`src/cli/http-command.ts`) exposes a `GET /healt # Query the built-in health endpoint (default port 3001) curl http://localhost:3001/health # Returns: { "status": "ok", "mode": "http", "listening": { "address", "port" }, "connections": N, +# "attended": bool, "listenStreams": N, # "sessions": […], -# "details": [{ "id", "openStreams", "streamLost", "idleMs", "debugSessions": [{ "id", "name", "language", "state" }] }] } +# "details": [{ "id", "openStreams", "streamLost", "idleMs" }], +# "debugSessions": [{ "id", "name", "language", "state", "parentSessionId"?, "idleMs"? }] } ``` ## Debugging Checklists diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 081739902..ad0c9a252 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -249,8 +249,10 @@ The runtime-affecting variables the server and its adapters read (the [developme | `DEBUG_MCP_LOG_LEVEL` | Server log level (`error`, `warn`, `info`, `debug`) | | `MCP_EXIT_ON_STDIN_CLOSE` | `1`/`true`: an `http` server exits when its stdin pipe closes — a parent-death signal for supervised backends | | `MCP_HTTP_STALE_SESSION_MS` | Idle time before an HTTP session that never opened an SSE stream is reaped (default `1800000`; `0` disables that path) | -| `MCP_HTTP_STREAM_LOST_SESSION_MS` | Idle time before an HTTP session whose SSE stream dropped and never returned is reaped — a crashed SDK client's debug sessions, and any paused attach target they hold, are released this soon (default `120000`; `0` falls back to the stale window; issue #658) | +| `MCP_HTTP_STREAM_LOST_SESSION_MS` | Idle time before an HTTP session whose SSE stream dropped and never returned is reaped (default `120000`; `0` falls back to the stale window; issue #658). Reaping an MCP session closes no debug session (see `MCP_DEBUG_SESSION_IDLE_MS`) | | `MCP_HTTP_STALE_SWEEP_INTERVAL_MS` | How often that reap sweep runs (default `60000`) | +| `MCP_DEBUG_SESSION_IDLE_MS` | Opt-in idle window, off by default on every transport (unset or `0`): a debug session then lives until it is closed, its program ends, or the server exits. When set, a debug-session tree no tool call has named for this long is closed — a launched program ended, an attached target released — unless a client is attending the process. Set it on a shared or long-lived `http` server so a crashed client's sessions, and any paused attach target they hold, are released (issue #1025) | +| `MCP_DEBUG_SESSION_IDLE_SWEEP_MS` | How often that idle sweep runs (default `60000`) | | `MCP_HTTP_ALLOWED_HOSTS` | Comma-separated `Host`/`Origin` hostnames the `http` endpoint accepts besides the loopback trio (same as repeating `--allowed-host`); an unusable entry stops startup with the entry named | | `DAP_TRACE` / `DAP_TRACE_FILE` | Enable per-session DAP frame capture / choose its file | | `DEBUG_MCP_NO_REDACT` | Disable secret redaction in captured output (diagnosis only) | diff --git a/docs/docker-support.md b/docs/docker-support.md index 3ccb5d48a..1671d2769 100644 --- a/docs/docker-support.md +++ b/docs/docker-support.md @@ -258,20 +258,26 @@ may reach it. All are passed through with `-e`: (or omit the variable) for detached network-mode containers. It is unrelated to the `stdio`-mode exit policy described under [Troubleshooting](#common-docker-issues), which is always on and needs no variable. +- **`MCP_DEBUG_SESSION_IDLE_MS`** (`src/session/idle-policy.ts`) -- opt-in, off by default on + every transport (unset or `0`): a debug session then lives until it is closed, its program + ends, or the container exits. When set, a debug-session tree that no tool call has named for + that long is closed, releasing any ptrace claim on the target, unless a client is attending + the process (an open SSE stream). Invalid values log a warning and leave it off. + `MCP_DEBUG_SESSION_IDLE_SWEEP_MS` (default 60000) is the sweep cadence. Set it on a + long-lived or shared container; a short-lived diagnostic one wants a tight window -- see + the [Kubernetes debugging recipe](kubernetes.md), which sets it to 5 minutes (issue #1025). - **`MCP_HTTP_STALE_SESSION_MS`** (`src/cli/http-command.ts`) -- idle window before an HTTP - MCP session with no open SSE stream is reaped, closing its debug sessions and releasing - any ptrace claim on the target. Defaults to 30 minutes (1800000); `0` disables that path; - invalid values log a warning and fall back to the default. Read only by `http` mode. - A short-lived diagnostic container wants a - much tighter window -- see the - [Kubernetes debugging recipe](kubernetes.md), which sets it to 5 minutes. -- **`MCP_HTTP_STREAM_LOST_SESSION_MS`** -- the short window for a session whose SSE stream + MCP session with no open SSE stream is reaped. Defaults to 30 minutes (1800000); `0` disables + that path; invalid values log a warning and fall back to the default. Read only by `http` + mode. Reaping an MCP session closes no debug session: those belong to the process, and only + `MCP_DEBUG_SESSION_IDLE_MS`, when set, ends one on a timer. +- **`MCP_HTTP_STREAM_LOST_SESSION_MS`** -- the short window for an MCP session whose SSE stream dropped and never came back (default 2 minutes; `0` falls back to the stale window). An MCP SDK client keeps a GET stream open, so when it crashes or exits without `DELETE /mcp` - the server sees the socket close and releases its debug sessions -- including a paused - attach target -- within this window plus one sweep, instead of holding them for the full - stale window (issue #658). Only a client that never opened a stream waits out - `MCP_HTTP_STALE_SESSION_MS`. `GET /health` lists what each HTTP session is holding. + the server sees the socket close and forgets the connection within this window plus one + sweep (issue #658); the moment that stream closes the process is also no longer attended, so + the idle policy, when it is set, can end the debug sessions it left behind. `GET /health` lists the MCP + sessions, the debug sessions and whether anyone is attending. - **`MCP_HTTP_ALLOWED_HOSTS`** (`src/cli/host-allowlist.ts`) -- comma-separated `Host` header values to accept in addition to the loopback trio (`localhost`, `127.0.0.1`, `[::1]`); the same list as repeating `--allowed-host`. Port-agnostic and case-insensitive; IPv6 literals in diff --git a/docs/jit-diagnostics/README.md b/docs/jit-diagnostics/README.md index 39ed8680b..732115fb0 100644 --- a/docs/jit-diagnostics/README.md +++ b/docs/jit-diagnostics/README.md @@ -122,13 +122,14 @@ The mcp-debugger image must be reachable by the cluster (`kind load docker-image POD=$(kubectl get pods -l app=pricer -o jsonpath='{.items[0].metadata.name}') kubectl debug "$POD" --image=debugmcp/mcp-debugger:latest \ --target=app --profile=general --container=debugger \ + --env=MCP_DEBUG_SESSION_IDLE_MS=300000 --env=MCP_HTTP_STALE_SESSION_MS=300000 \ -- /app/entry.sh http -p 3001 kubectl port-forward "pod/$POD" 3001:3001 & ``` Register the endpoint with your agent (e.g. `claude mcp add-json sick-pod '{"type":"http","url":"http://127.0.0.1:3001/mcp"}'`). -The HTTP server reaps MCP sessions abandoned by a crashed client, closing their debug sessions and releasing any ptrace claim on the target: a client whose SSE stream dropped and never returned (any MCP SDK client that crashed or exited without `DELETE`) is reaped after 2 minutes idle (`MCP_HTTP_STREAM_LOST_SESSION_MS`), one that never opened a stream after 30 (`MCP_HTTP_STALE_SESSION_MS`). For a short-lived diagnostic sidecar a tighter stale window is safer — add `--env=MCP_HTTP_STALE_SESSION_MS=300000` (5 minutes; `0` disables) to the `kubectl debug` command. `GET /health` on the sidecar lists what each MCP session is holding. +The HTTP server closes nothing on a timer by default, so a debug session abandoned by a crashed client would keep its ptrace claim on the target until the sidecar exits. The command above therefore turns the per-debug-session idle policy on (`--env=MCP_DEBUG_SESSION_IDLE_MS=300000`, 5 minutes): a session tree no tool call has named for that long is closed, releasing the claim, unless a client is attending the process. The abandoned MCP sessions themselves are reaped separately: a client whose SSE stream dropped and never returned (any MCP SDK client that crashed or exited without `DELETE`) is reaped after 2 minutes idle (`MCP_HTTP_STREAM_LOST_SESSION_MS`), one that never opened a stream after 30 (`MCP_HTTP_STALE_SESSION_MS`). For a short-lived diagnostic sidecar a tighter stale window is safer, so the command above sets `--env=MCP_HTTP_STALE_SESSION_MS=300000` too (5 minutes; `0` disables). `GET /health` on the sidecar lists the process's debug sessions with their idle time, and whether a client is attending. Facts that will save you an afternoon (all observed, not assumed): diff --git a/docs/kubernetes.md b/docs/kubernetes.md index ac033a5bb..bb0b6c395 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -122,7 +122,7 @@ binary. Send the debugger to it: POD=$(kubectl get pods -l app=cpp-demo -o jsonpath='{.items[0].metadata.name}') kubectl debug "$POD" --image=debugmcp/mcp-debugger:latest \ --target=app --profile=general --container=debugger \ - --env=MCP_HTTP_STALE_SESSION_MS=300000 \ + --env=MCP_DEBUG_SESSION_IDLE_MS=300000 --env=MCP_HTTP_STALE_SESSION_MS=300000 \ -- /app/entry.sh http -p 3001 kubectl port-forward "pod/$POD" 3001:3001 & ``` @@ -144,11 +144,15 @@ The flags that matter — each one is load-bearing: pass `-- /app/entry.sh http -p 3001` — bare `-- http -p 3001` fails with `exec: "http": executable file not found`. (Git Bash on Windows mangles `/app/...` — prefix the command with `MSYS_NO_PATHCONV=1`.) -- **`MCP_HTTP_STALE_SESSION_MS=300000`** tightens the reaper that cleans up MCP - sessions abandoned by a client that never opened an SSE stream from 30 min - to 5 — releasing the ptrace claim on your pod sooner. (A crashed SDK client, - whose stream the server saw close, is reaped after 2 min regardless — - `MCP_HTTP_STREAM_LOST_SESSION_MS`.) +- **`MCP_DEBUG_SESSION_IDLE_MS=300000`** turns the idle policy on at 5 minutes: a + debug session nobody has named for that long is closed — releasing the ptrace + claim on your pod once the client is gone (while a client holds its SSE stream + open, nothing expires). Leave it out and nothing expires: a crashed client's + attach keeps its claim until the sidecar exits. **`MCP_HTTP_STALE_SESSION_MS=300000`** tightens + the separate reaper for abandoned MCP sessions the same way. (A crashed SDK + client's MCP session, whose stream the server saw close, is reaped after + 2 min regardless — `MCP_HTTP_STREAM_LOST_SESSION_MS`; its debug sessions then + answer to the idle policy.) - Ephemeral containers **cannot be removed or restarted** — each retry needs a fresh `--container` name; only a pod restart clears them. @@ -188,7 +192,8 @@ though `--profile=general` avoids a deprecation warning): ```bash kubectl debug "$POD" --image=debugmcp/mcp-debugger:latest --profile=general \ - --container=debugger --env=MCP_HTTP_STALE_SESSION_MS=300000 -- /app/entry.sh http -p 3001 + --container=debugger --env=MCP_DEBUG_SESSION_IDLE_MS=300000 --env=MCP_HTTP_STALE_SESSION_MS=300000 \ + -- /app/entry.sh http -p 3001 ``` Then `attach_to_process {host: "127.0.0.1", port: 5678, ...}` from a session on @@ -220,8 +225,9 @@ the MCP endpoint, and the containerized server attaches to python, ruby logpoints (`set_breakpoint` with `logMessage`): the pod keeps running at full speed while values stream to `get_output`. - An abandoned attach no longer leaves an orphaned tracer (#337): teardown - kills the adapter's process group, and the sidecar's HTTP reaper cleans up - crashed-client sessions after the stale window. Suspect a leftover anyway? + kills the adapter's process group, and the sidecar's idle policy — on because + the recipe sets `MCP_DEBUG_SESSION_IDLE_MS` — closes a crashed client's debug + sessions once nobody attends the process. Suspect a leftover anyway? `grep TracerPid /proc/1/status` from the sidecar. ## Distroless and minimal images diff --git a/docs/tool-reference.md b/docs/tool-reference.md index daa4ead09..5f4ebc2f5 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -87,14 +87,14 @@ Creates a new debugging session. **Notes:** - Session IDs are UUIDs in the format `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` - Sessions start in `"created"` state -- **Streamable HTTP: debug sessions are per MCP client session.** Each HTTP client (each `Mcp-Session-Id`) gets its own server, so a debug session is visible only to the client that created it; `list_debug_sessions` lists that client's sessions alone. A tool call from another client naming the id answers `success: false` with `error: "Session belongs to another MCP client session: …"` and says how it ends: only its client can close it or end its MCP session with an explicit `DELETE /mcp` (an SDK client's `close()` is not a DELETE — a plain disconnect leaves it to the idle reaper: `MCP_HTTP_STREAM_LOST_SESSION_MS` for a client that held an SSE stream, `MCP_HTTP_STALE_SESSION_MS` for one that never did; both `0` means nothing else ends it). Drive one debug session from one client and keep that client's MCP session open; a one-shot script that opens a fresh MCP session per call cannot reach the sessions its previous run created (issue #939). stdio has one client per process +- **One debugger core per process; the id is the capability (issue #1025).** On Streamable HTTP every MCP client of a process sees the same debug sessions: `list_debug_sessions` is the whole process's list, and any client holding an id can drive or close that session — run one `http` process per agent where agents must not see each other's work. A debug session outlives the MCP session that created it, so a one-shot script that opens a fresh MCP session per call reaches the sessions its previous run created, and a client that reconnects can resume or close what it left behind. A session ends by `close_debug_session`, by its program ending, or with the server: nothing expires on a timer by default. An operator can turn on the **idle policy** (`MCP_DEBUG_SESSION_IDLE_MS`, off by default on every transport): a session tree that no tool call has named for that long is then closed through the normal close cascade (a launched program is ended, an attached one released), unless a client is attending the process — while any client holds its SSE stream open, nothing expires; an in-flight `wait_for_stop` holds its tree; children never expire on a clock of their own. `list_debug_sessions` shows each session's `idleMs` and, with a policy on, `expiresInMs`; a later call naming a session the policy closed answers what happened, with `closedBy: "idle-policy"`. The server is built to scope sessions per principal once it verifies callers itself (no authenticated `http` mode ships yet; an authenticating reverse proxy does not identify callers to the server), so today every caller is in the one shared scope - When `port`, `processId` or an `adapterConfig` URI/info-file key is provided in `create_debug_session`, the server performs an inline attach (creating the session and immediately attaching). The session exists before the addressing gate can refuse the form (a `port` on C/C++, say), so such a refusal answers `success: false` with a usable `sessionId`. The response then mirrors `attach_to_process`: alongside `sessionId` it carries `state`, the attach `data` payload, an optional `warning`, and — when a requested post-attach pause has not landed yet — `pending: true`, with `message` saying so (`…; post-attach pause pending — the target stops when it next executes code; call wait_for_stop to block until it does (pass stopOnEntry: false to attach without pausing)`) --- ### list_debug_sessions -Lists all active debugging sessions — in Streamable HTTP mode, the ones this MCP client session created (see the note under `create_debug_session`). +Lists all active debugging sessions — the whole process's list, whichever MCP client created them (see the note under `create_debug_session`). **Parameters:** None (empty object `{}`) @@ -109,7 +109,8 @@ Lists all active debugging sessions — in Streamable HTTP mode, the ones this M "language": "python", "state": "created", "createdAt": "2025-06-11T04:53:14.762Z", - "updatedAt": "2025-06-11T04:53:14.762Z" + "updatedAt": "2025-06-11T04:53:14.762Z", + "idleMs": 4210 } ], "count": 1 @@ -131,6 +132,8 @@ Lists all active debugging sessions — in Streamable HTTP mode, the ones this M Errored sessions include optional `diagnostics` with the current launch attempt's server-host `proxyLogPath` and remote-safe `proxyLogResource`. The record is retained for proxy initialization failures and for proxy/adapter deaths after initialization, and is cleared when a new launch or attach attempt begins. +**`idleMs`:** milliseconds since a tool call last named this session or any session in its tree (a child reports its root's) — what tells a forgotten session from a live one. **`expiresInMs`:** present only when the operator turned the idle policy on (`MCP_DEBUG_SESSION_IDLE_MS`): how long the tree has before the policy closes it; any tool call naming the session resets it, a `wait_for_stop` in flight holds it, and nothing expires while a client is attending the process. + **`parentSessionId`:** present on child rows only. A child session is created by the server, never by `create_debug_session`, when the adapter reports a process the debuggee started: a Python launch or attach — debugpy's sub-process debugging is on by default; `dapLaunchArgs: { subProcess: false }` opts a launch out and an attach target decides for itself with `--configure-subProcess` — where debugpy reports each child, or a JavaScript launch or verified-local attach — js-debug's `autoAttachChildProcesses` is on by default for launch or an inspector target that can read both a temporary server-side probe file and the installed js-debug bootloader files; other attaches default it off with a warning; `dapLaunchArgs: { autoAttachChildProcesses: false }` opts a launch out, `adapterConfig: { autoAttachChildProcesses: false }` an attach — where js-debug reports every Node process the program forks or spawns, `cluster` workers and grandchildren included (see [the session tree](./architecture/session-tree.md)). The child inherits the parent's `language`, its `name` is the adapter's own label for the process (debugpy: `Subprocess `; js-debug: `