44
55import contextlib
66import logging
7+ import time
78from collections .abc import AsyncIterator
89from http import HTTPStatus
910from typing import Any
@@ -51,6 +52,14 @@ class StreamableHTTPSessionManager:
5152 json_response: Whether to use JSON responses instead of SSE streams
5253 stateless: If True, creates a completely fresh transport for each request
5354 with no session tracking or state persistence between requests.
55+ security_settings: Optional security settings for DNS rebinding protection
56+ session_idle_timeout: Maximum idle time in seconds before a session is eligible
57+ for cleanup. Default is 1800 seconds (30 minutes).
58+ cleanup_check_interval: Interval in seconds between cleanup checks.
59+ Default is 300 seconds (5 minutes).
60+ max_sessions_before_cleanup: Threshold number of sessions before idle cleanup
61+ is activated. Default is 10000. Cleanup only runs
62+ when the session count exceeds this threshold.
5463 """
5564
5665 def __init__ (
@@ -60,16 +69,23 @@ def __init__(
6069 json_response : bool = False ,
6170 stateless : bool = False ,
6271 security_settings : TransportSecuritySettings | None = None ,
72+ session_idle_timeout : float = 1800 , # 30 minutes default
73+ cleanup_check_interval : float = 300 , # 5 minutes default
74+ max_sessions_before_cleanup : int = 10000 , # Threshold to activate cleanup
6375 ):
6476 self .app = app
6577 self .event_store = event_store
6678 self .json_response = json_response
6779 self .stateless = stateless
6880 self .security_settings = security_settings
81+ self .session_idle_timeout = session_idle_timeout
82+ self .cleanup_check_interval = cleanup_check_interval
83+ self .max_sessions_before_cleanup = max_sessions_before_cleanup
6984
7085 # Session tracking (only used if not stateless)
7186 self ._session_creation_lock = anyio .Lock ()
7287 self ._server_instances : dict [str , StreamableHTTPServerTransport ] = {}
88+ self ._session_last_activity : dict [str , float ] = {}
7389
7490 # The task group will be set during lifespan
7591 self ._task_group = None
@@ -108,15 +124,21 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]:
108124 # Store the task group for later use
109125 self ._task_group = tg
110126 logger .info ("StreamableHTTP session manager started" )
127+
128+ # Start the cleanup task if not in stateless mode
129+ if not self .stateless :
130+ tg .start_soon (self ._run_session_cleanup )
131+
111132 try :
112133 yield # Let the application run
113134 finally :
114135 logger .info ("StreamableHTTP session manager shutting down" )
115- # Cancel task group to stop all spawned tasks
136+ # Cancel task group to stop all spawned tasks (this will also stop cleanup task)
116137 tg .cancel_scope .cancel ()
117138 self ._task_group = None
118- # Clear any remaining server instances
139+ # Clear any remaining server instances and tracking
119140 self ._server_instances .clear ()
141+ self ._session_last_activity .clear ()
120142
121143 async def handle_request (
122144 self ,
@@ -213,6 +235,9 @@ async def _handle_stateful_request(
213235 if request_mcp_session_id is not None and request_mcp_session_id in self ._server_instances :
214236 transport = self ._server_instances [request_mcp_session_id ]
215237 logger .debug ("Session already exists, handling request directly" )
238+ # Update last activity time for this session
239+ if request_mcp_session_id :
240+ self ._session_last_activity [request_mcp_session_id ] = time .time ()
216241 await transport .handle_request (scope , receive , send )
217242 return
218243
@@ -230,6 +255,8 @@ async def _handle_stateful_request(
230255
231256 assert http_transport .mcp_session_id is not None
232257 self ._server_instances [http_transport .mcp_session_id ] = http_transport
258+ # Track initial activity time for new session
259+ self ._session_last_activity [http_transport .mcp_session_id ] = time .time ()
233260 logger .info (f"Created new transport with session ID: { new_session_id } " )
234261
235262 # Define the server runner
@@ -262,6 +289,8 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
262289 "active instances."
263290 )
264291 del self ._server_instances [http_transport .mcp_session_id ]
292+ # Also remove from activity tracking
293+ self ._session_last_activity .pop (http_transport .mcp_session_id , None )
265294
266295 # Assert task group is not None for type checking
267296 assert self ._task_group is not None
@@ -277,3 +306,63 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
277306 status_code = HTTPStatus .BAD_REQUEST ,
278307 )
279308 await response (scope , receive , send )
309+
310+ async def _run_session_cleanup (self ) -> None :
311+ """
312+ Background task that periodically cleans up idle sessions.
313+ Only performs cleanup when the number of sessions exceeds the threshold.
314+ """
315+ logger .info (
316+ f"Session cleanup task started (threshold: { self .max_sessions_before_cleanup } sessions, "
317+ f"idle timeout: { self .session_idle_timeout } s)"
318+ )
319+ try :
320+ while True :
321+ await anyio .sleep (self .cleanup_check_interval )
322+
323+ # Only perform cleanup if we're above the threshold
324+ session_count = len (self ._server_instances )
325+ if session_count <= self .max_sessions_before_cleanup :
326+ logger .debug (
327+ f"Session count ({ session_count } ) below threshold "
328+ f"({ self .max_sessions_before_cleanup } ), skipping cleanup"
329+ )
330+ continue
331+
332+ logger .info (f"Session count ({ session_count } ) exceeds threshold, performing idle session cleanup" )
333+
334+ current_time = time .time ()
335+ sessions_to_cleanup : list [tuple [str , float ]] = []
336+
337+ # Identify sessions that have been idle too long
338+ for session_id , last_activity in list (self ._session_last_activity .items ()):
339+ idle_time = current_time - last_activity
340+ if idle_time > self .session_idle_timeout :
341+ sessions_to_cleanup .append ((session_id , idle_time ))
342+
343+ # Clean up identified sessions
344+ for session_id , idle_time in sessions_to_cleanup :
345+ try :
346+ if session_id in self ._server_instances :
347+ transport = self ._server_instances [session_id ]
348+ logger .info (f"Cleaning up idle session { session_id } " )
349+ # Terminate the transport to properly close resources
350+ await transport .terminate ()
351+ # Remove from tracking dictionaries
352+ del self ._server_instances [session_id ]
353+ self ._session_last_activity .pop (session_id , None )
354+ except Exception :
355+ logger .exception (f"Error cleaning up session { session_id } " )
356+
357+ if sessions_to_cleanup :
358+ logger .info (
359+ f"Cleaned up { len (sessions_to_cleanup )} idle sessions, "
360+ f"{ len (self ._server_instances )} sessions remaining"
361+ )
362+
363+ except anyio .get_cancelled_exc_class ():
364+ logger .info ("Session cleanup task cancelled" )
365+ raise
366+ except Exception :
367+ logger .exception ("Unexpected error in session cleanup task - cleanup task terminated" )
368+ # Don't re-raise - let the task end gracefully without crashing the server
0 commit comments