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
Copy file name to clipboardExpand all lines: docs/run/index.md
+5Lines changed: 5 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -70,6 +70,11 @@ 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`: seconds a legacy session may sit with nothing in flight before the
74
+
server closes it. Default 1800. `None` disables it. See
75
+
[Session lifetime and limits](legacy-clients.md#session-lifetime-and-limits).
76
+
*`max_sessions`: how many legacy sessions one process holds at once. Default 10 000. `None`
77
+
removes the limit. Covered in the same section.
73
78
*`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`.
Copy file name to clipboardExpand all lines: docs/run/legacy-clients.md
+34Lines changed: 34 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -56,6 +56,40 @@ On one worker that is invisible. On two, it is the whole problem: a request that
56
56
events to a client reconnecting to the *same* session), not a session store. It never makes a
57
57
session reachable from another process.
58
58
59
+
## Session lifetime and limits
60
+
61
+
A legacy session does not live forever, and one process does not hold an unlimited number of
62
+
them. Two settings control this. Both are keyword arguments on `run()`, `streamable_http_app()`
63
+
and `Server.streamable_http_app()`. Modern (`2026-07-28`) connections and `stateless_http=True`
64
+
have no sessions, so neither setting applies to them.
65
+
66
+
| Setting | Default | What it does | What the client sees | Turn it off |
67
+
|---|---|---|---|---|
68
+
|`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`|
69
+
|`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`|
70
+
71
+
What counts as "in flight":
72
+
73
+
* An open `GET` stream. The SDK clients keep one open, so a connected client's session never
74
+
expires.
75
+
* A request that is still being answered. A tool call that runs longer than the timeout is not
76
+
interrupted, and the countdown only starts once it finishes.
77
+
* Nothing else. Between requests the clock runs. Any request on the session restarts it,
78
+
`ping` included. Once a session has expired, nothing revives it.
79
+
80
+
A client that ends its session with `DELETE` frees it immediately. So does a client whose
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. 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.
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