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 .github/workflows/k8s-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
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
- 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)
Expand Down
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions changelog.d/1025-idle-policy.added.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changelog.d/1025-ownership.changed.md
Original file line number Diff line number Diff line change
@@ -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.
64 changes: 43 additions & 21 deletions docs/architecture/session-tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/system-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading