fix(server): discard a session whose establishing request was refused - #3229
Draft
sainikhiljuluri wants to merge 1 commit into
Conversation
A stateful streamable-HTTP session is minted, registered in _server_instances and given a running task BEFORE the request is validated: Host/DNS-rebinding, Accept, Content-Type, JSON parse, JSON-RPC shape and the "Missing session ID" check all live downstream in the transport. So every request the server itself refuses left a live, non-terminated session behind, and nothing reclaimed it -- the idle reaper is off by default and unreachable from streamable_http_app() (modelcontextprotocol#2455). A refused 406 also handed back a usable Mcp-Session-Id, and a follow-up request on that never-initialized id was served 200. Track the establishing response status and, if it is >= 400, drop the session and terminate the transport. All six session-less vectors now leak nothing, while a legitimate initialize still establishes a session that keeps serving. This is a correctness fix, not a DoS fix: 200 valid initialize requests create 200 sessions on the same server, so unbounded growth is already reachable with legitimate traffic. That gap is modelcontextprotocol#2455. The suite previously obtained sessions *via requests the transport rejects* -- _open_session POSTed an empty body answered 400/406 and used the session id it still returned. That helper now performs a real initialize, which is plausibly why this went unnoticed. Removes a stale "pragma: no cover" on the DELETE header-validation path and four "pragma: no branch" markers that the rewritten helper made unnecessary.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3228
Problem
A stateful streamable-HTTP session is minted, registered in
_server_instancesand given arunning task before the request is validated. Every check — Host / DNS-rebinding,
Accept,Content-Type, JSON parse, JSON-RPC shape, and the "Missing session ID" check for non-initializePOSTs — lives downstream in the transport, so it runs only after the session already exists.
Every request the server itself refuses therefore left a live, non-terminated session behind, and
nothing reclaimed it:
session_idle_timeoutdefaults toNoneand is not reachable fromstreamable_http_app()(#2455). A refused406also handed back a usableMcp-Session-Id, and afollow-up request on that never-initialized id was served
200.Solution
Track the status of the response to the establishing request; if it is
>= 400, drop the sessionand terminate the transport.
Chosen over the alternative — "only a POST carrying
initializemay establish a session" — becausethat requires the manager to peek the body, which currently lives in the transport
(
is_initialization_request). This shape needs no body parsing and closes the same vectors. If youwould rather have the stricter shape, say so and I will rewrite it; it would also subsume #3129.
Scope: correctness, not a DoS fix
Worth stating plainly, because an earlier draft of my own report got this wrong. This does not
close an availability hole: on the same no-auth server, 200 valid
initializerequests create 200sessions, so unbounded session growth is already reachable with entirely legitimate traffic. The
real gap there is the absence of a session cap plus an unreachable idle reaper, which is #2455 and
is untouched here.
What this fixes is that the server contradicts itself — requests it refuses leave state behind, a
421from the default-on DNS-rebinding protection creates a session anyway, and a refusal returns aworking session handle.
How to test
uv sync --frozen --all-extras --dev uv run --frozen pytest tests/server/test_streamable_http_manager.py tests/server/test_streamable_http_router.py -q uv run --frozen --python 3.10 --all-extras --dev pytest -q -n auto ./scripts/test # coverage + strict-no-coverVerified on this branch:
./scripts/test→ 100.00%,TOTAL 49628 Stmts 0 Miss 3920 Branch 0 BrPart,strict-no-covercleanruff check/ruff format --check→ cleanpyright --pythonversion 3.10→ the only 2 errors are pre-existing and unrelated(
tests/transports/stdio/test_lifecycle.pyusesos.waitid, absent on macOS)Against a default-configured server, every session-less vector before and after:
GETno session idDELETEno session idPOSTnon-initializePOSTmalformed bodyPOSTbadAcceptGETbadHost(421)A legitimate
initializestill returns200with a session header, and a follow-uptools/listonthat session is still served
200.Tests
test_refused_request_leaves_no_session_behind— five parametrized vectors, each asserting thestatus and
manager._server_instances == {}. Red before this change (all five fail), green after.It pins the same property the suite already asserts by name for the
413path intest_oversized_content_length_is_rejected_before_body_read_or_session_creation.test_router_reports_a_stream_closure_it_did_not_cause— covers the router's "unexpected closure"branch. Worth explaining: that branch was previously reached only incidentally, by leaked sessions
whose streams closed at teardown without ever being terminated. Now that refused sessions are
terminated properly, nothing reaches it by accident, so it gets a deliberate test instead of losing
coverage.
A note on the existing test helper
_open_sessionpreviously obtained a session via a request the transport rejects — it POSTed anempty body, was answered
400/406, and used the session id that came back anyway. Eight testsdepended on that, so they fail against any correct fix.
It now performs a real
initialize. The reply is an SSE stream, so the body is followed by anhttp.disconnect, which ends the stream and letshandle_requestreturn while the session lives onin the manager's task group.
test_idle_session_is_reapedwas building its session the same way andis switched over too.
That the suite encoded the buggy behaviour as the way to obtain a session is plausibly why this
went unnoticed.
Incidental
# pragma: no coveron the DELETE header-validation path, now covered by the newparametrized test.
# pragma: no branchmarkers made unnecessary by the rewritten helper.# pragma,# type: ignore, or# noqaanywhere in the diff.Disclosure
Written with AI assistance (Claude Code). Filed by me as the human contributor — I own this change
and will answer any questions on the implementation or the trade-offs above.