Skip to content

Rejected streamable-HTTP requests leave live sessions behind: the session is registered before the request is validated #3228

Description

@sainikhiljuluri

Release line

2.x (main), reproduced at a4f4ccd0.

Bug description

In the default stateful streamable-HTTP configuration, StreamableHTTPSessionManager mints a
session id, registers a transport in _server_instances, and starts a long-lived session task
before the request has been validated in any way. Every actual check lives downstream in the
transport, so a request the server itself refuses — 400, 406, or 421 — still leaves a fully
registered, non-terminated session behind.

Nothing reclaims it. session_idle_timeout defaults to None and is not reachable from
streamable_http_app() (that half is #2455), so the only reaper cannot be switched on by an
operator using the documented API.

Not filed as a security issue, and why

I originally wrote this up as a vulnerability report and then talked myself out of it, so it seems
worth stating plainly rather than leaving you to work it out.

It is unauthenticated, remotely triggerable, present on the documented default configuration, and
retains roughly 22.6 KiB per rejected request (measured two independent ways, ~2,240 req/s from a
single client). But it grants an attacker nothing they did not already have: on the same no-auth
server, 200 valid initialize requests create 200 sessions — verified — so unbounded session
growth is already reachable with entirely legitimate traffic. Closing the rejected-request path does
not change that, and I am not claiming otherwise. The real availability gap is the absence of a
session cap plus an idle reaper that is off by default and unreachable, which is #2455.

So this is a correctness / defense-in-depth defect, and a public issue is the honest channel for it.

What is worth fixing regardless of any attacker

  1. Requests the server itself refuses leave live state behind — which contradicts a property the
    test suite already asserts by name for the 413 path (see below).
  2. Requests blocked by the default-on DNS-rebinding protection (421) create a session anyway, so a
    security control fires and state is created regardless.
  3. Ordinary incidental traffic — scanners, health checks, a stray probe at /mcp — accumulates
    sessions forever on a default server.
  4. A refused 406 still returns a usable Mcp-Session-Id, and a follow-up request on that
    never-initialized id is served 200 by the leaked session's running server loop. An unauthenticated
    caller obtains a working session handle from a request the server rejected.

Affected

Any server built the documented way — MCPServer(...).streamable_http_app(),
Server(...).streamable_http_app(), or mcp.run("streamable-http") — i.e. stateless_http=False
(default), session_idle_timeout=None (default).

Not affected: stateless_http=True (transport terminated per request); servers behind
RequireAuthMiddleware, where 401 is returned before the manager runs; requests carrying
MCP-Protocol-Version: 2026-07-28, which route to the sessionless modern handler. Note that header
is client-controlled — omitting it selects the sessionful path — so it is not something an operator
can rely on.

Root cause

src/mcp/server/streamable_http_manager.py, _handle_stateful_request: the branch is taken purely
on request_mcp_session_id is None. Inside it the code mints uuid4().hex, constructs a
StreamableHTTPServerTransport, inserts it into self._server_instances, and
await self._task_group.start(run_server) — and only then calls http_transport.handle_request(...),
which is where every check lives (Host / DNS-rebinding, Accept, Content-Type, JSON parse,
JSON-RPC validation, and the "Missing session ID" check for non-initialize POSTs).

No error path calls terminate() or pops the dict, and run_server's finally only runs when the
session task exits — which it never does, because serve_loop blocks on its read stream and the idle
scope has no deadline when session_idle_timeout is None.

Steps to reproduce

Default configuration, zero arguments. _server_instances is only read, to observe the effect.

import anyio, httpx2 as httpx
from mcp.server.lowlevel import Server

server = Server("leak-probe")
app = server.streamable_http_app()          # no arguments: the documented default
mgr = server.session_manager

HDRS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}

async def main():
    async with anyio.create_task_group() as tg:
        await tg.start(serve)
        async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app),
                                     base_url="http://localhost:8000") as c:
            before = len(mgr._server_instances)
            for _ in range(300):
                await c.post("/mcp", json={"jsonrpc": "2.0", "id": 1,
                                           "method": "tools/list", "params": {}}, headers=HDRS)
            after = len(mgr._server_instances)
            live = sum(1 for t in mgr._server_instances.values() if not t.is_terminated)
            print(f"sessions {before} -> {after}; non-terminated: {live}")
        tg.cancel_scope.cancel()

async def serve(*, task_status):
    async with mgr.run():
        task_status.started()
        await anyio.sleep_forever()

anyio.run(main)

Actual behaviour

Every session-less vector leaks exactly one session, each rejected with a different status:

vector status session leaked
GET no session id 400 yes
DELETE no session id 400 yes
POST non-initialize 400 yes
POST malformed body 400 yes
POST bad Accept 406 yes
GET bad Host (DNS-rebinding protection) 421 yes

300 rejected POSTs → sessions 2 -> 302, exactly one per request, 302 non-terminated transports
still registered.

The one pre-session guard that does exist works correctly: an oversized declared body is rejected
413 by RequestBodyLimitMiddleware with no session created.

Expected behaviour

A request the server refuses does not leave a registered, non-terminated session behind, and a
refused response does not hand back a usable Mcp-Session-Id.

Why this is not already covered

Existing evidence this is unintended

tests/server/test_streamable_http_manager.py contains
test_oversized_content_length_is_rejected_before_body_read_or_session_creation, asserting
manager._server_instances == {}. "Rejected without creating a session" is already treated as the
desired property — it was simply only implemented for the 413 path.

docs/run/legacy-clients.md likewise describes session creation as a consequence of initialize:
"The moment a pre-2026 client sends initialize, the SDK mints an Mcp-Session-Id … and keeps a
live record behind it."

Suggested fix

Two shapes; you are better placed to choose.

A. Only initialize may establish a session. When there is no session id, reject anything that is
not a POST carrying an initialize request before creating a transport. Most faithful to the
documented model, and subsumes #3129. Cost: the manager must peek the body, which currently lives in
the transport (is_initialization_request).

B. Discard a provisional session whose establishing request was refused. Wrap send to observe
the response status; if >= 400, pop the session and terminate() the transport. Smaller, needs no
body parsing. I have a working patch that closes all vectors in the table (0 leaked across 300
requests) while a legitimate initialize still establishes a session and follow-up requests still
return 200.

One thing to know before fixing, whichever shape you pick

tests/server/test_streamable_http_manager.py currently establishes sessions via requests the
transport rejects
: _open_session POSTs an empty body, which is answered 400/406 and yet returns
a session id. Eight tests depend on it, so any correct fix fails them until that helper uses a real
initialize. The suite encodes the buggy behaviour as the way to obtain a session, which is plausibly
why this went unnoticed.

With patch B applied and no test changes the suite is 5572 passed / 8 failed — all 8 in that one file,
all from that helper. Rewriting it is not a one-liner: a successful initialize in SSE mode keeps
handle_request open, so the helper must read the response headers without waiting for the stream to
finish.

Disclosure

Investigated with AI assistance (Claude Code). Filed by me as the human contributor. Happy to open a
PR once you have indicated which fix shape you prefer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions