@@ -39,23 +39,28 @@ class StreamableHTTPSessionManager:
3939 2. Resumability via an optional event store
4040 3. Connection management and lifecycle
4141 4. Request handling and transport setup
42+ 5. Idle session cleanup via optional timeout
4243
4344 Important: Only one StreamableHTTPSessionManager instance should be created
4445 per application. The instance cannot be reused after its run() context has
4546 completed. If you need to restart the manager, create a new instance.
4647
4748 Args:
4849 app: The MCP server instance
49- event_store: Optional event store for resumability support.
50- If provided, enables resumable connections where clients
51- can reconnect and receive missed events.
52- If None, sessions are still tracked but not resumable.
50+ event_store: Optional event store for resumability support. If provided, enables resumable connections
51+ where clients can reconnect and receive missed events. If None, sessions are still tracked but not
52+ resumable.
5353 json_response: Whether to use JSON responses instead of SSE streams
54- stateless: If True, creates a completely fresh transport for each request
55- with no session tracking or state persistence between requests.
54+ stateless: If True, creates a completely fresh transport for each request with no session tracking or
55+ state persistence between requests.
5656 security_settings: Optional transport security settings.
57- retry_interval: Retry interval in milliseconds to suggest to clients in SSE
58- retry field. Used for SSE polling behavior.
57+ retry_interval: Retry interval in milliseconds to suggest to clients in SSE retry field. Used for SSE
58+ polling behavior.
59+ session_idle_timeout: Optional idle timeout in seconds for stateful sessions. If set, sessions that
60+ receive no HTTP requests for this duration will be automatically terminated and removed. When
61+ retry_interval is also configured, ensure the idle timeout comfortably exceeds the retry interval to
62+ avoid reaping sessions during normal SSE polling gaps. Default is None (no timeout). A value of 1800
63+ (30 minutes) is recommended for most deployments.
5964 """
6065
6166 def __init__ (
@@ -66,13 +71,20 @@ def __init__(
6671 stateless : bool = False ,
6772 security_settings : TransportSecuritySettings | None = None ,
6873 retry_interval : int | None = None ,
74+ session_idle_timeout : float | None = None ,
6975 ):
76+ if session_idle_timeout is not None and session_idle_timeout <= 0 :
77+ raise ValueError ("session_idle_timeout must be a positive number of seconds" )
78+ if stateless and session_idle_timeout is not None :
79+ raise RuntimeError ("session_idle_timeout is not supported in stateless mode" )
80+
7081 self .app = app
7182 self .event_store = event_store
7283 self .json_response = json_response
7384 self .stateless = stateless
7485 self .security_settings = security_settings
7586 self .retry_interval = retry_interval
87+ self .session_idle_timeout = session_idle_timeout
7688
7789 # Session tracking (only used if not stateless)
7890 self ._session_creation_lock = anyio .Lock ()
@@ -184,6 +196,9 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S
184196 if request_mcp_session_id is not None and request_mcp_session_id in self ._server_instances :
185197 transport = self ._server_instances [request_mcp_session_id ]
186198 logger .debug ("Session already exists, handling request directly" )
199+ # Push back idle deadline on activity
200+ if transport .idle_scope is not None and self .session_idle_timeout is not None :
201+ transport .idle_scope .deadline = anyio .current_time () + self .session_idle_timeout # pragma: no cover
187202 await transport .handle_request (scope , receive , send )
188203 return
189204
@@ -210,16 +225,31 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
210225 read_stream , write_stream = streams
211226 task_status .started ()
212227 try :
213- await self .app .run (
214- read_stream ,
215- write_stream ,
216- self .app .create_initialization_options (),
217- stateless = False , # Stateful mode
218- )
228+ # Use a cancel scope for idle timeout — when the
229+ # deadline passes the scope cancels app.run() and
230+ # execution continues after the ``with`` block.
231+ # Incoming requests push the deadline forward.
232+ idle_scope = anyio .CancelScope ()
233+ if self .session_idle_timeout is not None :
234+ idle_scope .deadline = anyio .current_time () + self .session_idle_timeout
235+ http_transport .idle_scope = idle_scope
236+
237+ with idle_scope :
238+ await self .app .run (
239+ read_stream ,
240+ write_stream ,
241+ self .app .create_initialization_options (),
242+ stateless = False ,
243+ )
244+
245+ if idle_scope .cancelled_caught :
246+ assert http_transport .mcp_session_id is not None
247+ logger .info (f"Session { http_transport .mcp_session_id } idle timeout" )
248+ self ._server_instances .pop (http_transport .mcp_session_id , None )
249+ await http_transport .terminate ()
219250 except Exception :
220251 logger .exception (f"Session { http_transport .mcp_session_id } crashed" )
221252 finally :
222- # Only remove from instances if not terminated
223253 if ( # pragma: no branch
224254 http_transport .mcp_session_id
225255 and http_transport .mcp_session_id in self ._server_instances
0 commit comments