diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index c29c39ec8..6cc30e782 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -3,7 +3,7 @@ from collections.abc import AsyncGenerator, Callable, Generator from contextlib import asynccontextmanager, contextmanager from contextvars import ContextVar -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from mcp.server.auth.routes import build_resource_metadata_url from mcp.server.lowlevel.server import LifespanResultT @@ -271,6 +271,7 @@ def create_streamable_http_app( auth: AuthProvider | None = None, json_response: bool = False, stateless_http: bool = False, + session_idle_timeout: float | None = None, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None, @@ -287,6 +288,11 @@ def create_streamable_http_app( auth: Optional authentication provider (AuthProvider) json_response: Whether to use JSON response format stateless_http: Whether to use stateless mode (new transport per request) + session_idle_timeout: Optional timeout in seconds for idle sessions. + When set, sessions that receive no requests within this duration are + automatically cleaned up, preventing unbounded memory growth from + abandoned sessions. Only applies when stateless_http is False. + Requires mcp SDK >= 1.27.0. debug: Whether to enable debug mode routes: Optional list of custom routes middleware: Optional list of middleware @@ -298,13 +304,29 @@ def create_streamable_http_app( server_middleware: list[Middleware] = [] # Create session manager using the provided event store - session_manager = StreamableHTTPSessionManager( - app=server._mcp_server, - event_store=event_store, - retry_interval=retry_interval, - json_response=json_response, - stateless=stateless_http, - ) + session_manager_kwargs: dict[str, Any] = { + "app": server._mcp_server, + "event_store": event_store, + "retry_interval": retry_interval, + "json_response": json_response, + "stateless": stateless_http, + } + + # Pass session_idle_timeout if the MCP SDK supports it (>= 1.27.0) + if session_idle_timeout is not None: + import inspect + + sig = inspect.signature(StreamableHTTPSessionManager.__init__) + if "session_idle_timeout" in sig.parameters: + session_manager_kwargs["session_idle_timeout"] = session_idle_timeout + else: + logger.warning( + "session_idle_timeout was specified but the installed version of " + "the MCP SDK does not support it. Upgrade to mcp >= 1.27.0 to " + "enable idle session cleanup." + ) + + session_manager = StreamableHTTPSessionManager(**session_manager_kwargs) # Create the ASGI app wrapper streamable_http_app = StreamableHTTPASGIApp(session_manager) diff --git a/src/fastmcp/server/mixins/transport.py b/src/fastmcp/server/mixins/transport.py index 833b5e385..fcaba8367 100644 --- a/src/fastmcp/server/mixins/transport.py +++ b/src/fastmcp/server/mixins/transport.py @@ -210,6 +210,7 @@ async def run_http_async( json_response: bool | None = None, stateless_http: bool | None = None, stateless: bool | None = None, + session_idle_timeout: float | None = None, ) -> None: """Run the server using HTTP transport. @@ -224,6 +225,9 @@ async def run_http_async( json_response: Whether to use JSON response format (defaults to settings.json_response) stateless_http: Whether to use stateless HTTP (defaults to settings.stateless_http) stateless: Alias for stateless_http for CLI consistency + session_idle_timeout: Optional timeout in seconds for idle sessions. + When set, sessions that receive no requests within this duration + are automatically cleaned up. Only used when stateless_http is False. """ # Allow stateless as alias for stateless_http if stateless is not None and stateless_http is None: @@ -247,6 +251,7 @@ async def run_http_async( middleware=middleware, json_response=json_response, stateless_http=stateless_http, + session_idle_timeout=session_idle_timeout, ) # Display server banner @@ -282,6 +287,7 @@ def http_app( middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None, + session_idle_timeout: float | None = None, transport: Literal["http", "streamable-http", "sse"] = "http", event_store: EventStore | None = None, retry_interval: int | None = None, @@ -293,6 +299,10 @@ def http_app( middleware: A list of middleware to apply to the app json_response: Whether to use JSON response format stateless_http: Whether to use stateless mode (new transport per request) + session_idle_timeout: Optional timeout in seconds for idle sessions. + When set, sessions that receive no requests within this duration + are automatically cleaned up. Only used with streamable-http + transport when stateless_http is False. transport: Transport protocol to use - "http", "streamable-http", or "sse" event_store: Optional event store for SSE polling/resumability. When set, enables clients to reconnect and resume receiving events after @@ -323,6 +333,11 @@ def http_app( if stateless_http is not None else fastmcp.settings.stateless_http ), + session_idle_timeout=( + session_idle_timeout + if session_idle_timeout is not None + else fastmcp.settings.session_idle_timeout + ), debug=fastmcp.settings.debug, middleware=middleware, ) diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index bc6d22065..161c2c1fd 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -280,6 +280,9 @@ def normalize_log_level(cls, v): stateless_http: bool = ( False # If True, uses true stateless mode (new transport per request) ) + session_idle_timeout: float | None = ( + None # Seconds before idle sessions are cleaned up. None means no timeout. + ) mounted_components_raise_on_load_error: Annotated[ bool, diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 388eaaa9b..25497344e 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -1,4 +1,5 @@ import asyncio +import inspect import json import sys from contextlib import suppress @@ -6,6 +7,7 @@ import pytest from mcp import McpError +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.types import TextResourceContents from fastmcp import Context @@ -289,3 +291,124 @@ async def test_timeout_tool_call_overrides_client_timeout( ) as client: with pytest.raises(McpError): await client.call_tool("sleep", {"seconds": 0.2}, timeout=0.1) + + +# Check if the installed MCP SDK supports session_idle_timeout +_sdk_supports_idle_timeout = ( + "session_idle_timeout" + in inspect.signature(StreamableHTTPSessionManager.__init__).parameters +) + + +class TestSessionIdleTimeout: + async def test_session_idle_timeout_parameter_threads_through(self): + """session_idle_timeout should be accepted by http_app() without errors.""" + server = create_test_server() + # Should not raise regardless of SDK support + app = server.http_app(session_idle_timeout=30.0) + assert app is not None + + async def test_session_idle_timeout_none_by_default(self): + """session_idle_timeout should default to None (no timeout).""" + import fastmcp + + assert fastmcp.settings.session_idle_timeout is None + + async def test_session_idle_timeout_from_settings(self): + """session_idle_timeout should be configurable via settings.""" + import fastmcp + + original = fastmcp.settings.session_idle_timeout + try: + fastmcp.settings.session_idle_timeout = 60.0 + server = create_test_server() + # Should not raise - the setting is picked up + app = server.http_app() + assert app is not None + finally: + fastmcp.settings.session_idle_timeout = original + + async def test_session_idle_timeout_explicit_overrides_settings(self): + """Explicit session_idle_timeout should override settings value.""" + import fastmcp + + original = fastmcp.settings.session_idle_timeout + try: + fastmcp.settings.session_idle_timeout = 60.0 + server = create_test_server() + # Explicit value should override settings + app = server.http_app(session_idle_timeout=120.0) + assert app is not None + finally: + fastmcp.settings.session_idle_timeout = original + + async def test_session_idle_timeout_warns_on_unsupported_sdk(self, caplog): + """Should log a warning when SDK doesn't support session_idle_timeout.""" + if _sdk_supports_idle_timeout: + pytest.skip( + "MCP SDK supports session_idle_timeout; warning test not applicable" + ) + + import logging + + with caplog.at_level(logging.WARNING): + server = create_test_server() + server.http_app(session_idle_timeout=30.0) + + assert any( + "session_idle_timeout" in record.message + and "does not support" in record.message + for record in caplog.records + ) + + @pytest.mark.skipif( + not _sdk_supports_idle_timeout, + reason="MCP SDK does not yet support session_idle_timeout", + ) + async def test_session_idle_timeout_cleans_up_idle_sessions(self): + """Sessions should be cleaned up after the idle timeout expires.""" + import httpx + + server = create_test_server() + async with run_server_async(server) as url: + async with httpx.AsyncClient() as http_client: + # Initialize a session + init_resp = await http_client.post( + url, + json={ + "jsonrpc": "2.0", + "id": "init-1", + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { + "name": "test", + "version": "1.0.0", + }, + }, + }, + headers={ + "Accept": "application/json, text/event-stream", + }, + ) + session_id = init_resp.headers.get("Mcp-Session-Id", "") + assert session_id, "Should get a session ID" + + # Wait for the session to expire (timeout is very short) + await asyncio.sleep(2.5) + + # Try to use the expired session - should get 404 + resp = await http_client.post( + url, + json={ + "jsonrpc": "2.0", + "id": "test-1", + "method": "tools/list", + }, + headers={ + "Accept": "application/json, text/event-stream", + "Mcp-Session-Id": session_id, + }, + ) + assert resp.status_code == 404