Skip to content

Commit 4f8e64a

Browse files
committed
fix(server): expose session_idle_timeout via streamable_http_app() and pause reaping during active requests
Closes #2455. session_idle_timeout existed only on StreamableHTTPSessionManager, so users of the high-level streamable_http_app() API had to drop down to manual session-manager wiring to configure idle reaping. Worse, the idle deadline was only pushed forward when a request *arrived*, so a request still being processed when the deadline passed could have its session reaped mid-flight. - Thread session_idle_timeout through Server.streamable_http_app() and MCPServer.streamable_http_app() into the session manager. - Track in-flight requests on the transport: mark_request_started() suspends idle reaping (deadline -> inf) while >=1 request is active; mark_request_finished() re-arms the deadline only when the last concurrent request completes, and is a no-op when no timeout is configured. - Wrap both stateful handle_request() call sites in start/finished so an active request is never counted as idle. - Move task_status.started() to after idle_scope is attached so the first request cannot race ahead of the scope it needs to suspend. Verification: 7 new tests (app forwarding + default None, transport suspend/resume across overlapping requests, no-timeout no-op, counter underflow guard); 249 streamable/idle/session tests pass; existing test_idle_session_is_reaped still green. ruff + pyright clean.
1 parent cf110e3 commit 4f8e64a

5 files changed

Lines changed: 112 additions & 6 deletions

File tree

