diff --git a/pyproject.toml b/pyproject.toml index be8794e95..4cbd0d595 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [ "python-dotenv>=1.1.0", "exceptiongroup>=1.2.2", "httpx>=0.28.1,<1.0", - "mcp>=1.24.0,<2.0", + "mcp>=1.27.0,<2.0", "openapi-pydantic>=0.5.1", "opentelemetry-api>=1.20.0", "packaging>=24.0", diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index e60ae6061..895e5f7cb 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 @@ -270,6 +270,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, @@ -286,6 +287,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 @@ -297,13 +303,18 @@ 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, + } + + if session_idle_timeout is not None: + session_manager_kwargs["session_idle_timeout"] = session_idle_timeout + + 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 10223f38a..17c010469 100644 --- a/src/fastmcp/server/mixins/transport.py +++ b/src/fastmcp/server/mixins/transport.py @@ -236,6 +236,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. @@ -250,6 +251,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: @@ -273,6 +277,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 @@ -308,6 +313,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, @@ -319,6 +325,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 @@ -349,6 +359,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 393b1ff2f..1060f1ce9 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -320,6 +320,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..ff81e8ab5 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -289,3 +289,94 @@ 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) + + +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_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