Skip to content

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

Merged
debugmcpdev merged 9 commits into
mainfrom
feat/1025-shared-core
Nov 11, 2026
Merged

debugmcpdev merged 9 commits into
mainfrom
feat/1025-shared-core

Conversation

@debugmcpdev

@debugmcpdev debugmcpdev commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Updated 2026-11-10 (second look: commits d34e5c73, 46e0d205). Two things said below no longer hold: the idle policy is off by default on every transport (not "30 min on http"), and a session outside the caller's scope is no longer answered by the dispatch with one fixed sentence — it is hidden from the call and each tool answers as it answers an unknown id. See "Second look" at the end.

Summary

PR 2 of 5 for #1025 step 2 (plan and decisions on the issue). Stacked on #1114 (PR 1); the base retargets to main when that merges. Three commits, each green on its own:

  1. The owner-key seam. Ownership stays server-side — the handle is the capability — but the seam for real isolation exists now: a debug session is created in its caller's scope (the authenticated principal when the request carried authInfo, otherwise the shared anonymous scope), a child inherits its root's scope, and list_debug_sessions, resources/list, resources/read, resources/subscribe and 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.
  2. The per-debug-session idle policy. A tree whose root has seen no tool call for MCP_DEBUG_SESSION_IDLE_MS (default 30 min on http, 0 = off on stdio; 0 disables) 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-flight wait_for_stop holds 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.
  3. One core per http process. The app builds one DebugMcpServer after 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 or DELETE /mcp forgets the connection and nothing else; the debug sessions it created answer to the idle policy. "Attended" = any MCP session holds an SSE stream open. /health gains attended, listenStreams and the process's debugSessions (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.ts connects a v2 SDK client in legacy mode, makes no tool call, and reads /health.attended === true for the life of the connection and false once it closes. Green on this box.

Tests

  • New: 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 in server-lifecycle.test.ts (timer, env knobs, supplier, a rejecting sweep), hold cases in execution-tools.test.ts, http-shared-core.test.ts (replaces http-foreign-session.test.ts).
  • http-command.test.ts: one core after the gates, started once; /health re-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 inside handleRequest, as the SDK does.
  • mcp-server-smoke-http-stale-reap.test.ts sets the idle knobs and watches Closing idle debug session after Reaping stale HTTP session.
  • Gates locally: typecheck:all 0 (ratchet 0/0), lint, check-docs, changelog, unit 415 files / 8258 passed, integration tests/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_stop now counts as activity (the idle clock restarts when it settles, not when it was asked); the attendance measurement reads /health again 2.5 s after the first attended: true and 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 /mcp scopes sessions per principal — the seam exists in the server, but no authenticated http mode ships yet and every caller shares one scope. Also hardened on the same pass: a transport whose handshake threw is closed in finally (no orphaned facade sink); a child adoption refreshes its root's idle clock; a stray sessionId on 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 removed details[].debugSessions field. Deferred (noted on the issue for the authenticated-mode follow-up): a failed listen exits without core.stop() (cosmetic), and the owner-key dispatch suite drives the handler with a hand-built request context rather than a real SDK Server.

Notes

  • The ten-per-language adapter cap is per process now, as it always was on stdio.
  • After merge: file "authenticated http mode" (bearer verification in front of /mcp, the SDK's requireBearerAuth, non-loopback binding guidance) as the path that turns the seam into real multi-tenant isolation; per-principal attendance belongs there too.
  • Next: PR 3 (the modern era on stdio through 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_MS is 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_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 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 a sessionId with 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.ts holds 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 sessionId too, and list_debug_sessions then 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 in 46e0d205 (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/listen and GET /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-http issue.

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 main only, so a stacked PR gets no automatic run and gh pr checks shows nothing): https://github.com/debugmcp/mcp-debugger/actions/runs/42879516734 (head 46e0d205, 11 of 11 jobs green)

🤖 Generated with Claude Code

cynarlab and others added 4 commits November 9, 2026 19:59
…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

codecov Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.91120% with 8 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/cli/http-command.ts 93.10% 2 Missing ⚠️
src/session/idle-policy.ts 92.30% 2 Missing ⚠️
src/session/session-manager-core.ts 97.95% 2 Missing ⚠️
src/server.ts 96.15% 1 Missing ⚠️
src/server/tool-dispatch.ts 97.43% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

cynarlab and others added 2 commits November 10, 2026 10:55
…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 debugmcpdev left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 debugmcpdev left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Base automatically changed from refactor/1025-protocol-facade to main November 11, 2026 04:42
Comment thread src/server.ts Fixed
Keep setting names and fallback guidance while omitting raw environment contents. Add regressions for both settings with secret-like multiline input.

@debugmcpdev debugmcpdev left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@debugmcpdev
debugmcpdev merged commit ac504b1 into main Nov 11, 2026
15 checks passed
@debugmcpdev
debugmcpdev deleted the feat/1025-shared-core branch November 11, 2026 10:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants