Skip to content

Commit 1e7fb45

Browse files
committed
Expire idle Streamable HTTP sessions by default
`session_idle_timeout` was opt-in (default None), so at stock settings a stateful session that its client never deleted stayed registered, with its server task and streams, until the process exited. The docstring already recommended 1800 seconds; make that the default (DEFAULT_SESSION_IDLE_TIMEOUT) so sessions nobody is using are reclaimed after 30 minutes. `None` keeps the previous behaviour. "Idle" is now measured from the moment the session's last in-flight request completes rather than from the arrival of the last request: the transport takes an `idle_timeout` and owns the countdown, holding it while any request (an open GET stream included) is being served and restarting it when the last one finishes. A connected client, or a call that runs longer than the timeout, therefore never loses its session; a client that goes quiet with no stream open gets 404 on its next request and initializes again, as the spec describes. The timeout is simply unused in stateless mode, which keeps no sessions, so constructing a stateless manager with a timeout no longer raises.
1 parent cbdbced commit 1e7fb45

4 files changed

Lines changed: 247 additions & 33 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 37 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 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 idle_timeout <= 0:
207+
raise ValueError("idle_timeout must be a positive 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,23 @@ 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+
# A request in flight (an open GET stream included) holds the session:
481+
# the idle countdown is suspended while any is being served and
482+
# restarts when the last one completes.
483+
self._requests_in_flight += 1
484+
self.idle_scope.deadline = math.inf
485+
try:
486+
await self._handle_request(scope, receive, send)
487+
finally:
488+
self._requests_in_flight -= 1
489+
if not self._requests_in_flight:
490+
self.idle_scope.deadline = anyio.current_time() + self._idle_timeout
491+
492+
async def _handle_request(self, scope: Scope, receive: Receive, send: Send) -> None:
461493
request = Request(scope, receive)
462494

463495
# Validate request headers for DNS rebinding protection
@@ -793,7 +825,7 @@ async def _handle_delete_request(self, request: Request, send: Send) -> None:
793825
await response(request.scope, request.receive, send)
794826
return
795827

796-
if not await self._validate_request_headers(request, send): # pragma: no cover
828+
if not await self._validate_request_headers(request, send):
797829
return
798830

799831
await self.terminate()
@@ -995,6 +1027,8 @@ async def connect(
9951027
Yields:
9961028
Tuple of (read_stream, write_stream) for bidirectional communication
9971029
"""
1030+
if self._idle_timeout is not None:
1031+
self.idle_scope = anyio.CancelScope()
9981032

9991033
# Create the memory streams for this connection
10001034

src/mcp/server/streamable_http_manager.py

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import contextlib
66
import logging
77
from collections.abc import AsyncIterator
8-
from typing import TYPE_CHECKING, Any
8+
from typing import TYPE_CHECKING, Any, Final
99
from uuid import uuid4
1010

1111
import anyio
@@ -34,6 +34,9 @@
3434

3535
logger = logging.getLogger(__name__)
3636

37+
DEFAULT_SESSION_IDLE_TIMEOUT: Final = 30 * 60
38+
"""Default idle period in seconds after which a stateful Streamable HTTP session is closed (30 minutes)."""
39+
3740

3841
class StreamableHTTPSessionManager:
3942
"""Manages StreamableHTTP sessions with optional resumability via event store.
@@ -45,7 +48,7 @@ class StreamableHTTPSessionManager:
4548
2. Resumability via an optional event store
4649
3. Connection management and lifecycle
4750
4. Request handling and transport setup
48-
5. Idle session cleanup via optional timeout
51+
5. Idle session cleanup
4952
5053
Important: Only one StreamableHTTPSessionManager instance should be created
5154
per application. The instance cannot be reused after its run() context has
@@ -62,11 +65,12 @@ class StreamableHTTPSessionManager:
6265
security_settings: Optional transport security settings.
6366
retry_interval: Retry interval in milliseconds to suggest to clients in SSE retry field. Used for SSE
6467
polling behavior.
65-
session_idle_timeout: Optional idle timeout in seconds for stateful sessions. If set, sessions that
66-
receive no HTTP requests for this duration will be automatically terminated and removed. When
67-
retry_interval is also configured, ensure the idle timeout comfortably exceeds the retry interval to
68-
avoid reaping sessions during normal SSE polling gaps. Default is None (no timeout). A value of 1800
69-
(30 minutes) is recommended for most deployments.
68+
session_idle_timeout: Idle timeout in seconds for stateful sessions. A session that has had no HTTP
69+
request in flight for this long (no request being served, no open GET stream) is terminated and
70+
removed; its ID then answers 404 and the client has to initialize a new session. When retry_interval
71+
is also configured, ensure the idle timeout comfortably exceeds the retry interval to avoid reaping
72+
sessions during normal SSE polling gaps. Defaults to 1800 (30 minutes); None disables the timeout so
73+
sessions live until the client deletes them or the manager shuts down. Unused in stateless mode.
7074
max_request_body_size: Maximum size in bytes for Streamable HTTP request bodies. Requests that
7175
exceed this limit receive a 413 response before parsing or session creation. Defaults to 4 MiB.
7276
"""
@@ -79,13 +83,11 @@ def __init__(
7983
stateless: bool = False,
8084
security_settings: TransportSecuritySettings | None = None,
8185
retry_interval: int | None = None,
82-
session_idle_timeout: float | None = None,
86+
session_idle_timeout: float | None = DEFAULT_SESSION_IDLE_TIMEOUT,
8387
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
8488
):
8589
if session_idle_timeout is not None and session_idle_timeout <= 0:
8690
raise ValueError("session_idle_timeout must be a positive number of seconds")
87-
if stateless and session_idle_timeout is not None:
88-
raise RuntimeError("session_idle_timeout is not supported in stateless mode")
8991
if max_request_body_size <= 0:
9092
raise ValueError("max_request_body_size must be a positive number of bytes")
9193

@@ -274,9 +276,6 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S
274276
await response(scope, receive, send)
275277
return
276278
logger.debug("Session already exists, handling request directly")
277-
# Push back idle deadline on activity
278-
if transport.idle_scope is not None and self.session_idle_timeout is not None:
279-
transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout # pragma: no cover
280279
await transport.handle_request(scope, receive, send)
281280
if transport.is_terminated:
282281
# The client ended the session (DELETE): forget it now rather
@@ -295,6 +294,7 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S
295294
event_store=self.event_store, # May be None (no resumability)
296295
security_settings=self.security_settings,
297296
retry_interval=self.retry_interval,
297+
idle_timeout=self.session_idle_timeout,
298298
)
299299

300300
assert http_transport.mcp_session_id is not None
@@ -309,15 +309,13 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
309309
read_stream, write_stream = streams
310310
task_status.started()
311311
try:
312-
# Use a cancel scope for idle timeout — when the
313-
# deadline passes the scope cancels the loop and
314-
# execution continues after the ``with`` block.
315-
# Incoming requests push the deadline forward.
316-
idle_scope = anyio.CancelScope()
317-
if self.session_idle_timeout is not None:
318-
idle_scope.deadline = anyio.current_time() + self.session_idle_timeout
319-
http_transport.idle_scope = idle_scope
320-
312+
# The transport cancels its idle scope once no request
313+
# has been in flight for `session_idle_timeout`; that
314+
# ends the loop and execution continues after the
315+
# `with` block. Without a timeout there is nothing to fire.
316+
idle_scope = http_transport.idle_scope
317+
if idle_scope is None:
318+
idle_scope = anyio.CancelScope()
321319
with idle_scope:
322320
# Drive via `serve_loop` (not `Server.run()`) so the
323321
# manager's already-entered lifespan is reused

0 commit comments

Comments
 (0)