The Session That Never Died
The client disconnected.
The request was finished. The HTTP connection was gone. From the client’s perspective, the interaction was over.
The server disagreed.
Inside the Python MCP SDK, a session remained registered. The client might never return, but the server had no default mechanism to reclaim its state. There was no automatic expiry and no session limit. In one particularly unfortunate case, explicitly deleting the session prevented the existing cleanup logic from removing its registry entry.
On September 30, 2026, maintainers published CVE-2026-59951, affecting stateful Streamable HTTP servers using SDK versions 1.8.0 through 1.29.1 and 2.0.0 through 2.1.1. The advisory assigned a CVSS score of 7.5: a client able to reach the endpoint could grow retained session state without bound and potentially exhaust memory.
The interesting failure required nothing exotic.
The system knew how to create a resource. It had not reliably defined who was responsible for ending its lifetime.
A successful request is not a completed lifecycle
HTTP encourages a convenient mental model: a request arrives, the server processes it, a response leaves, and the associated resources are released.
Stateful protocols complicate that model. A request can establish a session holding initialization parameters, streams, transport state and a background task. Subsequent requests find those resources using a session identifier.
Surviving the request is expected. Surviving indefinitely after the client disappears is the problem.
The affected StreamableHTTPSessionManager registered state on the handshake-based Streamable HTTP path and started an associated server task:
POST initialize
|
v
Create session + register ID + start task
|
v
Return response and session ID
|
v
Client disappears
|
v
Session remains: no default expiry, no population cap
A service expected to run indefinitely needs bounded resource quantities, bounded lifetimes, or both. Default session management provided neither.
The public advisory and issue reproductions establish the failures described here. This article does not claim an independent reproduction or a comparative memory benchmark.
Three ways to leave something behind
Sessions did not expire by default
An idle-timeout mechanism existed, but its default was None. Some higher-level server APIs did not expose it, so developers could inherit unlimited retention through the ordinary documented construction path without deliberately choosing it.
Issue #2455 also described a second concern: expiry based on request arrival could interrupt work that was still running. A timeout is only useful if the component understands what counts as idle.
DELETE did not remove the registration
A client could explicitly end a session with HTTP DELETE. The transport terminated, but its registry entry remained.
The termination logic set an internal terminated flag before the session task finished unwinding. The manager’s cleanup path then checked that flag before removing the entry. For a successfully terminated transport, the condition skipped removal.
Cleanup avoided exactly the sessions that had shut down correctly.
Issue #3300 documented a controlled reproduction with a real server and SDK client, including a configured idle timeout that did not reclaim these terminated entries.
There is an important distinction between those entries and abandoned live sessions. The report describes the terminated transport as an inert remnant after its streams were closed, rather than a fully active session retaining all its working resources.
With an idle timeout configured, politely terminating a session could leave a more persistent registry entry than abandoning it. That does not mean DELETE consumed more memory. Without a timeout, abandonment could retain more state as well as lasting indefinitely.
The lifecycle contract was broken in both cases, with different consequences.
Rejected opening requests could create live state
Session registration could happen before the transport determined whether an opening request was acceptable:
Allocate ID -> register session -> start task -> validate request
|
v
REJECT
|
v
Registered state remains
Issue #3228 reported roughly 22.6 KiB retained per rejected request in one tested configuration. That is the reporter’s measurement, not a benchmark independently reproduced for this article. Cost varies with version, configuration and input.
The report also made the more consequential point: valid initialization requests could already create unlimited sessions. Rejecting malformed traffic correctly would close one defective path without fixing the underlying absence of resource bounds.
Scanners, probes and broken integrations regularly send requests a service cannot process. They should consume temporary resources while being handled. Rejection should not enroll them in a permanent residency program.
Authentication is not a resource budget
Authentication determines who may establish a session. It does not determine how many sessions that principal may establish.
An authenticated service account can repeatedly initialize connections. A buggy agent framework can create fresh sessions instead of reusing them. A compromised token can do the same intentionally. Even a readiness probe can accidentally allocate state on every check.
The permission to establish a session is not permission to establish unlimited sessions.
For a multi-tenant service, that distinction becomes a governance concern as well as a reliability concern: correctly identifying and authorizing a tenant does not bound its consumption of shared memory.
Access control and admission control answer different questions. A stateful service needs both.
The problem with green dashboards
Consider a long-running MCP server exposing HTTP request counts, status codes, latency, error rate, CPU and process memory. This is an observability thought experiment, not a claim about a particular deployment’s dashboards.
Clients initialize sessions. The server responds. Tool calls work. Requests look healthy while registered state accumulates between them.
Eventually the process reaches its memory limit. From request metrics, the failure can appear abrupt. From the lifecycle perspective, it began much earlier.
The monitoring measured whether operations succeeded. It did not measure whether their lasting resource effects were reversed.
An operation can be locally successful and globally destructive.
Useful instrumentation for this service would distinguish registered sessions, work in flight, registrations, removals, expirations and admission rejections. For example:
mcp_sessions_registered
mcp_sessions_in_flight
mcp_session_registrations_total
mcp_session_removals_total
mcp_session_expirations_total
mcp_session_rejections_total
These are proposed names, not metrics the SDK is claimed to expose automatically. Removals should count every deregistration; expirations can be a subset classified by reason.
Over an observation interval, the useful accounting relationship is:
registered_now = registered_at_start
+ registrations_during_interval
- removals_during_interval
Account for process restarts and counter resets when implementing that relationship. A raw count alone cannot establish whether retained sessions are useful or abandoned; it needs lifecycle and workload context.
Did the transport terminate? Was its entry removed? How old are idle sessions? Is an apparently idle session holding an open stream? Can the session ID still address useful work?
Those questions expose a class of failure that successful-request counts cannot.
The experiment worth running
A useful comparison would give affected and patched SDK releases the same controlled workload and record three separate series: registered sessions, process memory and completed requests.
Use distinct cases for successful initialization followed by DELETE, abandonment with and without idle expiry, and transport-rejected opening requests. Keep session payloads, concurrency and process configuration fixed. Test the population limit separately with a deliberately small configured cap.
The expected distinction is logical retention: completed lifecycles should release registry entries, while timeout and admission limits bound other retained state. An open stream or active tool call should remain alive.
Observe memory separately. Python’s allocator can keep resident memory elevated after objects are released, so RSS alone does not prove that session objects remain reachable.
No results from that comparison are claimed here. An expected curve is a test hypothesis, not evidence with nicer axes.
The fix: explicit ownership, expiry and admission
Maintainers merged PR #3395 on August 28, 2026. The advisory identifies mcp 1.30.0 and 2.2.0 as the first patched releases in their respective lines.
The fix addressed the lifecycle rather than adding a timer to an otherwise unchanged registry:
| Control | Patched default and behavior |
|---|---|
| Idle expiry | session_idle_timeout=1800; an expired ID subsequently receives HTTP 404 |
| Admission | max_sessions=10000; an opening request over the cap receives HTTP 503, without evicting existing sessions |
| Explicit termination | DELETE deregisters the terminated session |
| Transport rejection | Refused opening requests no longer leave the registered session and task behind |
Idleness is measured from completion of the last in-flight request. An open GET stream or an in-progress tool call keeps the session alive. The cleanup mechanism should not kill legitimate work simply because it takes longer than the timeout.
The population limit applies to each session manager, not the cluster. With one manager per worker, each worker has its own allowance. Ten thousand is a default, not a capacity qualification for a small container.
Both settings are configurable; None disables the corresponding limit.
There is also a boundary to the rejection fix. An initialize with invalid parameters can still open a session that expires under the ordinary policy. Cleaning up transport-rejected opening requests does not mean every application-level initialization failure is rejected before allocation.
Live sessions still retain initialization parameters. The advisory recommends tuning session count, idle timeout and request-body size to host capacity. Bounding retention does not eliminate each session’s memory cost.
Not every MCP transport was affected
This vulnerability concerns the stateful, handshake-based Streamable HTTP implementation. The advisory excludes stdio, the older SSE transport, and SDK versions 1.12.0 or later using stateless_http=True.
The newer 2026-07-28 request model uses a sessionless HTTP path. That does not automatically remove the legacy path from a server supporting both protocol eras. The SDK’s legacy-client documentation describes routing by the request’s protocol-version header; legacy clients, including opening requests without that header, still reach the handshake path.
Supporting modern clients is therefore not evidence that legacy session handling is unreachable. Compatibility retains an operational cost even after the newer protocol stops needing those resources.
A registry is not yet a resource owner
A registry stores identifiers and lets requests find state. A resource owner also controls admission, lifetime and eventual destruction.
The vulnerable manager did the first more reliably than the second.
The pattern extends beyond MCP: queues that accept unbounded work, caches without eviction, subscriptions without cancellation cleanup, temporary files whose creator disappears. Every allocation can be valid while the aggregate behavior is unsustainable.
Three architecture-review questions follow:
- What survives a request? Name its owner and termination conditions. “The client should clean it up” is insufficient when clients crash, time out or disappear.
- Can rejection or cancellation leave state behind? Trace allocation, registration and validation order, including failure after registration but before the client receives its identifier.
- Can you distinguish useful state from abandoned state? Measure lifecycle transitions and age alongside the resource count, and define what happens when the budget is exhausted.
A session limit is an admission boundary. An idle timeout is a lifetime policy. Observability tells you whether either is working.
The request succeeded. That proved the server completed an operation. It did not prove the server cleaned up after itself.
If success ends at the HTTP response, the consequences can remain registered long after the client has left.