Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions src/fastmcp/server/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions src/fastmcp/server/mixins/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down
3 changes: 3 additions & 0 deletions src/fastmcp/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
123 changes: 123 additions & 0 deletions tests/client/test_streamable_http.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import asyncio
import inspect
import json
import sys
from contextlib import suppress
from unittest.mock import AsyncMock, call

import pytest
from mcp import McpError
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.types import TextResourceContents

from fastmcp import Context
Expand Down Expand Up @@ -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
Loading