Skip to content

Commit 3bdc230

Browse files
Clean up idle timeout implementation
- Remove unrelated blank line between logger.info and try block - Only create CancelScope when session_idle_timeout is set, keeping the no-timeout path identical to the original code - Add docstring to _effective_idle_timeout explaining the 3x retry_interval multiplier rationale
1 parent 2c87708 commit 3bdc230

File tree

1 file changed

+31
-16
lines changed

1 file changed

+31
-16
lines changed

src/mcp/server/streamable_http_manager.py

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)