Skip to content

Commit 2f502bc

Browse files
committed
[v1.x] Expire idle Streamable HTTP sessions by default and cap concurrent sessions
v1.x backport of #3395. Stateful Streamable HTTP sessions get a lifecycle the server owns: a session is forgotten and its transport terminated as soon as it ends (client DELETE, a refused or failed opening request, idle expiry, crash), sessions with nothing in flight expire after session_idle_timeout (default 1800 seconds; an open GET stream or a request being answered holds the session), and a manager admits at most max_sessions (default 10 000) and answers 503 beyond that. A stateless transport is terminated even when its request is cancelled. Both settings are FastMCP(...) keyword arguments and Settings fields. Differences from #3395: - the per-session task drives Server.run() rather than serve_loop; - idle expiry ends a session by discarding it (which closes the transport's streams, as a client DELETE does) rather than by cancelling Server.run(): on 1.x Server.run() enters the server's lifespan per session, and its teardown should complete rather than run under cancellation; - the settings are FastMCP constructor arguments / Settings fields rather than run()/app-factory keywords, and FastMCP.streamable_http_app() takes no options; - the manager's JSON-RPC error bodies keep this branch's id "server-error" envelope; - the transport also closes its per-request SSE streams when a response ends (the 1.x share of an earlier main change), which the in-process tests need; - tests drive the manager through raw ASGI callables; - documentation lives in docs/server.md.
1 parent 5ebdfed commit 2f502bc

7 files changed

Lines changed: 883 additions & 208 deletions

File tree

docs/server.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,6 +1254,7 @@ The FastMCP server instance accessible via `ctx.fastmcp` provides access to serv
12541254
- `mount_path`, `sse_path`, `streamable_http_path` - Transport paths
12551255
- `stateless_http` - Whether the server operates in stateless mode
12561256
- `max_request_body_size` - Maximum HTTP request body size in bytes (Streamable HTTP and SSE)
1257+
- `session_idle_timeout` and `max_sessions` - Streamable HTTP session expiry and session cap
12571258
- And other configuration options
12581259

12591260
```python
@@ -1426,6 +1427,9 @@ messages, configure the smallest suitable byte limit:
14261427
mcp = FastMCP("Large messages", max_request_body_size=8 * 1024 * 1024)
14271428
```
14281429

1430+
Stateful sessions expire and are capped per process. See
1431+
[Session lifetime and limits](#session-lifetime-and-limits) below.
1432+
14291433
<!-- snippet-source examples/snippets/servers/streamable_config.py -->
14301434
```python
14311435
"""
@@ -1536,6 +1540,39 @@ The streamable HTTP transport supports:
15361540
- JSON or SSE response formats
15371541
- Better scalability for multi-node deployments
15381542

1543+
#### Session lifetime and limits
1544+
1545+
A stateful session does not live forever, and one process does not hold an unlimited number of
1546+
them. Two settings control this. Both are keyword arguments on `FastMCP(...)`. `stateless_http=True`
1547+
keeps no sessions, so neither applies there.
1548+
1549+
| Setting | Default | What it does | What the client sees | Turn it off |
1550+
|---|---|---|---|---|
1551+
| `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` |
1552+
| `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` |
1553+
1554+
What counts as "in flight":
1555+
1556+
- An open `GET` stream. The SDK clients keep one open, so a connected client's session never
1557+
expires.
1558+
- A request that is still being answered. A tool call that runs longer than the timeout is not
1559+
interrupted, and the countdown only starts once it finishes.
1560+
- Nothing else. Between requests the clock runs. Any request on the session restarts it,
1561+
`ping` included. Once a session has expired, nothing revives it.
1562+
1563+
A client that ends its session with `DELETE` frees it immediately. So does a client whose
1564+
opening request was refused.
1565+
1566+
```python
1567+
mcp = FastMCP("My server", session_idle_timeout=None, max_sessions=50_000)
1568+
```
1569+
1570+
Both events show up in the server log. An expiry is `Session <id> idle timeout` at `INFO`. A
1571+
refused open is `Refusing to open a new session: <n> sessions are already open` at `WARNING`.
1572+
1573+
The limits are per process. With four workers the ceiling is four times `max_sessions`, and each
1574+
worker expires its own sessions.
1575+
15391576
#### CORS Configuration for Browser-Based Clients
15401577

15411578
If you'd like your server to be accessible by browser-based MCP clients, you'll need to configure CORS headers. The `Mcp-Session-Id` header must be exposed for browser clients to access it:

src/mcp/server/fastmcp/server.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@
6262
from mcp.server.sse import SseServerTransport
6363
from mcp.server.stdio import stdio_server
6464
from mcp.server.streamable_http import EventStore
65-
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
65+
from mcp.server.streamable_http_manager import (
66+
DEFAULT_MAX_SESSIONS,
67+
DEFAULT_SESSION_IDLE_TIMEOUT,
68+
StreamableHTTPSessionManager,
69+
)
6670
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings
6771
from mcp.shared.context import LifespanContextT, RequestContext, RequestT
6872
from mcp.types import Annotations, AnyFunction, ContentBlock, GetPromptResult, Icon, ToolAnnotations
@@ -108,6 +112,10 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
108112
"""Define if the server should create a new transport per request."""
109113
max_request_body_size: int
110114
"""Maximum request body size in bytes for the Streamable HTTP endpoint and the SSE message endpoint."""
115+
session_idle_timeout: float | None
116+
"""Seconds a stateful session may have no request in flight before it is closed. None disables expiry."""
117+
max_sessions: int | None
118+
"""Maximum number of concurrent stateful sessions. None removes the limit."""
111119

112120
# resource settings
113121
warn_on_duplicate_resources: bool
@@ -169,6 +177,8 @@ def __init__( # noqa: PLR0913
169177
json_response: bool = False,
170178
stateless_http: bool = False,
171179
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
180+
session_idle_timeout: float | None = DEFAULT_SESSION_IDLE_TIMEOUT,
181+
max_sessions: int | None = DEFAULT_MAX_SESSIONS,
172182
warn_on_duplicate_resources: bool = True,
173183
warn_on_duplicate_tools: bool = True,
174184
warn_on_duplicate_prompts: bool = True,
@@ -197,6 +207,8 @@ def __init__( # noqa: PLR0913
197207
json_response=json_response,
198208
stateless_http=stateless_http,
199209
max_request_body_size=max_request_body_size,
210+
session_idle_timeout=session_idle_timeout,
211+
max_sessions=max_sessions,
200212
warn_on_duplicate_resources=warn_on_duplicate_resources,
201213
warn_on_duplicate_tools=warn_on_duplicate_tools,
202214
warn_on_duplicate_prompts=warn_on_duplicate_prompts,
@@ -966,6 +978,8 @@ def streamable_http_app(self) -> Starlette:
966978
stateless=self.settings.stateless_http, # Use the stateless setting
967979
security_settings=self.settings.transport_security,
968980
max_request_body_size=self.settings.max_request_body_size,
981+
session_idle_timeout=self.settings.session_idle_timeout,
982+
max_sessions=self.settings.max_sessions,
969983
)
970984

971985
# Create the ASGI handler

src/mcp/server/streamable_http.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import json
1111
import logging
12+
import math
1213
import re
1314
from abc import ABC, abstractmethod
1415
from collections.abc import AsyncGenerator, Awaitable, Callable
@@ -150,6 +151,7 @@ def __init__(
150151
event_store: EventStore | None = None,
151152
security_settings: TransportSecuritySettings | None = None,
152153
retry_interval: int | None = None,
154+
idle_timeout: float | None = None,
153155
) -> None:
154156
"""
155157
Initialize a new StreamableHTTP server transport.
@@ -167,12 +169,22 @@ def __init__(
167169
retry field. When set, the server will send a retry field in
168170
SSE priming events to control client reconnection timing for
169171
polling behavior. Only used when event_store is provided.
172+
idle_timeout: Seconds the session may go without any request in flight before
173+
`idle_scope` is cancelled. A request being served or an open GET
174+
stream holds the session open; the countdown starts each time the
175+
last in-flight request completes. The host waits on `idle_scope`
176+
(available once `connect()` has been entered) and ends the session
177+
when it fires. Default is None: no `idle_scope`, the session never
178+
expires.
170179
171180
Raises:
172-
ValueError: If the session ID contains invalid characters.
181+
ValueError: If the session ID contains invalid characters, or if `idle_timeout`
182+
is not a positive, finite number.
173183
"""
174184
if mcp_session_id is not None and not SESSION_ID_PATTERN.fullmatch(mcp_session_id):
175185
raise ValueError("Session ID must only contain visible ASCII characters (0x21-0x7E)")
186+
if idle_timeout is not None and not (math.isfinite(idle_timeout) and idle_timeout > 0):
187+
raise ValueError("idle_timeout must be a positive, finite number of seconds")
176188

177189
self.mcp_session_id = mcp_session_id
178190
self.is_json_response_enabled = is_json_response_enabled
@@ -188,8 +200,11 @@ def __init__(
188200
] = {}
189201
self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[SSEEvent]] = {}
190202
self._terminated = False
191-
# Idle timeout cancel scope; managed by the session manager.
203+
self._idle_timeout = idle_timeout
204+
self._requests_in_flight = 0
192205
self.idle_scope: anyio.CancelScope | None = None
206+
"""Created when `connect()` is entered if `idle_timeout` is set; cancelled once no request has been in
207+
flight for `idle_timeout` seconds."""
193208

194209
@property
195210
def is_terminated(self) -> bool:
@@ -402,6 +417,32 @@ async def _clean_up_memory_streams(self, request_id: RequestId) -> None: # prag
402417

403418
async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None:
404419
"""Application entry point that handles all HTTP requests"""
420+
if self.idle_scope is None or self._idle_timeout is None:
421+
await self._handle_request(scope, receive, send)
422+
return
423+
424+
if self.idle_scope.cancel_called:
425+
# The idle period already ran out and the host is ending this
426+
# session: answer as terminated rather than dispatch into a
427+
# message loop that is going away.
428+
if not self._terminated:
429+
await self.terminate()
430+
await self._handle_request(scope, receive, send)
431+
return
432+
433+
# A request in flight (an open GET stream included) holds the session:
434+
# the idle countdown is suspended while any is being served and
435+
# restarts when the last one completes.
436+
self._requests_in_flight += 1
437+
self.idle_scope.deadline = math.inf
438+
try:
439+
await self._handle_request(scope, receive, send)
440+
finally:
441+
self._requests_in_flight -= 1
442+
if not self._requests_in_flight:
443+
self.idle_scope.deadline = anyio.current_time() + self._idle_timeout
444+
445+
async def _handle_request(self, scope: Scope, receive: Receive, send: Send) -> None:
405446
request = Request(scope, receive)
406447

407448
# Validate request headers for DNS rebinding protection
@@ -643,8 +684,9 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
643684
except Exception:
644685
logger.exception("SSE response error")
645686
await sse_stream_writer.aclose()
646-
await sse_stream_reader.aclose()
647687
await self._clean_up_memory_streams(request_id)
688+
finally:
689+
await sse_stream_reader.aclose()
648690

649691
except Exception as err:
650692
logger.exception("Error handling POST request")
@@ -747,9 +789,10 @@ async def standalone_sse_writer():
747789
await response(request.scope, request.receive, send)
748790
except Exception:
749791
logger.exception("Error in standalone SSE response")
792+
await self._clean_up_memory_streams(GET_STREAM_KEY)
793+
finally:
750794
await sse_stream_writer.aclose()
751795
await sse_stream_reader.aclose()
752-
await self._clean_up_memory_streams(GET_STREAM_KEY)
753796

754797
async def _handle_delete_request(self, request: Request, send: Send) -> None: # pragma: no cover
755798
"""Handle DELETE requests for explicit session termination."""
@@ -992,6 +1035,8 @@ async def connect(
9921035
Yields:
9931036
Tuple of (read_stream, write_stream) for bidirectional communication
9941037
"""
1038+
if self._idle_timeout is not None:
1039+
self.idle_scope = anyio.CancelScope()
9951040

9961041
# Create the memory streams for this connection
9971042

0 commit comments

Comments
 (0)