Expire idle Streamable HTTP sessions by default and cap concurrent sessions (#3395)

This commit is contained in:
Max 2026-08-28 15:54:49 +01:00 • committed by GitHub
commit d2290ca343
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 915 additions and 234 deletions

View file

@ -70,6 +70,11 @@ Each transport has its own keyword arguments, all on `run()`:
* `max_request_body_size`: largest accepted request body in bytes. Defaults to 4 MiB; larger requests
receive HTTP 413 before parsing or session creation. Raise it only when legitimate MCP messages
exceed that size.
* `session_idle_timeout`: seconds a legacy session may sit with nothing in flight before the
server closes it. Default 1800. `None` disables it. See
[Session lifetime and limits](legacy-clients.md#session-lifetime-and-limits).
* `max_sessions`: how many legacy sessions one process holds at once. Default 10 000. `None`
removes the limit. Covered in the same section.
* `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`.
!!! warning

View file

@ -56,6 +56,40 @@ On one worker that is invisible. On two, it is the whole problem: a request that
events to a client reconnecting to the *same* session), not a session store. It never makes a
session reachable from another process.
## Session lifetime and limits
A legacy session does not live forever, and one process does not hold an unlimited number of
them. Two settings control this. Both are keyword arguments on `run()`, `streamable_http_app()`
and `Server.streamable_http_app()`. Modern (`2026-07-28`) connections and `stateless_http=True`
have no sessions, so neither setting applies to them.
| Setting | Default | What it does | What the client sees | Turn it off |
|---|---|---|---|---|
| `session_idle_timeout` | `1800` (30 min) | Closes a session that has had nothing in flight for that long. | `404 Session not found`. It has to `initialize` again. | `None` |
| `max_sessions` | `10_000` | Refuses to open a session beyond that many. Existing sessions are untouched and nothing is evicted. | `503 Too many open sessions` with JSON-RPC code `-32603`. | `None` |
What counts as "in flight":
* An open `GET` stream. The SDK clients keep one open, so a connected client's session never
expires.
* A request that is still being answered. A tool call that runs longer than the timeout is not
interrupted, and the countdown only starts once it finishes.
* Nothing else. Between requests the clock runs. Any request on the session restarts it,
`ping` included. Once a session has expired, nothing revives it.
A client that ends its session with `DELETE` frees it immediately. So does a client whose
opening request was refused.
```python
mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000)
```
Both events show up in the server log. An expiry is `Session <id> idle timeout` at `INFO`. A
refused open is `Refusing to open a new session: <n> sessions are already open` at `WARNING`.
The limits are per process. With four workers the ceiling is four times `max_sessions`, and each
worker expires its own sessions.
## The one knob: `stateless_http`
If stickiness is a cost you refuse to pay, there is exactly one thing you can change.