Skip to content

Commit c60175b

Browse files
committed
Cap concurrent Streamable HTTP sessions and expose the session limits on the server factories
Add `max_sessions` (DEFAULT_MAX_SESSIONS = 10_000, `None` for no limit) to StreamableHTTPSessionManager: while that many stateful sessions are open, a request that would open another is answered 503 with a JSON-RPC error body and nothing is allocated; existing sessions are untouched and room frees up as they end or expire. This matches the Ruby SDK's defaults (the C# SDK uses the same 10 000 figure). `session_idle_timeout` and `max_sessions` are accepted by `Server.streamable_http_app()`, `MCPServer.streamable_http_app()`, `run_streamable_http_async()` and `run(transport="streamable-http")`, the same way `max_request_body_size` is, so applications can tune or disable them without reaching into `session_manager` after the fact. Docs: run/index.md options list, run/legacy-clients.md session cost, migration.md, troubleshooting.md.
1 parent 1e7fb45 commit c60175b

10 files changed

Lines changed: 160 additions & 27 deletions

File tree

docs/migration.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,7 @@ Transport-specific parameters have been moved off the `MCPServer` constructor an
758758
- `streamable_http_path` - StreamableHTTP endpoint path, on `run(transport="streamable-http", ...)` and `streamable_http_app()`
759759
- `json_response`, `stateless_http` - StreamableHTTP behavior, same two places; each also removes a server-to-client channel, see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror)
760760
- `max_request_body_size` - HTTP request-body limit, on `run()` for both HTTP transports and on both app methods
761+
- `session_idle_timeout`, `max_sessions` - StreamableHTTP session expiry and session cap, on `run(transport="streamable-http", ...)` and `streamable_http_app()`
761762
- `event_store`, `retry_interval` - StreamableHTTP event handling, same two places
762763
- `transport_security` - DNS rebinding protection, on `run()` for both HTTP transports and on both app methods
763764

@@ -862,6 +863,25 @@ mcp.run(transport="streamable-http", max_request_body_size=8 * 1024 * 1024)
862863
The limit must be positive and applies to both legacy session-based requests and V2's modern
863864
single-exchange requests. Keep the smallest value your application actually needs.
864865

866+
### Streamable HTTP sessions expire when idle and are capped
867+
868+
V2 closes a legacy (session-based) Streamable HTTP session once it has had no request in flight for
869+
30 minutes, and holds at most 10 000 such sessions per app (one `StreamableHTTPSessionManager`).
870+
871+
Most servers need no migration. A client that keeps its `GET` stream open, or has a request still
872+
being answered, is never considered idle, so clients holding a stream are unaffected. A client that goes
873+
quiet for longer than the timeout with no stream open gets `404` on its next request and must
874+
`initialize` again. While the cap is reached, a request that would open another session is
875+
answered `503`. Both settings are options on `run()` and `streamable_http_app()`; pass `None` to
876+
turn either off:
877+
878+
```python
879+
mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=None)
880+
```
881+
882+
`StreamableHTTPSessionManager(stateless=True, session_idle_timeout=...)` no longer raises: both
883+
settings are simply unused in stateless mode, which keeps no sessions.
884+
865885
### Streamable HTTP: lifespan now entered once at manager startup
866886

