Repository navigation
feat: one debugger core per http process — owner-key seam, idle policy, debug sessions outlive their MCP session (#1025 step 2, PR 2/5) - #1115
Conversation
…reads the caller's (#1025) The owner-key seam of #1025 step 2. Ownership stays server-side ("the handle is the capability"): a session is created in the caller's scope — the authenticated principal when the request carried one, otherwise the shared anonymous scope — a child adopted under it inherits that scope, and list_debug_sessions, resources/list, resources/read, resources/subscribe and the tools/call dispatch answer only within the caller's scope. With no authentication in front of the server every caller is anonymous and sees everything, which is today's single-tenant behaviour; an authenticated http mode later scopes sessions per principal with no tool-schema change. The #939 foreign-session refusal goes with it: there is no "other server" with one core per process, only scopes, and a session outside the caller's scope answers exactly as an unknown id would (no existence leak). The gate still sits in front of every handler, still gates only tools whose schema takes a sessionId, and never refuses create_debug_session, which stamps the scope. Every tool call naming a session also refreshes its tree's activity clock (touchActivity), the input the idle policy of the next commit sweeps on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The lifetime rule of #1025 step 2, decided on the issue and in the session-tree design: with no protocol-level session to end a debug session, a tree whose root has seen no tool call for the idle window is closed through the normal close cascade (a launch root closes its subtree, an attach root releases its targets). Only roots have a clock; a tool call naming any session in a tree refreshes its root; an in-flight wait_for_stop holds the root; and the sweep is skipped entirely while a client is attending the process (the transport layer supplies that answer; the default says nobody is). - SessionManagerCore: touchActivity, holdIdle, sweepIdle, idleMsOf; clocks and holds are per root and leave with it. - DebugMcpServer: `debugSessionIdle: { defaultMs }` (the transport's default — 30 min for http, 0 for stdio, absent = off), MCP_DEBUG_SESSION_IDLE_MS overriding it (0 disables), MCP_DEBUG_SESSION_IDLE_SWEEP_MS (60 s) for the cadence; invalid values warn and keep the default. start() arms the unref'd sweep, stop() clears it; setAttendedSupplier for the transport layer. - wait_for_stop holds the tree for the duration of the wait and releases in finally, success or failure. The transports adopt it in the next commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ir MCP session (#1025) The ownership-and-lifetime change of #1025 step 2 on the http transport. The app builds one DebugMcpServer core after the refusal gates and starts it once; every MCP session is a legacy-era SDK Server facade over it. An MCP session is a connection: its close, reap or DELETE forgets the connection and nothing else — the debug sessions it created belong to the process and answer to the per-debug-session idle policy (30 min default on http, off on stdio). The process is "attended" while any MCP session holds an SSE stream open, and the idle sweep closes nothing then. - /health: `attended`, `listenStreams`, the MCP sessions (`sessions`, `details` without the per-client debug list) and the process's `debugSessions` with their idle time. - Reaper log lines lose the "holding debug session(s)" suffix; the reap closes the transport only. - A transport whose initialize the SDK refused is closed right after the request, so neither it nor its facade is left behind. - Shutdown: every transport, then the one core. - stdio passes the idle default of 0. Tests: the http unit suite's transport mock now completes an initialize inside handleRequest, as the SDK does; http-shared-core.test.ts replaces the #939 foreign-session suite — two clients share one list, either drives the other's session, a session survives its creator's DELETE /mcp — and MEASURES the attendance premise: a v2 SDK client in legacy mode holds its SSE stream for the life of the connection, so /health reports attended with no tool call. The stale-reap e2e now sets the idle knobs and watches the idle policy end the debug session after the MCP-session reap. Docs: README, tool-reference, system-overview, debugging-guide, docker-support, diagnostics, kubernetes (+ sidecar script and smoke workflow), jit-diagnostics, the npm README, SECURITY.md, the session-tree design (root-only clocks, attended suspension), CLAUDE.md; two changelog fragments. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… handshake threw, and state the isolation seam's limits (#1025) Review follow-ups on the shared core: - A completed wait_for_stop refreshes the tree's idle clock when it settles, so an agent that returns a few minutes after a long wait does not find its session swept. - A child adoption refreshes the root's clock (the tree is alive). - A stray sessionId on a tool whose schema takes none refreshes nothing, so a caller cannot keep a tree it may not see alive by naming it. - The idle sweep asks the attended supplier again before each close: a client arriving while an earlier close runs stops the rest. The supplier itself travels into the sweep. - A new MCP session's transport is closed in `finally` when its handshake did not complete, whether the SDK refused it or the transport threw, so no facade sink is left in the process-wide hub. - One attendance predicate feeds both the supplier and /health. - The attendance measurement holds: /health is read again 2.5 s after the first attended reading and must still show the stream open. - The unit test for a refused handshake asserts the clean path (no 500, no error log); the transport mock's close resolves like the SDK's. - Docs: no authenticated http mode ships yet, and an authenticating reverse proxy does not identify callers to the server — SECURITY.md, the tool reference and the ownership fragment say so instead of implying per-principal scoping is available today; the jit-diagnostics /health sentence and the fragment name the new shape. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…ssion says what happened, and a session outside the caller's scope does not exist (#1025) A second look at three rulings made while building step 2, on JF's review. Idle expiry is off by default on every transport. A sessionless client gives the server no sign of life between its calls, so any finite default ends some live agent's program (an agent busy elsewhere, a human away from a paused session), while a leaked session is visible and closable. The thirty-minute http default came from the old MCP-session reaper, where an open stream protected a client; a 2026 tools-only client has no stream. The policy stays as the operator's opt-in (MCP_DEBUG_SESSION_IDLE_MS), and the per-transport default plumbing goes. The start-up log says which rule the process runs under. When the policy is on the agent can see it: list_debug_sessions rows carry idleMs (and expiresInMs), the server instructions state the window, and a tool call naming a session the policy closed answers when, under which window and whether its program was ended or released (closedBy: "idle-policy") where it used to answer "Session not found", the words a mistyped id gets. The record is bounded and told only inside the scope that owned the session. The idle log line says what the close did to the program. The adapter-cap refusal, which is where forgotten sessions now surface, says how to find and close them. The owner-key gate no longer answers for a session outside the caller's scope. Its one fixed sentence differed from the real unknown-id answer of nine tools (close, start, attach, detach, hot_reload, set and remove breakpoint, get_local_variables, evaluate) and from every tool's argument-check errors, and the operator log named the foreign session; the difference told a caller the session was there, which is the leak the seam exists to prevent. The dispatch now runs the whole call with that session hidden from the store's lookups (session-scope.ts), so each tool gives its own unknown-id answer by construction. owner-scope-contract.test.ts walks every tool that takes a sessionId, with four argument shapes and two session states, over the real SessionManager and store: 62 of 216 cases failed before, none now. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…isting that scopes it; say what the owner scope does not cover (#1025) Review of the second-look pass (fresh context, range bdf402e..84d1bad). A regression of the previous commit, reproduced by the reviewer: the dispatch entered the hidden scope for any tool handed a foreign session id, including tools that take none. list_debug_sessions then read each owner key through the hidden lookup, got nothing for the hidden session, and "no key" is the anonymous scope — so an anonymous caller who passed a foreign id as a stray argument was shown that session's row. The contract test had walked only tools that take a sessionId, and only with a keyed stranger. - The scope is entered only for a tool whose schema takes a sessionId; a tool that takes none runs as it always does. The operator log still names the session as it names an unknown one. - getSessionsVisibleTo reads owner keys off the managed rows, never through a lookup by id, so a session whose key cannot be read can never pass for an anonymous one. - owner-scope-contract.test.ts now asks as another principal AND as an anonymous caller, covers the tools that take no sessionId (the listings, with and without a stray id), the unlisted alias and resources/list, and shares one foreign session per state: 458 cases in 3 s where 216 took three minutes. The idle-clock assertion compares the last-activity instant, which a moving fake clock cannot blur. Stated, not fixed: a 2026-07-28 subscriptions/listen and GET /health are not owner-scoped. The SDK's entry serves listen and publishes by URI with no hook that sees the listener, and its acknowledgement echoes the honoured filter, so dropping a foreign URI at request time would itself tell it from an unknown one. Both are written into the design doc as limits of the seam and as acceptance criteria of the authenticated-http issue. Docs: the jit-diagnostics kubectl command sets the idle and stale knobs itself (with expiry off by default the recipe had lost the cleanup it attaches to a production pod with); two sentences that still said debug sessions "live on under the idle policy" now say what ends them. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Record released session IDs during the close cascade so a launched root can end while its JavaScript children are released. Cover mixed outcomes and a child terminating while an earlier close is pending.
debugmcpdev
left a comment
There was a problem hiding this comment.
Review found that idle expiry copied a launch root's 'ended' outcome onto JavaScript children that had actually been released. Fixed in 91a7e7f: the close cascade records per-session release outcomes, and regressions cover both mixed outcomes and natural termination during an earlier child's close. The original defect and a stale-snapshot race were reproduced; five focused production-method scenarios now pass, and the fix received a second independent review. Full CI on the updated branch remains required before merge. The fix has been propagated through the descendant PRs while preserving the stack.
debugmcpdev
left a comment
There was a problem hiding this comment.
Follow-up validation with this fix propagated to the top stack head 9e948b8: all 22 idle-policy and 71 child-session tests pass, including the new mixed-outcome and termination-during-cascade regressions. typecheck:all reports zero source/test errors and the changed production file passes ESLint. No validation-only source or lockfile changes were made. Fresh current-base GitHub CI remains required before merge.
Keep setting names and fallback guidance while omitting raw environment contents. Add regressions for both settings with secret-like multiline input.
debugmcpdev
left a comment
There was a problem hiding this comment.
Follow-up security review: the current-main CodeQL scan found raw rejected environment values being copied into idle-policy warnings. Commit f133870 removes those contents while retaining the setting name and fallback value. Added regression cases for both settings using synthetic secret-like multiline input. Both regressions failed before the fix; all 25 lifecycle and idle-answer tests pass after it, with type checks and lint clean. Independently reviewed the exact two-file change. The fresh CodeQL security gate now passes with no new alerts. Remaining CI must finish before merging.
Summary
PR 2 of 5 for #1025 step 2 (plan and decisions on the issue). Stacked on #1114 (PR 1); the base retargets to
mainwhen that merges. Three commits, each green on its own:authInfo, otherwise the shared anonymous scope), a child inherits its root's scope, andlist_debug_sessions,resources/list,resources/read,resources/subscribeand the tools/call dispatch answer only within the caller's scope. An out-of-scope id answers exactly as an unknown id (no existence leak). With no authentication in front of the server every caller is anonymous and sees everything, which is today's single-tenant behaviour. The http: a debug session owned by another MCP client session answers a plain Session not found; say that sessions are per client #939 foreign-session refusal goes with it: with one core per process there is no "other server", only scopes.MCP_DEBUG_SESSION_IDLE_MS(default 30 min onhttp, 0 = off onstdio;0disables) is closed through the normal close cascade. Only roots have a clock; a tool call naming any session in a tree refreshes its root; an in-flightwait_for_stopholds the root; children never expire on a clock of their own; and the sweep (MCP_DEBUG_SESSION_IDLE_SWEEP_MS, 60 s) is skipped entirely while a client is attending the process.httpprocess. The app builds oneDebugMcpServerafter the refusal gates and starts it once; every MCP session is a legacy-era facade over it. An MCP session is a connection: its close, reap orDELETE /mcpforgets the connection and nothing else; the debug sessions it created answer to the idle policy. "Attended" = any MCP session holds an SSE stream open./healthgainsattended,listenStreamsand the process'sdebugSessions(with idle time); the reaper lines lose the "holding debug session(s)" suffix; a transport whose initialize the SDK refused is closed at once; shutdown closes every transport, then the one core.Decisions carried (JF, 2026-11-07 and 2026-11-09)
Idle policy only (no stream-lost accelerator); idle off on stdio; the #939 isolation dropped in both eras with the owner-key seam built now; expiry suspended while the process is attended; children go with their root. The session-tree design doc's ownership-and-lifetime section is amended accordingly (root-only clocks; attended suspension).
Measured
The attendance premise the "suspend while attended" rule rests on:
tests/integration/cli/http-shared-core.test.tsconnects a v2 SDK client in legacy mode, makes no tool call, and reads/health.attended === truefor the life of the connection andfalseonce it closes. Green on this box.Tests
owner-key.test.ts,session-owner-key.test.ts,tool-dispatch-owner-key.test.ts(replaces the http: a debug session owned by another MCP client session answers a plain Session not found; say that sessions are per client #939 gate suite),session-idle-policy.test.ts(11 cases: expiry, root refresh through a child, children never alone, cascade, holds, attended,0, no clock left behind), idle cases inserver-lifecycle.test.ts(timer, env knobs, supplier, a rejecting sweep), hold cases inexecution-tools.test.ts,http-shared-core.test.ts(replaceshttp-foreign-session.test.ts).http-command.test.ts: one core after the gates, started once;/healthre-pinned; close/reap leave the core running; refused-initialize close; shutdown stops the core once after the transports; the transport mock now completes an initialize insidehandleRequest, as the SDK does.mcp-server-smoke-http-stale-reap.test.tssets the idle knobs and watchesClosing idle debug sessionafterReaping stale HTTP session.typecheck:all0 (ratchet 0/0), lint, check-docs, changelog, unit 415 files / 8258 passed, integrationtests/integration/cli/+ self-debug 41/41, e2e stale-reap + HTTP smoke 3/3, conformance 7/7 (0.1.16, unchanged — the 2025-11-25 wire is untouched).Review follow-ups (fourth commit)
A fresh-context review found no critical issue and three to fix, all in: a finished
wait_for_stopnow counts as activity (the idle clock restarts when it settles, not when it was asked); the attendance measurement reads/healthagain 2.5 s after the firstattended: trueand asserts the stream is still held (openStreams: 1), so a flicker could not pass it; the docs no longer suggest a reverse proxy in front of/mcpscopes sessions per principal — the seam exists in the server, but no authenticatedhttpmode ships yet and every caller shares one scope. Also hardened on the same pass: a transport whose handshake threw is closed infinally(no orphaned facade sink); a child adoption refreshes its root's idle clock; a straysessionIdon a tool whose schema takes none refreshes nothing; the sweep asks the attended supplier again before each close; one attendance predicate feeds both the supplier and/health; the refused-initialize unit test asserts the clean path (no 500, no error log); the ownership fragment names the removeddetails[].debugSessionsfield. Deferred (noted on the issue for the authenticated-mode follow-up): a failedlistenexits withoutcore.stop()(cosmetic), and the owner-key dispatch suite drives the handler with a hand-built request context rather than a real SDKServer.Notes
stdio./mcp, the SDK'srequireBearerAuth, non-loopback binding guidance) as the path that turns the seam into real multi-tenant isolation; per-principal attendance belongs there too.serveStdio).Second look (commits
d34e5c73,46e0d205)JF asked whether the rulings I made on his behalf while building step 2 were sound. Two on this PR were not, and one decision was re-opened and changed.
Idle expiry is off by default (JF's decision, 2026-11-10). The 30-minute default came from the old MCP-session reaper (#337), where an open stream protected a client. A 2026 tools-only client has no stream, so the default would have ended the program of any agent that went quiet for half an hour — one busy elsewhere, or waiting on a human. A sessionless client gives the server no sign of life between calls, so any finite default has that false positive, and it is destructive, while a leaked session is visible and closable. Now nothing expires unless
MCP_DEBUG_SESSION_IDLE_MSis set; the per-transport default plumbing is gone and the start-up log says which rule is in force. The Kubernetes and jit-diagnostics recipes set the knob in their command lines (5 minutes), because an attach to a production pod must not rely on a default.When the policy is on, the agent can see it.
list_debug_sessionsrows carryidleMs(andexpiresInMs); the server instructions state the window; and a tool call naming a session the policy closed answers when, under which window, and whether its program was ended or released (closedBy: "idle-policy") where it used to answer "Session not found" — the words a mistyped id gets. The adapter-cap refusal, where forgotten sessions now surface, says how to find and close them.The owner-key gate leaked what it exists to hide. It answered a session outside the caller's scope with one fixed sentence, which differed from the real unknown-id answer of nine tools (
close_debug_session,start_debugging,attach_to_process,detach_from_process,hot_reload,set_breakpoint,remove_breakpoint,get_local_variables,evaluate_expression) and in the operator log: a caller could tell "exists, not mine" from "does not exist". The dispatch now runs a tool that takes asessionIdwith that session hidden from the store's lookups (src/session/session-scope.ts,AsyncLocalStorage), so each tool gives its own unknown-id answer by construction.owner-scope-contract.test.tsholds it over the real SessionManager and store: every such tool and alias, four argument shapes, two session states, asked by another principal and by an anonymous caller, plus the listings and resources — 458 cases. Dormant until an authenticated mode exists, as before.Review of this pass. A fresh-context review reproduced a regression in my first version of that fix: it entered the hidden scope for tools that take no
sessionIdtoo, andlist_debug_sessionsthen read owner keys through the hidden lookup, so an anonymous caller passing a foreign id as a stray argument was shown that session's row. Fixed in46e0d205(the scope only for tools that take the id; the listing reads owner keys off the managed rows), with the contract test widened to the cases that would have caught it.Not scoped, stated in the design doc: a 2026-07-28
subscriptions/listenandGET /health. The SDK's entry serves listen and publishes by URI with no hook that sees the listener, and its acknowledgement echoes the honoured filter, so dropping a foreign URI at request time would itself tell it from an unknown one. Both are acceptance criteria of the authenticated-httpissue.Verified:
typecheck:all, lint, check-docs, changelog; pre-push hook unit 417 files / 8737 tests, integration 33 files / 161 tests.CI on this branch head (dispatched by hand: the workflow triggers on PRs to
mainonly, so a stacked PR gets no automatic run andgh pr checksshows nothing): https://github.com/debugmcp/mcp-debugger/actions/runs/42879516734 (head46e0d205, 11 of 11 jobs green)🤖 Generated with Claude Code