Skip to content

Commit ae2daca

Browse files
committed
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, troubleshooting.md.
1 parent 373e956 commit ae2daca

9 files changed

Lines changed: 136 additions & 23 deletions

File tree

docs/run/index.md

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

7581
!!! warning

docs/run/legacy-clients.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ 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+
The record is not kept forever. A client that ends its session (`DELETE`) frees it at once;
60+
a session that has had no request in flight for `session_idle_timeout` seconds (default 1800; an
61+
open `GET` stream or a request being answered counts as in flight) is closed, and its next request
62+
gets the same `404` a stray ID gets, so the client has to `initialize` again. Each worker process
63+
holds at most `max_sessions` of them (default 10 000) and answers `503` to a request that would
64+
open one more. Both are `run()` / `streamable_http_app()` options.
65+
5966
## The one knob: `stateless_http`
6067

6168
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, 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.
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_manager.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
DEFAULT_SESSION_IDLE_TIMEOUT: Final = 30 * 60
3838
"""Default idle period in seconds after which a stateful Streamable HTTP session is closed (30 minutes)."""
3939

40+
DEFAULT_MAX_SESSIONS: Final = 10_000
41+
"""Default maximum number of concurrent stateful Streamable HTTP sessions per session manager."""
42+
4043

4144
class StreamableHTTPSessionManager:
4245
"""Manages StreamableHTTP sessions with optional resumability via event store.
@@ -73,6 +76,10 @@ class StreamableHTTPSessionManager:
7376
sessions live until the client deletes them or the manager shuts down. Unused in stateless mode.
7477
max_request_body_size: Maximum size in bytes for Streamable HTTP request bodies. Requests that
7578
exceed this limit receive a 413 response before parsing or session creation. Defaults to 4 MiB.
79+
max_sessions: Maximum number of concurrent stateful sessions. While that many sessions are open, a
80+
request that would open another one receives a 503 response; existing sessions are unaffected and
81+
room frees up as they end or expire. Defaults to 10 000; None removes the limit. Unused in stateless
82+
mode.
7683
"""
7784

7885
def __init__(
@@ -85,11 +92,14 @@ def __init__(
8592
retry_interval: int | None = None,
8693
session_idle_timeout: float | None = DEFAULT_SESSION_IDLE_TIMEOUT,
8794
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
95+
max_sessions: int | None = DEFAULT_MAX_SESSIONS,
8896
):
8997
if session_idle_timeout is not None and session_idle_timeout <= 0:
9098
raise ValueError("session_idle_timeout must be a positive number of seconds")
9199
if max_request_body_size <= 0:
92100
raise ValueError("max_request_body_size must be a positive number of bytes")
101+
if max_sessions is not None and max_sessions <= 0:
102+
raise ValueError("max_sessions must be a positive number of sessions or None")
93103

94104
self.app = app
95105
self.event_store = event_store
@@ -99,6 +109,7 @@ def __init__(
99109
self.retry_interval = retry_interval
100110
self.session_idle_timeout = session_idle_timeout
101111
self.max_request_body_size = max_request_body_size
112+
self.max_sessions = max_sessions
102113
self.asgi_app = RequestBodyLimitMiddleware(self._handle_request, max_request_body_size)
103114

104115
# Session tracking (only used if not stateless)
@@ -265,15 +276,7 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S
265276
"Rejecting request for session %s: credential does not match the one that created the session",
266277
request_mcp_session_id[:64],
267278
)
268-
body = JSONRPCError(
269-
jsonrpc="2.0", id=None, error=ErrorData(code=INVALID_REQUEST, message="Session not found")
270-
)
271-
response = Response(
272-
body.model_dump_json(by_alias=True, exclude_unset=True),
273-
status_code=404,
274-
media_type="application/json",
275-
)
276-
await response(scope, receive, send)
279+
await _error_response("Session not found", 404)(scope, receive, send)
277280
return
278281
logger.debug("Session already exists, handling request directly")
279282
await transport.handle_request(scope, receive, send)
@@ -287,6 +290,11 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S
287290
# New session case
288291
logger.debug("Creating new transport")
289292
async with self._session_creation_lock:
293+
if self.max_sessions is not None and len(self._server_instances) >= self.max_sessions:
294+
logger.warning("Refusing to open a new session: %d sessions are already open", self.max_sessions)
295+
await _error_response("Too many open sessions", 503)(scope, receive, send)
296+
return
297+
290298
new_session_id = uuid4().hex
291299
http_transport = StreamableHTTPServerTransport(
292300
mcp_session_id=new_session_id,
@@ -365,20 +373,22 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
365373
# TODO(L62): Align error code once spec clarifies
366374
# See: https://github.com/modelcontextprotocol/python-sdk/issues/1821
367375
logger.info(f"Rejected request with unknown or expired session ID: {request_mcp_session_id[:64]}")
368-
body = JSONRPCError(
369-
jsonrpc="2.0", id=None, error=ErrorData(code=INVALID_REQUEST, message="Session not found")
370-
)
371-
response = Response(
372-
body.model_dump_json(by_alias=True, exclude_unset=True), status_code=404, media_type="application/json"
373-
)
374-
await response(scope, receive, send)
376+
await _error_response("Session not found", 404)(scope, receive, send)
375377

376378
def _forget_session(self, session_id: str) -> None:
377379
"""Stop tracking a session; requests naming it are answered 404 from then on."""
378380
self._server_instances.pop(session_id, None)
379381
self._session_owners.pop(session_id, None)
380382

381383

384+
def _error_response(message: str, status_code: int) -> Response:
385+
"""A JSON-RPC error body (no request id) with the given HTTP status."""
386+
body = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=INVALID_REQUEST, message=message))
387+
return Response(
388+
body.model_dump_json(by_alias=True, exclude_unset=True), status_code=status_code, media_type="application/json"
389+
)
390+
391+
382392
async def _send_and_report_status(app: ASGIApp, scope: Scope, receive: Receive, send: Send) -> int | None:
383393
"""Run `app` for one request and return the HTTP status it answered with (None if it sent no response)."""
384394
status: int | None = None

