diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 59de0ace4..7765739ce 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -567,6 +567,7 @@ def streamable_http_app( stateless_http: bool = False, event_store: EventStore | None = None, retry_interval: int | None = None, + session_idle_timeout: float | None = None, transport_security: TransportSecuritySettings | None = None, host: str = "127.0.0.1", auth: AuthSettings | None = None, @@ -588,6 +589,7 @@ def streamable_http_app( app=self, event_store=event_store, retry_interval=retry_interval, + session_idle_timeout=session_idle_timeout, json_response=json_response, stateless=stateless_http, security_settings=transport_security, diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 6f9bb0e28..dac29c079 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -270,6 +270,7 @@ def run( stateless_http: bool = ..., event_store: EventStore | None = ..., retry_interval: int | None = ..., + session_idle_timeout: float | None = ..., transport_security: TransportSecuritySettings | None = ..., ) -> None: ... @@ -889,6 +890,7 @@ async def run_streamable_http_async( # pragma: no cover stateless_http: bool = False, event_store: EventStore | None = None, retry_interval: int | None = None, + session_idle_timeout: float | None = None, transport_security: TransportSecuritySettings | None = None, ) -> None: """Run the server using StreamableHTTP transport.""" @@ -900,6 +902,7 @@ async def run_streamable_http_async( # pragma: no cover stateless_http=stateless_http, event_store=event_store, retry_interval=retry_interval, + session_idle_timeout=session_idle_timeout, transport_security=transport_security, host=host, ) @@ -1047,6 +1050,7 @@ def streamable_http_app( stateless_http: bool = False, event_store: EventStore | None = None, retry_interval: int | None = None, + session_idle_timeout: float | None = None, transport_security: TransportSecuritySettings | None = None, host: str = "127.0.0.1", ) -> Starlette: @@ -1057,6 +1061,7 @@ def streamable_http_app( stateless_http=stateless_http, event_store=event_store, retry_interval=retry_interval, + session_idle_timeout=session_idle_timeout, transport_security=transport_security, host=host, auth=self.settings.auth, diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index c25314eab..3f6c0229a 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -4,6 +4,7 @@ import contextlib import logging +import math from collections.abc import AsyncIterator from http import HTTPStatus from typing import TYPE_CHECKING, Any @@ -196,10 +197,15 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S if request_mcp_session_id is not None and request_mcp_session_id in self._server_instances: transport = self._server_instances[request_mcp_session_id] logger.debug("Session already exists, handling request directly") - # Push back idle deadline on activity + # Suspend idle deadline while the request is in-flight so a slow + # handler cannot be cancelled mid-execution, then reset after. if transport.idle_scope is not None and self.session_idle_timeout is not None: - transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout # pragma: no cover - await transport.handle_request(scope, receive, send) + transport.idle_scope.deadline = math.inf + try: + await transport.handle_request(scope, receive, send) + finally: + if transport.idle_scope is not None and self.session_idle_timeout is not None: + transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout return if request_mcp_session_id is None: @@ -266,8 +272,16 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE # Start the server task await self._task_group.start(run_server) - # Handle the HTTP request and return the response - await http_transport.handle_request(scope, receive, send) + # Suspend idle deadline while the request is in-flight so a slow + # handler cannot be cancelled mid-execution, then reset after. + if http_transport.idle_scope is not None and self.session_idle_timeout is not None: + http_transport.idle_scope.deadline = math.inf + try: + # Handle the HTTP request and return the response + await http_transport.handle_request(scope, receive, send) + finally: + if http_transport.idle_scope is not None and self.session_idle_timeout is not None: + http_transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout else: # Unknown or expired session ID - return 404 per MCP spec # TODO: Align error code once spec clarifies diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index 47cfbf14a..b4eaf5956 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -413,3 +413,67 @@ def test_session_idle_timeout_rejects_non_positive(): def test_session_idle_timeout_rejects_stateless(): with pytest.raises(RuntimeError, match="not supported in stateless"): StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=30, stateless=True) + + +def test_streamable_http_app_accepts_session_idle_timeout(): + """session_idle_timeout is forwarded from streamable_http_app() to the session manager.""" + app = Server("test-passthrough") + starlette_app = app.streamable_http_app(host="testserver", session_idle_timeout=30.0) + assert starlette_app is not None + assert app._session_manager is not None + assert app._session_manager.session_idle_timeout == 30.0 + + +@pytest.mark.anyio +async def test_active_request_not_cancelled_by_idle_timeout(): + """A request whose handler takes longer than session_idle_timeout must still complete.""" + host = "testserver" + IDLE_TIMEOUT = 0.05 # 50 ms + HANDLER_SLEEP = 0.15 # 150 ms — longer than the timeout + + tool_completed = False + + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + nonlocal tool_completed + await anyio.sleep(HANDLER_SLEEP) + tool_completed = True + return ListToolsResult(tools=[]) + + app = Server("test-no-cancel", on_list_tools=handle_list_tools) + mcp_app = app.streamable_http_app(host=host, session_idle_timeout=IDLE_TIMEOUT) + async with ( + mcp_app.router.lifespan_context(mcp_app), + httpx.ASGITransport(mcp_app) as transport, + httpx.AsyncClient(transport=transport) as http_client, + Client(streamable_http_client(f"http://{host}/mcp", http_client=http_client)) as client, + ): + result = await client.list_tools() + assert tool_completed, "Handler should have completed without being cancelled" + assert result.tools == [] + + +@pytest.mark.anyio +async def test_idle_session_reaped_after_request_completes(): + """After the last request finishes, idle reaping still works normally.""" + host = "testserver" + IDLE_TIMEOUT = 0.05 # 50 ms + + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[]) + + app = Server("test-reap-after-request", on_list_tools=handle_list_tools) + mcp_app = app.streamable_http_app(host=host, session_idle_timeout=IDLE_TIMEOUT) + async with ( + mcp_app.router.lifespan_context(mcp_app), + httpx.ASGITransport(mcp_app) as transport, + httpx.AsyncClient(transport=transport) as http_client, + Client(streamable_http_client(f"http://{host}/mcp", http_client=http_client)) as client, + ): + await client.list_tools() + # Wait well past the idle timeout + await anyio.sleep(IDLE_TIMEOUT * 4) + session_manager = app._session_manager + assert session_manager is not None + assert len(session_manager._server_instances) == 0, ( + "Session should have been reaped after idle timeout elapsed post-request" + )