src/mcp/server/lowlevel/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ def streamable_http_app(
440440
stateless_http: bool = False,
441441
event_store: EventStore | None = None,
442442
retry_interval: int | None = None,
443+
session_idle_timeout: float | None = None,
443444
transport_security: TransportSecuritySettings | None = None,
444445
host: str = "127.0.0.1",
445446
auth: AuthSettings | None = None,
@@ -461,6 +462,7 @@ def streamable_http_app(
461462
app=self,
462463
event_store=event_store,
463464
retry_interval=retry_interval,
465+
session_idle_timeout=session_idle_timeout,
464466
json_response=json_response,
465467
stateless=stateless_http,
466468
security_settings=transport_security,

src/mcp/server/mcpserver/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,7 @@ def streamable_http_app(
10461046
stateless_http: bool = False,
10471047
event_store: EventStore | None = None,
10481048
retry_interval: int | None = None,
1049+
session_idle_timeout: float | None = None,
10491050
transport_security: TransportSecuritySettings | None = None,
10501051
host: str = "127.0.0.1",
10511052
) -> Starlette:
@@ -1056,6 +1057,7 @@ def streamable_http_app(
10561057
stateless_http=stateless_http,
10571058
event_store=event_store,
10581059
retry_interval=retry_interval,
1060+
session_idle_timeout=session_idle_timeout,
10591061
transport_security=transport_security,
10601062
host=host,
10611063
auth=self.settings.auth,

src/mcp/server/streamable_http.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
import logging
10+
import math
1011
import re
1112
from abc import ABC, abstractmethod
1213
from collections.abc import AsyncGenerator, Awaitable, Callable
@@ -171,6 +172,7 @@ def __init__(
171172
] = {}
172173
self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[dict[str, str]]] = {}
173174
self._terminated = False
175+
self._active_request_count = 0
174176
# Idle timeout cancel scope; managed by the session manager.
175177
self.idle_scope: anyio.CancelScope | None = None
176178

@@ -179,6 +181,23 @@ def is_terminated(self) -> bool:
179181
"""Check if this transport has been explicitly terminated."""
180182
return self._terminated
181183

184+
def mark_request_started(self) -> None:
185+
"""Suspend idle reaping while at least one HTTP request is in flight."""
186+
self._active_request_count += 1
187+
if self.idle_scope is not None:
188+
self.idle_scope.deadline = math.inf
189+
190+
def mark_request_finished(self, idle_timeout_seconds: float | None) -> None:
191+
"""Resume idle reaping once the last in-flight request completes."""
192+
self._active_request_count = max(0, self._active_request_count - 1)
193+
if (
194+
idle_timeout_seconds is not None
195+
and self.idle_scope is not None
196+
and self._active_request_count == 0
197+
and not self._terminated
198+
):
199+
self.idle_scope.deadline = anyio.current_time() + idle_timeout_seconds
200+
182201
def close_sse_stream(self, request_id: RequestId) -> None:
183202
"""Close SSE connection for a specific request without terminating the stream.
184203

src/mcp/server/streamable_http_manager.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -222,10 +222,14 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S
222222
await response(scope, receive, send)
223223
return
224224
logger.debug("Session already exists, handling request directly")
225-
# Push back idle deadline on activity
226-
if transport.idle_scope is not None and self.session_idle_timeout is not None:
227-
transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout # pragma: no cover
228-
await transport.handle_request(scope, receive, send)
225+
# Suspend idle reaping for the duration of the request so an in-flight
226+
# request is never counted as an idle session; the deadline is pushed
227+
# forward when the last concurrent request completes.
228+
transport.mark_request_started()
229+
try:
230+
await transport.handle_request(scope, receive, send)
231+
finally:
232+
transport.mark_request_finished(self.session_idle_timeout)
229233
return
230234

231235
if request_mcp_session_id is None:
@@ -251,7 +255,6 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S
251255
async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED) -> None:
252256
async with http_transport.connect() as streams:
253257
read_stream, write_stream = streams
254-
task_status.started()
255258
try:
256259
# Use a cancel scope for idle timeout — when the
257260
# deadline passes the scope cancels app.run() and
@@ -262,6 +265,10 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
262265
idle_scope.deadline = anyio.current_time() + self.session_idle_timeout
263266
http_transport.idle_scope = idle_scope
264267

268+
# Signal readiness only after idle_scope is attached so the
269+
# first request (below) can suspend reaping without a race.
270+
task_status.started()
271+
265272
with idle_scope:
266273
await self.app.run(
267274
read_stream,
@@ -297,7 +304,11 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
297304
await self._task_group.start(run_server)
298305

299306
# Handle the HTTP request and return the response
300-
await http_transport.handle_request(scope, receive, send)
307+
http_transport.mark_request_started()
308+
try:
309+
await http_transport.handle_request(scope, receive, send)
310+
finally:
311+
http_transport.mark_request_finished(self.session_idle_timeout)
301312
else:
302313
# Unknown or expired session ID - return 404 per MCP spec
303314
# TODO: Align error code once spec clarifies

tests/server/test_streamable_http_manager.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,3 +601,75 @@ async def test_anonymous_session_accepts_anonymous_requests(
601601
session_id = await _open_session(manager, None)
602602

603603
assert await _request_session(manager, session_id, None) != 404
604+
605+
606+
# ---------------------------------------------------------------------------
607+
# session_idle_timeout: API exposure + in-flight request protection (#2455)
608+
# ---------------------------------------------------------------------------
609+
610+
611+
def test_streamable_http_app_forwards_session_idle_timeout():
612+
"""The high-level streamable_http_app() API exposes session_idle_timeout.
613+
614+
Previously the parameter only existed on StreamableHTTPSessionManager, forcing
615+
users to drop down to manual session-manager wiring to configure idle reaping.
616+
"""
617+
app = Server("test-idle-expose")
618+
app.streamable_http_app(session_idle_timeout=12.5)
619+
assert app.session_manager.session_idle_timeout == 12.5
620+
621+
622+
def test_streamable_http_app_session_idle_timeout_defaults_to_none():
623+
app = Server("test-idle-default")
624+
app.streamable_http_app()
625+
assert app.session_manager.session_idle_timeout is None
626+
627+
628+
@pytest.mark.anyio
629+
async def test_mark_request_suspends_and_resumes_idle_reaping():
630+
"""An in-flight request pushes the idle deadline to infinity until it completes.
631+
632+
Regression for #2455: a request actively being processed must not be counted as
633+
an idle session. The deadline is restored only when the last concurrent request
634+
finishes, and never moved if the session has no configured timeout.
635+
"""
636+
transport = StreamableHTTPServerTransport(mcp_session_id=None)
637+
scope = anyio.CancelScope()
638+
scope.deadline = 100.0
639+
transport.idle_scope = scope
640+
641+
# Two overlapping requests: deadline stays suspended until both finish.
642+
transport.mark_request_started()
643+
assert scope.deadline == float("inf")
644+
transport.mark_request_started()
645+
assert scope.deadline == float("inf")
646+
647+
transport.mark_request_finished(idle_timeout_seconds=30.0)
648+
# Still one request in flight -> still suspended.
649+
assert scope.deadline == float("inf")
650+
651+
transport.mark_request_finished(idle_timeout_seconds=30.0)
652+
# Last request done -> deadline re-armed into the future.
653+
assert scope.deadline != float("inf")
654+
assert scope.deadline > anyio.current_time()
655+
656+
657+
@pytest.mark.anyio
658+
async def test_mark_request_finished_is_noop_without_idle_timeout():
659+
"""When no idle timeout is configured the deadline is left untouched."""
660+
transport = StreamableHTTPServerTransport(mcp_session_id=None)
661+
scope = anyio.CancelScope() # default deadline is +inf
662+
transport.idle_scope = scope
663+
664+
transport.mark_request_started()
665+
transport.mark_request_finished(idle_timeout_seconds=None)
666+
assert scope.deadline == float("inf")
667+
668+
669+
@pytest.mark.anyio
670+
async def test_mark_request_finished_does_not_underflow():
671+
"""Unbalanced finishes never drive the active-request counter negative."""
672+
transport = StreamableHTTPServerTransport(mcp_session_id=None)
673+
transport.mark_request_finished(idle_timeout_seconds=30.0)
674+
transport.mark_request_finished(idle_timeout_seconds=30.0)
675+
assert transport._active_request_count == 0

0 commit comments

Comments
 (0)