Expire idle Streamable HTTP sessions by default and cap concurrent sessions (#3395)
This commit is contained in:
parent
6705402e24
commit
d2290ca343
3 changed files with 915 additions and 234 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif
|
|||
|
||||
## `MCPError: Session not found`
|
||||
|
||||
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.
|
||||
The server does not recognise the `Mcp-Session-Id` your client sent. Either the server **restarted** (or you were routed to a different instance), or the session **expired** because nothing was in flight for `session_idle_timeout`, which is 30 minutes by default. See [Session lifetime and limits](run/legacy-clients.md#session-lifetime-and-limits). Sessions live in that one process's memory.
|
||||
|
||||
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:
|
||||
|
||||
|
|
@ -256,9 +256,9 @@ There is no server bug to find. The HTTP response is a `404` whose body *is* JSO
|
|||
|
||||
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.
|
||||
|
||||
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`).
|
||||
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`).
|
||||
|
||||
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.
|
||||
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`.
|
||||
|
||||
## `MCPError: Method not found`
|
||||
|
||||
|
|
@ -411,7 +411,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key
|
|||
* `Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one.
|
||||
* 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=[...])`.
|
||||
* `Task group is not initialized` -> a mounted app whose host lifespan never entered `mcp.session_manager.run()`.
|
||||
* `Session not found` -> the server restarted; reconnect.
|
||||
* `Session not found` -> the server restarted or the session expired (`session_idle_timeout`); reconnect.
|
||||
* `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.
|
||||
* `Client did not declare the form elicitation capability ...` and `Elicitation not supported` -> the client is missing `elicitation_callback=`.
|
||||
* `Invalid or expired requestState` never says why on the wire. The server log does; `unknown key` means share `RequestStateSecurity(keys=[...])` across workers.
|
||||
|
|
|
|||
Loading…
Reference in a new issue