You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Copy file name to clipboardExpand all lines: docs/migration.md
+24-4Lines changed: 24 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -758,6 +758,7 @@ Transport-specific parameters have been moved off the `MCPServer` constructor an
758
758
-`streamable_http_path` - StreamableHTTP endpoint path, on `run(transport="streamable-http", ...)` and `streamable_http_app()`
759
759
-`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)
760
760
-`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()`
761
762
-`event_store`, `retry_interval` - StreamableHTTP event handling, same two places
762
763
-`transport_security` - DNS rebinding protection, on `run()` for both HTTP transports and on both app methods
`StreamableHTTPSessionManager(stateless=True, session_idle_timeout=...)` no longer raises: both
883
+
settings are simply unused in stateless mode, which keeps no sessions.
884
+
865
885
### Streamable HTTP: lifespan now entered once at manager startup
866
886
867
887
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
870
890
871
891
### Streamable HTTP: session manager, `EventStore`, and stateless mode unchanged
872
892
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:
874
894
875
895
-`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).
877
897
-`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.
878
898
-`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`.
879
899
@@ -1287,7 +1307,7 @@ Handler registration, signatures, and return values changed (the sections below)
1287
1307
-`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)).
1288
1308
-`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.
1289
1309
-`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).
1291
1311
- 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)).
1292
1312
1293
1313
So a v1 `main()` carries over untouched:
@@ -2060,7 +2080,7 @@ Also drop `execution=ToolExecution(taskSupport=types.TASK_REQUIRED)` from tool d
2060
2080
2061
2081
## Transports
2062
2082
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.
Copy file name to clipboardExpand all lines: docs/run/index.md
+6Lines changed: 6 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -70,6 +70,12 @@ Each transport has its own keyword arguments, all on `run()`:
70
70
*`max_request_body_size`: largest accepted request body in bytes. Defaults to 4 MiB; larger requests
71
71
receive HTTP 413 before parsing or session creation. Raise it only when legitimate MCP messages
72
72
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.
73
79
*`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`.
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.
250
250
251
251
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:
252
252
@@ -256,9 +256,9 @@ There is no server bug to find. The HTTP response is a `404` whose body *is* JSO
256
256
257
257
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.
258
258
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`).
260
260
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`.
*`Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one.
412
412
* 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=[...])`.
413
413
*`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.
415
415
*`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.
416
416
*`Client did not declare the form elicitation capability ...` and `Elicitation not supported` -> the client is missing `elicitation_callback=`.
417
417
*`Invalid or expired requestState` never says why on the wire. The server log does; `unknown key` means share `RequestStateSecurity(keys=[...])` across workers.
0 commit comments