Skip to content

Commit 8375819

Browse files
Christian-Sidakclaude
andcommitted
fix: expose session_idle_timeout in streamable_http_app and prevent active request cancellation
- Add session_idle_timeout parameter to Server.streamable_http_app(), MCPServer.streamable_http_app(), and run_streamable_http_async() so users of the high-level API can configure idle session reaping without dropping down to manual StreamableHTTPSessionManager wiring. - Suspend the idle CancelScope deadline (set to math.inf) while a request is in-flight and reset it after, preventing a slow handler from being cancelled mid-execution by the idle timeout. Fixes #2455 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d5b9155 commit 8375819

4 files changed

Lines changed: 90 additions & 5 deletions

File tree

src/mcp/server/lowlevel/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,7 @@ def streamable_http_app(
567567
stateless_http: bool = False,
568568
event_store: EventStore | None = None,
569569
retry_interval: int | None = None,
570+
session_idle_timeout: float | None = None,
570571
transport_security: TransportSecuritySettings | None = None,
571572
host: str = "127.0.0.1",
572573
auth: AuthSettings | None = None,
@@ -588,6 +589,7 @@ def streamable_http_app(
588589
app=self,
589590
event_store=event_store,
590591
retry_interval=retry_interval,
592+
session_idle_timeout=session_idle_timeout,
591593
json_response=json_response,
592594
stateless=stateless_http,
593595
security_settings=transport_security,

src/mcp/server/mcpserver/server.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ def run(
270270
stateless_http: bool = ...,
271271
event_store: EventStore | None = ...,
272272
retry_interval: int | None = ...,
273+
session_idle_timeout: float | None = ...,
273274
transport_security: TransportSecuritySettings | None = ...,
274275
) -> None: ...
275276

@@ -889,6 +890,7 @@ async def run_streamable_http_async( # pragma: no cover
889890
stateless_http: bool = False,
890891
event_store: EventStore | None = None,
891892
retry_interval: int | None = None,
893+
session_idle_timeout: float | None = None,
892894
transport_security: TransportSecuritySettings | None = None,
893895
) -> None:
894896
"""Run the server using StreamableHTTP transport."""
@@ -900,6 +902,7 @@ async def run_streamable_http_async( # pragma: no cover
900902
stateless_http=stateless_http,
901903
event_store=event_store,
902904
retry_interval=retry_interval,
905+
session_idle_timeout=session_idle_timeout,
903906
transport_security=transport_security,
904907
host=host,
905908
)
@@ -1047,6 +1050,7 @@ def streamable_http_app(
10471050
stateless_http: bool = False,
10481051
event_store: EventStore | None = None,
10491052
retry_interval: int | None = None,
1053+
session_idle_timeout: float | None = None,
10501054
transport_security: TransportSecuritySettings | None = None,
10511055
host: str = "127.0.0.1",
10521056
) -> Starlette:
@@ -1057,6 +1061,7 @@ def streamable_http_app(
10571061
stateless_http=stateless_http,
10581062
event_store=event_store,
10591063
retry_interval=retry_interval,
1064+
session_idle_timeout=session_idle_timeout,
10601065
transport_security=transport_security,
10611066
host=host,
10621067
auth=self.settings.auth,

src/mcp/server/streamable_http_manager.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import contextlib
66
import logging
7+
import math
78
from collections.abc import AsyncIterator
89
from http import HTTPStatus
910
from typing import TYPE_CHECKING, Any
@@ -196,10 +197,15 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S
196197
if request_mcp_session_id is not None and request_mcp_session_id in self._server_instances:
197198
transport = self._server_instances[request_mcp_session_id]
198199
logger.debug("Session already exists, handling request directly")
199-
# Push back idle deadline on activity
200+
# Suspend idle deadline while the request is in-flight so a slow
201+
# handler cannot be cancelled mid-execution, then reset after.
200202
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
202-
await transport.handle_request(scope, receive, send)
203+
transport.idle_scope.deadline = math.inf
204+
try:
205+
await transport.handle_request(scope, receive, send)
206+
finally:
207+
if transport.idle_scope is not None and self.session_idle_timeout is not None:
208+
transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout
203209
return
204210

205211
if request_mcp_session_id is None:
@@ -266,8 +272,16 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
266272
# Start the server task
267273
await self._task_group.start(run_server)
268274

269-
# Handle the HTTP request and return the response
270-
await http_transport.handle_request(scope, receive, send)
275+
# Suspend idle deadline while the request is in-flight so a slow
276+
# handler cannot be cancelled mid-execution, then reset after.
277+
if http_transport.idle_scope is not None and self.session_idle_timeout is not None:
278+
http_transport.idle_scope.deadline = math.inf
279+
try:
280+
# Handle the HTTP request and return the response
281+
await http_transport.handle_request(scope, receive, send)
282+
finally:
283+
if http_transport.idle_scope is not None and self.session_idle_timeout is not None:
284+
http_transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout
271285
else:
272286
# Unknown or expired session ID - return 404 per MCP spec
273287
# TODO: Align error code once spec clarifies

tests/server/test_streamable_http_manager.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,67 @@ def test_session_idle_timeout_rejects_non_positive():
413413
def test_session_idle_timeout_rejects_stateless():
414414
with pytest.raises(RuntimeError, match="not supported in stateless"):
415415
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=30, stateless=True)
416+
417+
418+
def test_streamable_http_app_accepts_session_idle_timeout():
419+
"""session_idle_timeout is forwarded from streamable_http_app() to the session manager."""
420+
app = Server("test-passthrough")
421+
starlette_app = app.streamable_http_app(host="testserver", session_idle_timeout=30.0)
422+
assert starlette_app is not None
423+
assert app._session_manager is not None
424+
assert app._session_manager.session_idle_timeout == 30.0
425+
426+
427+
@pytest.mark.anyio
428+
async def test_active_request_not_cancelled_by_idle_timeout():
429+
"""A request whose handler takes longer than session_idle_timeout must still complete."""
430+
host = "testserver"
431+
IDLE_TIMEOUT = 0.05 # 50 ms
432+
HANDLER_SLEEP = 0.15 # 150 ms — longer than the timeout
433+
434+
tool_completed = False
435+
436+
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
437+
nonlocal tool_completed
438+
await anyio.sleep(HANDLER_SLEEP)
439+
tool_completed = True
440+
return ListToolsResult(tools=[])
441+
442+
app = Server("test-no-cancel", on_list_tools=handle_list_tools)
443+
mcp_app = app.streamable_http_app(host=host, session_idle_timeout=IDLE_TIMEOUT)
444+
async with (
445+
mcp_app.router.lifespan_context(mcp_app),
446+
httpx.ASGITransport(mcp_app) as transport,
447+
httpx.AsyncClient(transport=transport) as http_client,
448+
Client(streamable_http_client(f"http://{host}/mcp", http_client=http_client)) as client,
449+
):
450+
result = await client.list_tools()
451+
assert tool_completed, "Handler should have completed without being cancelled"
452+
assert result.tools == []
453+
454+
455+
@pytest.mark.anyio
456+
async def test_idle_session_reaped_after_request_completes():
457+
"""After the last request finishes, idle reaping still works normally."""
458+
host = "testserver"
459+
IDLE_TIMEOUT = 0.05 # 50 ms
460+
461+
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
462+
return ListToolsResult(tools=[])
463+
464+
app = Server("test-reap-after-request", on_list_tools=handle_list_tools)
465+
mcp_app = app.streamable_http_app(host=host, session_idle_timeout=IDLE_TIMEOUT)
466+
async with (
467+
mcp_app.router.lifespan_context(mcp_app),
468+
httpx.ASGITransport(mcp_app) as transport,
469+
httpx.AsyncClient(transport=transport) as http_client,
470+
Client(streamable_http_client(f"http://{host}/mcp", http_client=http_client)) as client,
471+
):
472+
await client.list_tools()
473+
# Wait well past the idle timeout
474+
await anyio.sleep(IDLE_TIMEOUT * 4)
475+
session_manager = app._session_manager
476+
assert session_manager is not None
477+
assert len(session_manager._server_instances) == 0, (
478+
"Session should have been reaped after idle timeout elapsed post-request"
479+
)

0 commit comments

Comments
 (0)