867887
When serving streamable HTTP (stateful or `stateless_http=True`), the server's `lifespan` context manager is now entered once when `StreamableHTTPSessionManager.run()` starts, and the resulting state is shared across all sessions and requests. Previously each session (stateful) or each request (stateless) entered and exited `lifespan` independently.
@@ -870,10 +890,10 @@ Lifespans that set up process-wide state (connection pools, caches, background t
870890

871891
### Streamable HTTP: session manager, `EventStore`, and stateless mode unchanged
872892

873-
Beyond the constructor parameters that moved to `run()`/`streamable_http_app()` and the lifespan change above, the server-side Streamable HTTP machinery is as in v1:
893+
Beyond the constructor parameters that moved to `run()`/`streamable_http_app()`, the lifespan change and the [session expiry and cap defaults](#streamable-http-sessions-expire-when-idle-and-are-capped) above, the server-side Streamable HTTP machinery is as in v1:
874894

875895
- `mcp.server.streamable_http` still exports the `EventStore` ABC (`store_event()`, `replay_events_after()`), `EventMessage`, `EventCallback`, `EventId`, and `StreamId` with unchanged signatures; a custom `EventStore` keeps importing `JSONRPCMessage` from `mcp.types`, unchanged.
876-
- `StreamableHTTPSessionManager` keeps its constructor and its `run()` / `handle_request()` methods (see [Lowlevel `Server`: what did not change](#lowlevel-server-what-did-not-change)); its `stateless=` parameter is unrelated to the removed [`Server.run(stateless=)` flag](#serverrun-no-longer-takes-a-stateless-flag).
896+
- `StreamableHTTPSessionManager` keeps its constructor and its `run()` / `handle_request()` methods (`session_idle_timeout` now defaults to 1800 seconds and `max_sessions` was added; see [Lowlevel `Server`: what did not change](#lowlevel-server-what-did-not-change)); its `stateless=` parameter is unrelated to the removed [`Server.run(stateless=)` flag](#serverrun-no-longer-takes-a-stateless-flag).
877897
- `mcp.session_manager` still returns the manager once `streamable_http_app()` has been called, with the same `stateless`, `json_response`, `event_store`, and `retry_interval` attributes.
878898
- `stateless_http=True` still serves each request with a fresh transport, no `Mcp-Session-Id`, and no state carried between requests; `ctx.close_sse_stream()` and `ctx.close_standalone_sse_stream()` are still available on the handler `Context`.
879899

@@ -1287,7 +1307,7 @@ Handler registration, signatures, and return values changed (the sections below)
12871307
- `server.create_initialization_options(notification_options=..., experimental_capabilities=...)`, `server.get_capabilities(...)` (its arguments are now optional), and `NotificationOptions(prompts_changed=, resources_changed=, tools_changed=)`. Both methods gained an optional `extensions=` argument. `create_initialization_options()` is still how you build the `InitializationOptions` passed to `run()`; the only value that differs is `server_version` (see [Unversioned servers report an empty version](#unversioned-servers-report-an-empty-version)).
12881308
- `InitializationOptions` (`from mcp.server import InitializationOptions`, also `mcp.server.models`) gained optional `title`/`description` fields; `NotificationOptions` is importable from `mcp.server` and `mcp.server.lowlevel` as before.
12891309
- `lifespan=` keeps its contract — an async-context-manager factory that receives the `Server` and whose yielded value handlers read as `ctx.lifespan_context` — but is now keyword-only (see [constructor parameters are now keyword-only](#lowlevel-server-constructor-parameters-are-now-keyword-only)) and, under streamable HTTP, entered once at manager startup (see [Streamable HTTP: lifespan now entered once at manager startup](#streamable-http-lifespan-now-entered-once-at-manager-startup)).
1290-
- Server-side transports keep their v1 signatures: `mcp.server.stdio.stdio_server()`, `mcp.server.sse.SseServerTransport(endpoint)` (`connect_sse` / `handle_post_message`), and `mcp.server.streamable_http_manager.StreamableHTTPSessionManager`; the one stdio behavior change is [`stdio_server` keeps the protocol streams on private descriptors](#stdio_server-keeps-the-protocol-streams-on-private-descriptors).
1310+
- Server-side transports keep their v1 signatures: `mcp.server.stdio.stdio_server()`, `mcp.server.sse.SseServerTransport(endpoint)` (`connect_sse` / `handle_post_message`), and `mcp.server.streamable_http_manager.StreamableHTTPSessionManager` (whose sessions now [expire when idle and are capped](#streamable-http-sessions-expire-when-idle-and-are-capped) by default); the one stdio behavior change is [`stdio_server` keeps the protocol streams on private descriptors](#stdio_server-keeps-the-protocol-streams-on-private-descriptors).
12911311
- Import paths: `from mcp.server import Server` (preferred), `from mcp.server.lowlevel import Server`, and `from mcp.server.lowlevel.server import Server` all resolve; only the `request_ctx` contextvar left `mcp.server.lowlevel.server` (see [`request_context` property removed](#lowlevel-server-request_context-property-removed)). `mcp.server.lowlevel.helper_types.ReadResourceContents` still exists (it is `MCPServer.read_resource()`'s return type), but lowlevel `on_read_resource` handlers return `ReadResourceResult` (see [automatic return value wrapping removed](#lowlevel-server-automatic-return-value-wrapping-removed)).
12921312

12931313
So a v1 `main()` carries over untouched:
@@ -2060,7 +2080,7 @@ Also drop `execution=ToolExecution(taskSupport=types.TASK_REQUIRED)` from tool d
20602080

20612081
## Transports
20622082

2063-
Server-side transport entry points (`stdio_server()`, `SseServerTransport`, `StreamableHTTPSessionManager`) keep their v1 import paths and signatures (see [Lowlevel `Server`: what did not change](#lowlevel-server-what-did-not-change)), so the sections below are client-side apart from [`stdio_server` keeps the protocol streams on private descriptors](#stdio_server-keeps-the-protocol-streams-on-private-descriptors); the other server-side transport changes ([lifespan entered once](#streamable-http-lifespan-now-entered-once-at-manager-startup), the [4 MiB request-body limit](#streamable-http-request-bodies-are-limited-to-4-mib)) sit under MCPServer.
2083+
Server-side transport entry points (`stdio_server()`, `SseServerTransport`, `StreamableHTTPSessionManager`) keep their v1 import paths and signatures (see [Lowlevel `Server`: what did not change](#lowlevel-server-what-did-not-change)), so the sections below are client-side apart from [`stdio_server` keeps the protocol streams on private descriptors](#stdio_server-keeps-the-protocol-streams-on-private-descriptors); the other server-side transport changes ([lifespan entered once](#streamable-http-lifespan-now-entered-once-at-manager-startup), the [4 MiB request-body limit](#streamable-http-request-bodies-are-limited-to-4-mib), [session expiry and cap](#streamable-http-sessions-expire-when-idle-and-are-capped)) sit under MCPServer.
20642084

20652085
### `streamablehttp_client` removed
20662086

docs/run/index.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ Each transport has its own keyword arguments, all on `run()`:
7070
* `max_request_body_size`: largest accepted request body in bytes. Defaults to 4 MiB; larger requests
7171
receive HTTP 413 before parsing or session creation. Raise it only when legitimate MCP messages
7272
exceed that size.
73+
* `session_idle_timeout`: how long, in seconds, a [legacy](legacy-clients.md) (session-based)
74+
client's session may sit with no request in flight before the server closes it. Defaults to 1800
75+
(30 minutes); `None` keeps sessions until the client deletes them. A client with an open `GET`
76+
stream or a request still being answered is never idle.
77+
* `max_sessions`: how many such sessions one app holds at once. Defaults to 10 000; while that many
78+
are open, a request that would open another gets HTTP 503. `None` removes the limit.
7379
* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **[Deploy & scale](deploy.md)** covers `transport_security`.
7480

7581
!!! warning

docs/run/legacy-clients.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ On one worker that is invisible. On two, it is the whole problem: a request that
5656
events to a client reconnecting to the *same* session), not a session store. It never makes a
5757
session reachable from another process.
5858

59+
The record is not kept forever. A client that ends its session (`DELETE`) frees it at once;
60+
a session that has had no request in flight for `session_idle_timeout` seconds (default 1800; an
61+
open `GET` stream or a request being answered counts as in flight) is closed, and its next request
62+
gets the same `404` a stray ID gets, so the client has to `initialize` again. Each worker process
63+
holds at most `max_sessions` of them (default 10 000) and answers `503` to a request that would
64+
open one more. Both are `run()` / `streamable_http_app()` options.
65+
5966
## The one knob: `stateless_http`
6067

6168
If stickiness is a cost you refuse to pay, there is exactly one thing you can change.

docs/troubleshooting.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif
246246

247247
## `MCPError: Session not found`
248248

249-
The server does not recognise the `Mcp-Session-Id` your client sent, almost always because the server **restarted** (or you were routed to a different instance). Sessions live in that one process's memory.
249+
The server does not recognise the `Mcp-Session-Id` your client sent, because the server **restarted** (or you were routed to a different instance), or because the session **expired**: a legacy session with no request in flight for `session_idle_timeout` (30 minutes by default; an open `GET` stream or a request being answered counts as in flight) is closed, as is one the client ended with `DELETE`. Sessions live in that one process's memory.
250250

251251
There is no server bug to find. The HTTP response is a `404` whose body *is* JSON-RPC, so, unlike the `421` above, the python `Client` shows you this one verbatim:
252252

@@ -256,9 +256,9 @@ There is no server bug to find. The HTTP response is a `404` whose body *is* JSO
256256

257257
The fix is to reconnect: leave the `async with Client(...)` block and enter a new one, which negotiates a fresh session. For a long-lived client, that means catching `MCPError` around your calls and reconnecting on this message rather than retrying inside a dead session.
258258

259-
If it happens *without* a restart, you are running more than one worker without sticky sessions: each worker holds its own session table, so a request routed to the wrong one lands here. **[Deploy & scale](run/deploy.md)** and **[Serving legacy clients](run/legacy-clients.md)** own that story and its two fixes (sticky routing, or `stateless_http=True`).
259+
If it happens *without* a restart and without the client having gone quiet that long, you are running more than one worker without sticky sessions: each worker holds its own session table, so a request routed to the wrong one lands here. **[Deploy & scale](run/deploy.md)** and **[Serving legacy clients](run/legacy-clients.md)** own that story and its two fixes (sticky routing, or `stateless_http=True`).
260260

261-
For the server operator, the matching log line is `Rejected request with unknown or expired session ID: <id>`. It is logged at `INFO`, so it is invisible at the usual `WARNING` threshold. Seeing it in bursts right after a deploy is normal; every connected client is reconnecting.
261+
For the server operator, the matching log line is `Rejected request with unknown or expired session ID: <id>`. It is logged at `INFO`, so it is invisible at the usual `WARNING` threshold. Seeing it in bursts right after a deploy is normal; every connected client is reconnecting. When the session expired instead, that line is preceded by `Session <id> idle timeout`, also at `INFO`.
262262

263263
## `MCPError: Method not found`
264264

@@ -411,7 +411,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key
411411
* `Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one.
412412
* One 421, three spellings: `Server returned an error response` (the python `Client`), `421 Misdirected Request` / `Invalid Host header` (everything else), `Invalid Host header: <host>` (the server log). Fix: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`.
413413
* `Task group is not initialized` -> a mounted app whose host lifespan never entered `mcp.session_manager.run()`.
414-
* `Session not found` -> the server restarted; reconnect.
414+
* `Session not found` -> the server restarted or the session expired (`session_idle_timeout`); reconnect.
415415
* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` needs a server-to-client channel: a `2026-07-28` connection never has one, `stateless_http=True` takes away the legacy one, and `json_response=True` takes away the request-scoped one. Use a resolver (a legacy client also needs a server that keeps the channel). Its neighbour `Method not found` is a request for a method the other side's protocol revision doesn't have.
416416
* `Client did not declare the form elicitation capability ...` and `Elicitation not supported` -> the client is missing `elicitation_callback=`.
417417
* `Invalid or expired requestState` never says why on the wire. The server log does; `unknown key` means share `RequestStateSecurity(keys=[...])` across workers.

src/mcp/server/lowlevel/server.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,12 @@ async def main():
6565
from mcp.server.models import InitializationOptions
6666
from mcp.server.runner import serve_dual_era_loop
6767
from mcp.server.streamable_http import EventStore
68-
from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
68+
from mcp.server.streamable_http_manager import (
69+
DEFAULT_MAX_SESSIONS,
70+
DEFAULT_SESSION_IDLE_TIMEOUT,
71+
StreamableHTTPASGIApp,
72+
StreamableHTTPSessionManager,
73+
)
6974
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings
7075
from mcp.shared._stream_protocols import ReadStream, WriteStream
7176
from mcp.shared.exceptions import MCPDeprecationWarning
@@ -722,6 +727,8 @@ def streamable_http_app(
722727
event_store: EventStore | None = None,
723728
retry_interval: int | None = None,
724729
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
730+
session_idle_timeout: float | None = DEFAULT_SESSION_IDLE_TIMEOUT,
731+
max_sessions: int | None = DEFAULT_MAX_SESSIONS,
725732
transport_security: TransportSecuritySettings | None = None,
726733
host: str = "127.0.0.1",
727734
auth: AuthSettings | None = None,
@@ -747,6 +754,8 @@ def streamable_http_app(
747754
stateless=stateless_http,
748755
security_settings=transport_security,
749756
max_request_body_size=max_request_body_size,
757+
session_idle_timeout=session_idle_timeout,
758+
max_sessions=max_sessions,
750759
)
751760
self._session_manager = session_manager
752761

src/mcp/server/mcpserver/server.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,11 @@
9393
from mcp.server.sse import SseServerTransport
9494
from mcp.server.stdio import stdio_server
9595
from mcp.server.streamable_http import EventStore
96-
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
96+
from mcp.server.streamable_http_manager import (
97+
DEFAULT_MAX_SESSIONS,
98+
DEFAULT_SESSION_IDLE_TIMEOUT,
99+
StreamableHTTPSessionManager,
100+
)
97101
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus
98102
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings
99103
from mcp.shared.exceptions import MCPError
@@ -388,6 +392,8 @@ def run(
388392
event_store: EventStore | None = ...,
389393
retry_interval: int | None = ...,
390394
max_request_body_size: int = ...,
395+
session_idle_timeout: float | None = ...,
396+
max_sessions: int | None = ...,
391397
transport_security: TransportSecuritySettings | None = ...,
392398
) -> None: ...
393399

@@ -1106,6 +1112,8 @@ async def run_streamable_http_async( # pragma: no cover
11061112
event_store: EventStore | None = None,
11071113
retry_interval: int | None = None,
11081114
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
1115+
session_idle_timeout: float | None = DEFAULT_SESSION_IDLE_TIMEOUT,
1116+
max_sessions: int | None = DEFAULT_MAX_SESSIONS,
11091117
transport_security: TransportSecuritySettings | None = None,
11101118
) -> None:
11111119
"""Run the server using StreamableHTTP transport."""
@@ -1118,6 +1126,8 @@ async def run_streamable_http_async( # pragma: no cover
11181126
event_store=event_store,
11191127
retry_interval=retry_interval,
11201128
max_request_body_size=max_request_body_size,
1129+
session_idle_timeout=session_idle_timeout,
1130+
max_sessions=max_sessions,
11211131
transport_security=transport_security,
11221132
host=host,
11231133
)
@@ -1270,6 +1280,8 @@ def streamable_http_app(
12701280
event_store: EventStore | None = None,
12711281
retry_interval: int | None = None,
12721282
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
1283+
session_idle_timeout: float | None = DEFAULT_SESSION_IDLE_TIMEOUT,
1284+
max_sessions: int | None = DEFAULT_MAX_SESSIONS,
12731285
transport_security: TransportSecuritySettings | None = None,
12741286
host: str = "127.0.0.1",
12751287
) -> Starlette:
@@ -1281,6 +1293,8 @@ def streamable_http_app(
12811293
event_store=event_store,
12821294
retry_interval=retry_interval,
12831295
max_request_body_size=max_request_body_size,
1296+
session_idle_timeout=session_idle_timeout,
1297+
max_sessions=max_sessions,
12841298
transport_security=transport_security,
12851299
host=host,
12861300
auth=self.settings.auth,

0 commit comments

Comments
 (0)