tests/docs_src/test_asgi.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from docs_src.asgi import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006
1515
from mcp import Client
16-
from mcp.server import MCPServer
16+
from mcp.server import MCPServer, Server
1717

1818
# See test_index.py for why this is a per-module mark and not a conftest hook.
1919
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@@ -44,6 +44,8 @@ async def test_streamable_http_app_takes_runs_options_except_port() -> None:
4444
"event_store",
4545
"retry_interval",
4646
"max_request_body_size",
47+
"session_idle_timeout",
48+
"max_sessions",
4749
"transport_security",
4850
"host",
4951
}
@@ -68,6 +70,18 @@ async def test_streamable_http_app_applies_the_configured_request_body_limit() -
6870
assert response.status_code == 413
6971

7072

73+
async def test_streamable_http_app_applies_the_configured_session_limits() -> None:
74+
"""The documented `session_idle_timeout` and `max_sessions` options reach the session manager, from
75+
both the high-level and the low-level factory."""
76+
server = MCPServer("Notes")
77+
server.streamable_http_app(session_idle_timeout=5, max_sessions=7)
78+
assert (server.session_manager.session_idle_timeout, server.session_manager.max_sessions) == (5, 7)
79+
80+
lowlevel = Server("Notes")
81+
lowlevel.streamable_http_app(session_idle_timeout=None, max_sessions=None)
82+
assert (lowlevel.session_manager.session_idle_timeout, lowlevel.session_manager.max_sessions) == (None, None)
83+
84+
7185
async def test_mounting_at_the_root_keeps_the_default_path() -> None:
7286
"""tutorial002: `Mount("/")` plus the default `streamable_http_path` leaves the endpoint at `/mcp`."""
7387
(mount,) = tutorial002.app.routes

tests/docs_src/test_legacy_clients.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ def test_streamable_http_app_has_no_era_knob() -> None:
5353
"event_store",
5454
"retry_interval",
5555
"max_request_body_size",
56+
"session_idle_timeout",
57+
"max_sessions",
5658
"transport_security",
5759
"host",
5860
}
@@ -76,6 +78,20 @@ async def test_a_legacy_session_is_minted_in_process_and_a_stray_session_id_is_a
7678
assert stray.status_code == 404
7779

7880

81+
def test_legacy_sessions_expire_and_are_capped_by_default() -> None:
82+
"""The cost section: a session record is dropped after 30 idle minutes and each worker process holds at most
83+
10 000 of them, unless `run()` / `streamable_http_app()` say otherwise."""
84+
server = MCPServer("Bookshop")
85+
server.streamable_http_app()
86+
assert server.session_manager.session_idle_timeout == 30 * 60
87+
assert server.session_manager.max_sessions == 10_000
88+
89+
server = MCPServer("Bookshop")
90+
server.streamable_http_app(session_idle_timeout=None, max_sessions=None)
91+
assert server.session_manager.session_idle_timeout is None
92+
assert server.session_manager.max_sessions is None
93+
94+
7995
async def test_stateless_http_never_mints_a_session() -> None:
8096
"""The `stateless_http=True` section: the same legacy `initialize` no longer gets an `Mcp-Session-Id`."""
8197
app = MCPServer("Bookshop").streamable_http_app(stateless_http=True)

0 commit comments

Comments
 (0)