@@ -131,7 +131,6 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]:
131131 # Store the task group for later use
132132 self ._task_group = tg
133133 logger .info ("StreamableHTTP session manager started" )
134-
135134 try :
136135 yield # Let the application run
137136 finally :
@@ -266,30 +265,39 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
266265 read_stream , write_stream = streams
267266 task_status .started ()
268267 try :
269- # Use a cancel scope for idle timeout — when the
270- # deadline passes the scope cancels app.run() and
271- # execution continues after the ``with`` block.
272- # Incoming requests push the deadline forward.
273- idle_scope = anyio .CancelScope ()
274268 if self .session_idle_timeout is not None :
269+ # Use a cancel scope for idle timeout — when
270+ # the deadline passes the scope cancels
271+ # app.run() and execution continues after the
272+ # ``with`` block. Incoming requests push the
273+ # deadline forward.
275274 timeout = self ._effective_idle_timeout ()
276- idle_scope .deadline = anyio .current_time () + timeout
275+ idle_scope = anyio .CancelScope (
276+ deadline = anyio .current_time () + timeout ,
277+ )
277278 http_transport .idle_scope = idle_scope
278279
279- with idle_scope :
280+ with idle_scope :
281+ await self .app .run (
282+ read_stream ,
283+ write_stream ,
284+ self .app .create_initialization_options (),
285+ stateless = False ,
286+ )
287+
288+ if idle_scope .cancelled_caught :
289+ session_id = http_transport .mcp_session_id
290+ logger .info (f"Session { session_id } idle timeout" )
291+ if session_id is not None : # pragma: no branch
292+ self ._server_instances .pop (session_id , None )
293+ await http_transport .terminate ()
294+ else :
280295 await self .app .run (
281296 read_stream ,
282297 write_stream ,
283298 self .app .create_initialization_options (),
284299 stateless = False ,
285300 )
286-
287- if idle_scope .cancelled_caught :
288- session_id = http_transport .mcp_session_id
289- logger .info (f"Session { session_id } idle timeout" )
290- if session_id is not None : # pragma: no branch
291- self ._server_instances .pop (session_id , None )
292- await http_transport .terminate ()
293301 except Exception as e :
294302 logger .error (
295303 f"Session { http_transport .mcp_session_id } crashed: { e } " ,
@@ -335,7 +343,14 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
335343 await response (scope , receive , send )
336344
337345 def _effective_idle_timeout (self ) -> float :
338- """Compute the effective idle timeout, accounting for retry_interval."""
346+ """Compute the effective idle timeout, accounting for retry_interval.
347+
348+ When SSE retry_interval is configured, clients periodically reconnect
349+ to resume the event stream. A gap of up to ``retry_interval`` between
350+ connections is normal, not a sign of idleness. We use a 3x multiplier
351+ to tolerate up to two consecutive missed polls (network jitter, slow
352+ client) before considering the session idle.
353+ """
339354 assert self .session_idle_timeout is not None
340355 timeout = self .session_idle_timeout
341356 if self .retry_interval is not None :
0 commit comments