55import contextlib
66import logging
77from collections .abc import AsyncIterator
8- from typing import TYPE_CHECKING , Any
8+ from typing import TYPE_CHECKING , Any , Final
99from uuid import uuid4
1010
1111import anyio
3434
3535logger = 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
3841class 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