Skip to content

Commit d2290ca

Browse files
authored
Expire idle Streamable HTTP sessions by default and cap concurrent sessions (#3395)
1 parent 6705402 commit d2290ca

13 files changed

Lines changed: 915 additions & 234 deletions

File tree

docs/run/index.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ 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`: 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.
7378
* `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`.
7479

7580
!!! warning

docs/run/legacy-clients.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,40 @@ 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+
## 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
81+
opening request was refused.
82+
83+
```python
84+
mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000)
85+
```
86+
87+
Both events show up in the server log. An expiry is `Session <id> idle timeout` at `INFO`. A
88+
refused open is `Refusing to open a new session: <n> sessions are already open` at `WARNING`.
89+
90+
The limits are per process. With four workers the ceiling is four times `max_sessions`, and each
91+
worker expires its own sessions.
92+
5993
## The one knob: `stateless_http`
6094

6195
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. 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.
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,

src/mcp/server/streamable_http.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
import logging
10+
import math
1011
import re
1112
from abc import ABC, abstractmethod
1213
from collections.abc import AsyncGenerator, Awaitable, Callable
@@ -167,6 +168,7 @@ def __init__(
167168
event_store: EventStore | None = None,
168169
security_settings: TransportSecuritySettings | None = None,
169170
retry_interval: int | None = None,
171+
idle_timeout: float | None = None,
170172
) -> None:
171173
"""Initialize a new StreamableHTTP server transport.
172174
@@ -187,12 +189,22 @@ def __init__(
187189
retry field. When set, the server will send a retry field in
188190
SSE priming events to control client reconnection timing for
189191
polling behavior. Only used when event_store is provided.
192+
idle_timeout: Seconds the session may go without any request in flight before
193+
`idle_scope` is cancelled. A request being served or an open GET
194+
stream holds the session open; the countdown starts each time the
195+
last in-flight request completes. The host enters `idle_scope`
196+
(available once `connect()` has been entered) around the session's
197+
message loop to end the session when it fires. Default is None: no
198+
`idle_scope`, the session never expires.
190199
191200
Raises:
192-
ValueError: If the session ID contains invalid characters.
201+
ValueError: If the session ID contains invalid characters, or if `idle_timeout`
202+
is not a positive, finite number.
193203
"""
194204
if mcp_session_id is not None and not SESSION_ID_PATTERN.fullmatch(mcp_session_id):
195205
raise ValueError("Session ID must only contain visible ASCII characters (0x21-0x7E)")
206+
if idle_timeout is not None and not (math.isfinite(idle_timeout) and idle_timeout > 0):
207+
raise ValueError("idle_timeout must be a positive, finite number of seconds")
196208

197209
self.mcp_session_id = mcp_session_id
198210
self.is_json_response_enabled = is_json_response_enabled
@@ -208,8 +220,11 @@ def __init__(
208220
] = {}
209221
self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[SSEEvent]] = {}
210222
self._terminated = False
211-
# Idle timeout cancel scope; managed by the session manager.
223+
self._idle_timeout = idle_timeout
224+
self._requests_in_flight = 0
212225
self.idle_scope: anyio.CancelScope | None = None
226+
"""Created when `connect()` is entered if `idle_timeout` is set; cancelled once no request has been in
227+
flight for `idle_timeout` seconds."""
213228

214229
@property
215230
def is_terminated(self) -> bool:
@@ -458,6 +473,32 @@ async def _clean_up_memory_streams(self, request_id: RequestId) -> None:
458473

459474
async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None:
460475
"""Application entry point that handles all HTTP requests."""
476+
if self.idle_scope is None or self._idle_timeout is None:
477+
await self._handle_request(scope, receive, send)
478+
return
479+
480+
if self.idle_scope.cancel_called:
481+
# The idle period already ran out and the host is ending this
482+
# session: answer as terminated rather than dispatch into a
483+
# message loop that is going away.
484+
if not self._terminated:
485+
await self.terminate()
486+
await self._handle_request(scope, receive, send)
487+
return
488+
489+
# A request in flight (an open GET stream included) holds the session:
490+
# the idle countdown is suspended while any is being served and
491+
# restarts when the last one completes.
492+
self._requests_in_flight += 1
493+
self.idle_scope.deadline = math.inf
494+
try:
495+
await self._handle_request(scope, receive, send)
496+
finally:
497+
self._requests_in_flight -= 1
498+
if not self._requests_in_flight:
499+
self.idle_scope.deadline = anyio.current_time() + self._idle_timeout
500+
501+
async def _handle_request(self, scope: Scope, receive: Receive, send: Send) -> None:
461502
request = Request(scope, receive)
462503

463504
# Validate request headers for DNS rebinding protection
@@ -793,7 +834,7 @@ async def _handle_delete_request(self, request: Request, send: Send) -> None:
793834
await response(request.scope, request.receive, send)
794835
return
795836

796-
if not await self._validate_request_headers(request, send): # pragma: no cover
837+
if not await self._validate_request_headers(request, send):
797838
return
798839

799840
await self.terminate()
@@ -995,6 +1036,8 @@ async def connect(
9951036
Yields:
9961037
Tuple of (read_stream, write_stream) for bidirectional communication
9971038
"""
1039+
if self._idle_timeout is not None:
1040+
self.idle_scope = anyio.CancelScope()
9981041

9991042
# Create the memory streams for this connection
10001043

0 commit comments

Comments
 (0)