Expire idle Streamable HTTP sessions by default and cap concurrent sessions (#3395)

This commit is contained in:
Max 2026-08-28 15:54:49 +01:00 • committed by GitHub
commit d2290ca343
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 915 additions and 234 deletions

View file

@ -13,7 +13,7 @@ from starlette.routing import Mount, Route
from docs_src.asgi import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006
from mcp import Client
from mcp.server import MCPServer
from mcp.server import MCPServer, Server
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@ -44,6 +44,8 @@ async def test_streamable_http_app_takes_runs_options_except_port() -> None:
"event_store",
"retry_interval",
"max_request_body_size",
"session_idle_timeout",
"max_sessions",
"transport_security",
"host",
}
@ -68,6 +70,18 @@ async def test_streamable_http_app_applies_the_configured_request_body_limit() -
assert response.status_code == 413
async def test_streamable_http_app_applies_the_configured_session_limits() -> None:
"""The documented `session_idle_timeout` and `max_sessions` options reach the session manager, from
both the high-level and the low-level factory."""
server = MCPServer("Notes")
server.streamable_http_app(session_idle_timeout=5, max_sessions=7)
assert (server.session_manager.session_idle_timeout, server.session_manager.max_sessions) == (5, 7)
lowlevel = Server("Notes")
lowlevel.streamable_http_app(session_idle_timeout=None, max_sessions=None)
assert (lowlevel.session_manager.session_idle_timeout, lowlevel.session_manager.max_sessions) == (None, None)
async def test_mounting_at_the_root_keeps_the_default_path() -> None:
"""tutorial002: `Mount("/")` plus the default `streamable_http_path` leaves the endpoint at `/mcp`."""
(mount,) = tutorial002.app.routes

View file

@ -53,6 +53,8 @@ def test_streamable_http_app_has_no_era_knob() -> None:
"event_store",
"retry_interval",
"max_request_body_size",
"session_idle_timeout",
"max_sessions",
"transport_security",
"host",
}
@ -76,6 +78,20 @@ async def test_a_legacy_session_is_minted_in_process_and_a_stray_session_id_is_a
assert stray.status_code == 404
def test_legacy_sessions_expire_and_are_capped_by_default() -> None:
"""The session lifetime section: a session is closed after 30 idle minutes and each worker process
holds at most 10 000 of them, unless `run()` / `streamable_http_app()` say otherwise."""
server = MCPServer("Bookshop")
server.streamable_http_app()
assert server.session_manager.session_idle_timeout == 30 * 60
assert server.session_manager.max_sessions == 10_000
server = MCPServer("Bookshop")
server.streamable_http_app(session_idle_timeout=None, max_sessions=None)
assert server.session_manager.session_idle_timeout is None
assert server.session_manager.max_sessions is None
async def test_stateless_http_never_mints_a_session() -> None:
"""The `stateless_http=True` section: the same legacy `initialize` no longer gets an `Mcp-Session-Id`."""
app = MCPServer("Bookshop").streamable_http_app(stateless_http=True)

View file

@ -107,23 +107,15 @@ async def test_delete_terminates_the_session_and_subsequent_requests_return_404(
delete = await http.delete("/mcp", headers=base_headers(session_id=session_id))
assert delete.status_code == 200
# The manager keeps the terminated transport registered, so the next request reaches the
# transport's own _terminated check rather than the manager's unknown-session path.
assert session_id in manager._server_instances
# The manager forgets a terminated session, so from then on the ID is simply unknown.
assert session_id not in manager._server_instances
post = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
headers=base_headers(session_id=session_id),
)
assert (post.status_code, post.json()) == snapshot(
(
404,
{
"jsonrpc": "2.0",
"id": None,
"error": {"code": -32600, "message": "Not Found: Session has been terminated"},
},
)
(404, {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Session not found"}})
)

View file

@ -2,15 +2,28 @@
import json
import logging
from collections.abc import Iterator
from typing import Any
import math
from collections.abc import AsyncIterator, Iterator
from contextlib import asynccontextmanager, contextmanager
from typing import Any, cast
from unittest.mock import AsyncMock, patch
import anyio
import httpx2
import pytest
from mcp_types import INVALID_REQUEST, ListToolsResult, PaginatedRequestParams
from starlette.types import Message, Scope
from mcp_types import (
INTERNAL_ERROR,
INVALID_REQUEST,
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from mcp import Client
from mcp.client.streamable_http import streamable_http_client
@ -18,7 +31,32 @@ from mcp.server import Server, ServerRequestContext, streamable_http_manager
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from mcp.server.auth.provider import AccessToken
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport
from mcp.server.streamable_http_manager import DEFAULT_MAX_REQUEST_BODY_SIZE, StreamableHTTPSessionManager
from mcp.server.streamable_http_manager import (
DEFAULT_MAX_REQUEST_BODY_SIZE,
DEFAULT_MAX_SESSIONS,
DEFAULT_SESSION_IDLE_TIMEOUT,
StreamableHTTPSessionManager,
)
from tests.interaction.transports import StreamingASGITransport
# The in-process app is mounted at this origin purely so URLs are well-formed; nothing listens here.
BASE_URL = "http://127.0.0.1:8000"
_JSON_HEADERS = {"accept": "application/json, text/event-stream", "content-type": "application/json"}
_INITIALIZE_BODY = json.dumps(
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": LATEST_HANDSHAKE_VERSION,
"capabilities": {},
"clientInfo": {"name": "test", "version": "0"},
},
}
).encode()
"""A wire-level initialize request: the only request that may open a session."""
@pytest.mark.anyio
@ -286,19 +324,7 @@ async def test_stateless_requests_memory_cleanup():
app = Server("test-stateless-real-cleanup")
manager = StreamableHTTPSessionManager(app=app, stateless=True)
# Track created transport instances
created_transports: list[StreamableHTTPServerTransport] = []
# Patch StreamableHTTPServerTransport constructor to track instances
original_constructor = StreamableHTTPServerTransport
def track_transport(*args: Any, **kwargs: Any) -> StreamableHTTPServerTransport:
transport = original_constructor(*args, **kwargs)
created_transports.append(transport)
return transport
with patch.object(streamable_http_manager, "StreamableHTTPServerTransport", side_effect=track_transport):
with _created_transports() as created_transports:
async with manager.run():
# Send a simple request
sent_messages: list[Message] = []
@ -420,50 +446,57 @@ class _IdleTimeoutObserver(logging.Handler):
self.reaped.set()
@pytest.mark.anyio
async def test_idle_session_is_reaped(caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest):
"""After idle timeout fires, the session returns 404."""
app = Server("test-idle-reap")
manager = StreamableHTTPSessionManager(app=app, session_idle_timeout=0.05)
def _observe_idle_timeout(caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest) -> _IdleTimeoutObserver:
"""Install an observer for the manager's "idle timeout" log record for the rest of the test.
# The reap is observed through the manager's own "idle timeout" log record: the manager pops
# the session synchronously after emitting it, before its next await, so a waiter woken by
# the record always finds the session gone. caplog.set_level enables INFO so it is created.
The manager pops the session synchronously after emitting that record, before its next await,
so a waiter woken by it always finds the session gone. caplog.set_level enables INFO so the
record is created.
"""
observer = _IdleTimeoutObserver()
manager_logger = logging.getLogger(streamable_http_manager.__name__)
manager_logger.addHandler(observer)
request.addfinalizer(lambda: manager_logger.removeHandler(observer))
caplog.set_level(logging.INFO, logger=streamable_http_manager.__name__)
return observer
@contextmanager
def _created_transports() -> Iterator[list[StreamableHTTPServerTransport]]:
"""Collect every transport a session manager creates while the context is open."""
created: list[StreamableHTTPServerTransport] = []
def create(*args: Any, **kwargs: Any) -> StreamableHTTPServerTransport:
transport = StreamableHTTPServerTransport(*args, **kwargs)
created.append(transport)
return transport
with patch.object(streamable_http_manager, "StreamableHTTPServerTransport", side_effect=create):
yield created
@asynccontextmanager
async def _served(
manager: StreamableHTTPSessionManager, endpoint: ASGIApp | None = None
) -> AsyncIterator[httpx2.AsyncClient]:
"""Run `manager` behind an in-process HTTP client whose responses stream as they are produced.
`endpoint` is the ASGI app mounted for it; by default the manager itself.
"""
app = Starlette(routes=[Mount("/", app=endpoint or manager.handle_request)])
async with manager.run(), httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http:
yield http
@pytest.mark.anyio
async def test_idle_session_is_reaped(caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest):
"""After idle timeout fires, the session returns 404."""
app = Server("test-idle-reap")
manager = StreamableHTTPSessionManager(app=app, session_idle_timeout=0.05)
observer = _observe_idle_timeout(caplog, request)
async with manager.run():
sent_messages: list[Message] = []
async def mock_send(message: Message):
sent_messages.append(message)
scope = {
"type": "http",
"method": "POST",
"path": "/mcp",
"headers": [(b"content-type", b"application/json")],
}
async def mock_receive():
return {"type": "http.request", "body": b"", "more_body": False}
await manager.handle_request(scope, mock_receive, mock_send)
session_id = None
for msg in sent_messages: # pragma: no branch
if msg["type"] == "http.response.start": # pragma: no branch
for header_name, header_value in msg.get("headers", []): # pragma: no branch
if header_name.decode().lower() == MCP_SESSION_ID_HEADER.lower():
session_id = header_value.decode()
break
if session_id: # pragma: no branch
break
assert session_id is not None, "Session ID not found in response headers"
session_id = await _open_session(manager, None)
# Wait for the 50ms idle timeout to fire and the session to be unregistered. Re-requesting
# the session to poll for the 404 would push its idle deadline forward and keep it alive.
@ -471,41 +504,402 @@ async def test_idle_session_is_reaped(caplog: pytest.LogCaptureFixture, request:
await observer.reaped.wait()
# Verify via public API: old session ID now returns 404
response_messages: list[Message] = []
assert await _request_session(manager, session_id, None) == 404
async def capture_send(message: Message):
response_messages.append(message)
scope_with_session = {
"type": "http",
"method": "POST",
"path": "/mcp",
"headers": [
(b"content-type", b"application/json"),
(b"mcp-session-id", session_id.encode()),
],
@pytest.mark.anyio
async def test_request_in_flight_holds_the_session_open(
caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest
) -> None:
"""A session does not expire while one of its requests is still being served, however long that takes;
the idle period is counted from the moment its last request completes."""
tool_started = anyio.Event()
release_tool = anyio.Event()
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
tool_started.set()
await release_tool.wait()
return CallToolResult(content=[TextContent(type="text", text="done")])
app = Server("test-in-flight", on_call_tool=handle_call_tool)
manager = StreamableHTTPSessionManager(app=app, session_idle_timeout=30)
observer = _observe_idle_timeout(caplog, request)
async with _served(manager) as http:
initialize = await http.post("/mcp", content=_INITIALIZE_BODY, headers=_JSON_HEADERS)
assert initialize.status_code == 200
session_id = initialize.headers[MCP_SESSION_ID_HEADER]
transport = manager._server_instances[session_id]
session_headers = _JSON_HEADERS | {MCP_SESSION_ID_HEADER: session_id}
call_tool_body: dict[str, Any] = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": "slow", "arguments": {}},
}
responses: list[httpx2.Response] = []
async def call_tool() -> None:
responses.append(await http.post("/mcp", json=call_tool_body, headers=session_headers))
async with anyio.create_task_group() as tg:
tg.start_soon(call_tool)
with anyio.fail_after(5):
await tool_started.wait()
# While the call is being served the idle countdown is suspended.
assert transport.idle_scope is not None and transport.idle_scope.deadline == math.inf
# From here on a short idle period, counted from the moment the call completes.
transport._idle_timeout = 0.05
release_tool.set()
assert responses[0].status_code == 200
assert '"done"' in responses[0].text
# Nothing is in flight any more, so the idle period now runs out.
with anyio.fail_after(5):
await observer.reaped.wait()
assert session_id not in manager._server_instances
assert transport.is_terminated
followup = await http.post("/mcp", json={"jsonrpc": "2.0", "id": 3, "method": "ping"}, headers=session_headers)
assert followup.status_code == 404
@pytest.mark.anyio
async def test_open_event_stream_holds_the_session_open(
caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest
) -> None:
"""A client listening on the session's GET stream keeps the session, even if it sends nothing;
once the stream closes the idle period runs out and the session is gone."""
manager = StreamableHTTPSessionManager(app=Server("test-get-stream"), session_idle_timeout=30)
observer = _observe_idle_timeout(caplog, request)
async with _served(manager) as http:
initialize = await http.post("/mcp", content=_INITIALIZE_BODY, headers=_JSON_HEADERS)
assert initialize.status_code == 200
session_id = initialize.headers[MCP_SESSION_ID_HEADER]
session_headers = _JSON_HEADERS | {MCP_SESSION_ID_HEADER: session_id}
get_headers = {"accept": "text/event-stream", MCP_SESSION_ID_HEADER: session_id}
async with http.stream("GET", "/mcp", headers=get_headers) as stream:
assert stream.status_code == 200
# The stream has been answered, so it is in flight: the idle countdown is suspended.
transport = manager._server_instances[session_id]
assert transport.idle_scope is not None and transport.idle_scope.deadline == math.inf
# From here on a short idle period, counted from the moment the stream closes.
transport._idle_timeout = 0.05
with anyio.fail_after(5):
await observer.reaped.wait()
assert session_id not in manager._server_instances
assert transport.is_terminated
followup = await http.post("/mcp", json={"jsonrpc": "2.0", "id": 2, "method": "ping"}, headers=session_headers)
assert followup.status_code == 404
@pytest.mark.anyio
async def test_request_completing_under_an_open_event_stream_does_not_start_the_countdown(
caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest
) -> None:
"""A request that completes while the session's GET stream is still open does not start the idle
period: the stream is still in flight, so the countdown only begins once it closes too."""
manager = StreamableHTTPSessionManager(app=Server("test-get-stream-and-post"), session_idle_timeout=30)
observer = _observe_idle_timeout(caplog, request)
session_post_served = anyio.Event()
async def endpoint(scope: Scope, receive: Receive, send: Send) -> None:
# Report once a POST for the open session has been served to the end,
# in-flight bookkeeping included.
await manager.handle_request(scope, receive, send)
if scope["method"] == "POST" and MCP_SESSION_ID_HEADER.encode() in dict(scope["headers"]):
session_post_served.set()
async with _served(manager, endpoint) as http:
initialize = await http.post("/mcp", content=_INITIALIZE_BODY, headers=_JSON_HEADERS)
assert initialize.status_code == 200
session_id = initialize.headers[MCP_SESSION_ID_HEADER]
session_headers = _JSON_HEADERS | {MCP_SESSION_ID_HEADER: session_id}
get_headers = {"accept": "text/event-stream", MCP_SESSION_ID_HEADER: session_id}
async with http.stream("GET", "/mcp", headers=get_headers) as stream:
assert stream.status_code == 200
transport = manager._server_instances[session_id]
assert transport.idle_scope is not None and transport.idle_scope.deadline == math.inf
ping = await http.post("/mcp", json={"jsonrpc": "2.0", "id": 2, "method": "ping"}, headers=session_headers)
assert ping.status_code == 200
with anyio.fail_after(5):
await session_post_served.wait()
# The ping has completed, but the open stream still suspends the idle countdown.
assert transport.idle_scope.deadline == math.inf
# From here on a short idle period, counted from the moment the stream closes.
transport._idle_timeout = 0.05
with anyio.fail_after(5):
await observer.reaped.wait()
assert session_id not in manager._server_instances
assert transport.is_terminated
followup = await http.post("/mcp", json={"jsonrpc": "2.0", "id": 3, "method": "ping"}, headers=session_headers)
assert followup.status_code == 404
def test_session_idle_timeout_defaults_to_thirty_minutes() -> None:
"""Stateful sessions expire after 30 minutes without a request in flight unless configured otherwise."""
manager = StreamableHTTPSessionManager(app=Server("test"))
assert manager.session_idle_timeout == DEFAULT_SESSION_IDLE_TIMEOUT == 30 * 60
@pytest.mark.parametrize("session_idle_timeout", [0, -1, float("inf"), float("nan")])
def test_session_idle_timeout_rejects_invalid_values(session_idle_timeout: float) -> None:
"""The idle timeout is a positive, finite number of seconds, or None for sessions that never expire."""
with pytest.raises(ValueError) as exc_info:
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=session_idle_timeout)
assert str(exc_info.value) == "session_idle_timeout must be a positive, finite number of seconds"
@pytest.mark.anyio
async def test_session_idle_timeout_is_unused_in_stateless_mode() -> None:
"""Stateless mode keeps no sessions, so the idle timeout is accepted and simply has nothing to expire."""
manager = StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=30, stateless=True)
async with manager.run():
response_start, _ = await _call(manager, _request_scope(), _INITIALIZE_BODY)
assert response_start["status"] == 200
assert manager._server_instances == {}
@pytest.mark.anyio
@pytest.mark.parametrize("session_idle_timeout", [DEFAULT_SESSION_IDLE_TIMEOUT, None])
async def test_deleted_session_is_forgotten(session_idle_timeout: float | None) -> None:
"""A client DELETE ends the session and the manager stops tracking it; the ID is unknown afterwards."""
manager = StreamableHTTPSessionManager(app=Server("test-delete"), session_idle_timeout=session_idle_timeout)
async with manager.run():
session_id = await _open_session(manager, None)
assert session_id in manager._server_instances
assert await _request_session(manager, session_id, None, method="DELETE") == 200
assert session_id not in manager._server_instances
response_start, response_body = await _call(manager, _request_scope(session_id=session_id))
assert response_start["status"] == 404
assert json.loads(response_body) == {
"jsonrpc": "2.0",
"id": None,
"error": {"code": INVALID_REQUEST, "message": "Session not found"},
}
await manager.handle_request(scope_with_session, mock_receive, capture_send)
response_start = next(
(msg for msg in response_messages if msg["type"] == "http.response.start"),
None,
)
assert response_start is not None
assert response_start["status"] == 404
@pytest.mark.anyio
async def test_opening_request_that_fails_leaves_no_session() -> None:
"""If serving the request that would open a session raises, the provisional session is discarded
there and then rather than left registered with its server task running."""
manager = StreamableHTTPSessionManager(app=Server("test-failed-open"))
with _created_transports() as transports:
async with manager.run():
with (
patch.object(
StreamableHTTPServerTransport, "handle_request", AsyncMock(side_effect=RuntimeError("boom"))
),
pytest.raises(RuntimeError, match="boom"),
anyio.fail_after(5),
):
await _call(manager, _request_scope(), _INITIALIZE_BODY)
assert manager._server_instances == {}
assert manager._session_owners == {}
(transport,) = transports
assert transport.is_terminated
def test_session_idle_timeout_rejects_non_positive():
with pytest.raises(ValueError, match="positive number"):
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=-1)
with pytest.raises(ValueError, match="positive number"):
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=0)
@pytest.mark.anyio
async def test_opening_request_that_is_cancelled_leaves_no_session() -> None:
"""If the request that would open a session is cancelled while it is being served (the client went
away), the provisional session is discarded rather than left registered."""
manager = StreamableHTTPSessionManager(app=Server("test-cancelled-open"))
entered = anyio.Event()
async def hang(self: StreamableHTTPServerTransport, scope: Scope, receive: Receive, send: Send) -> None:
entered.set()
await anyio.sleep_forever()
opening_request = anyio.CancelScope()
async def open_session() -> None:
with opening_request:
await _call(manager, _request_scope(), _INITIALIZE_BODY)
with _created_transports() as transports, patch.object(StreamableHTTPServerTransport, "handle_request", hang):
async with manager.run():
async with anyio.create_task_group() as tg:
tg.start_soon(open_session)
with anyio.fail_after(5):
await entered.wait()
assert len(manager._server_instances) == 1
opening_request.cancel()
assert manager._server_instances == {}
assert manager._session_owners == {}
(transport,) = transports
assert transport.is_terminated
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)
@pytest.mark.anyio
async def test_opening_request_whose_session_task_cannot_start_leaves_no_session() -> None:
"""If the server task for a would-be session cannot be started, the provisional session is discarded
(forgotten, its transport terminated) rather than left registered without anything serving it."""
manager = StreamableHTTPSessionManager(app=Server("test-unstartable-open"))
@asynccontextmanager
async def connect_that_fails(self: StreamableHTTPServerTransport) -> AsyncIterator[None]:
raise RuntimeError("boom")
yield
with _created_transports() as transports:
async with manager.run():
with (
patch.object(StreamableHTTPServerTransport, "connect", connect_that_fails),
pytest.raises(RuntimeError, match="boom"),
anyio.fail_after(5),
):
await _call(manager, _request_scope(), _INITIALIZE_BODY)
assert manager._server_instances == {}
assert manager._session_owners == {}
(transport,) = transports
assert transport.is_terminated
@pytest.mark.anyio
async def test_stateless_request_that_is_cancelled_still_terminates_its_transport() -> None:
"""If a stateless request is cancelled while it is being served (the client went away), its transport
is terminated all the same, which is what ends the per-request server task."""
manager = StreamableHTTPSessionManager(app=Server("test-stateless-cancelled"), stateless=True)
entered = anyio.Event()
async def hang(self: StreamableHTTPServerTransport, scope: Scope, receive: Receive, send: Send) -> None:
entered.set()
await anyio.sleep_forever()
stateless_request = anyio.CancelScope()
async def make_request() -> None:
with stateless_request:
await _call(manager, _request_scope(), _INITIALIZE_BODY)
with _created_transports() as transports, patch.object(StreamableHTTPServerTransport, "handle_request", hang):
async with manager.run():
async with anyio.create_task_group() as tg:
tg.start_soon(make_request)
with anyio.fail_after(5):
await entered.wait()
stateless_request.cancel()
(transport,) = transports
assert transport.is_terminated
@pytest.mark.anyio
@pytest.mark.parametrize(
("method", "headers", "body", "expected_status"),
[
("POST", _JSON_HEADERS, b'{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', 400),
("POST", _JSON_HEADERS, b'{"jsonrpc": "2.0", "method": "notifications/initialized"}', 400),
("POST", _JSON_HEADERS, b"{not json", 400),
("POST", _JSON_HEADERS | {"accept": "text/plain"}, _INITIALIZE_BODY, 406),
("GET", {"accept": "text/event-stream"}, b"", 400),
("DELETE", _JSON_HEADERS, b"", 400),
("PATCH", _JSON_HEADERS, b"", 405),
],
ids=[
"non-initialize-request",
"notification",
"malformed-json",
"unacceptable-accept-header",
"get-without-session",
"delete-without-session",
"unsupported-method",
],
)
async def test_refused_opening_request_leaves_no_session(
method: str, headers: dict[str, str], body: bytes, expected_status: int
) -> None:
"""Only an accepted initialize opens a session: a request without a session ID that is answered with an
error leaves nothing registered once the manager has answered it."""
manager = StreamableHTTPSessionManager(app=Server("test-refused"))
scope: Scope = {
"type": "http",
"method": method,
"path": "/mcp",
"headers": [(name.encode(), value.encode()) for name, value in headers.items()],
}
with _created_transports() as transports:
async with manager.run():
response_start, _ = await _call(manager, scope, body)
assert response_start["status"] == expected_status
assert manager._server_instances == {}
assert manager._session_owners == {}
(transport,) = transports
assert transport.is_terminated
@pytest.mark.anyio
async def test_new_session_is_refused_at_max_sessions() -> None:
"""At the session limit a further initialize is answered 503 and opens nothing; room frees up as
sessions end."""
manager = StreamableHTTPSessionManager(app=Server("test-cap"), max_sessions=1)
async with manager.run():
first = await _open_session(manager, None)
response_start, response_body = await _call(manager, _request_scope(), _INITIALIZE_BODY)
assert response_start["status"] == 503
assert json.loads(response_body) == {
"jsonrpc": "2.0",
"id": None,
"error": {"code": INTERNAL_ERROR, "message": "Too many open sessions"},
}
assert list(manager._server_instances) == [first]
assert await _request_session(manager, first, None, method="DELETE") == 200
second = await _open_session(manager, None)
assert list(manager._server_instances) == [second]
@pytest.mark.anyio
async def test_client_that_is_slow_to_send_its_opening_request_does_not_hold_up_others() -> None:
"""While one client has yet to finish sending the request that would open its session, another
client can still open one."""
manager = StreamableHTTPSessionManager(app=Server("test-slow-open"))
body_awaited = anyio.Event()
async def stall() -> None:
# This client has sent its headers but never finishes sending the body.
body_awaited.set()
await anyio.sleep_forever()
async def discard(message: Message) -> None: ...
slow_client = anyio.CancelScope()
async def open_slowly() -> None:
with slow_client:
await manager.handle_request(_request_scope(), cast(Receive, stall), discard)
session_id: str | None = None
async with manager.run():
async with anyio.create_task_group() as tg:
tg.start_soon(open_slowly)
with anyio.fail_after(5):
await body_awaited.wait()
session_id = await _open_session(manager, None)
slow_client.cancel()
assert session_id is not None
assert list(manager._server_instances) == [session_id]
def test_max_sessions_defaults_to_ten_thousand() -> None:
"""A manager holds at most 10 000 concurrent stateful sessions unless configured otherwise."""
manager = StreamableHTTPSessionManager(app=Server("test"))
assert manager.max_sessions == DEFAULT_MAX_SESSIONS == 10_000
assert StreamableHTTPSessionManager(app=Server("test"), max_sessions=None).max_sessions is None
@pytest.mark.parametrize("max_sessions", [0, -1])
def test_max_sessions_rejects_non_positive_values(max_sessions: int) -> None:
with pytest.raises(ValueError) as exc_info:
StreamableHTTPSessionManager(app=Server("test"), max_sessions=max_sessions)
assert str(exc_info.value) == "max_sessions must be a positive number of sessions or None"
def _user(client_id: str, subject: str | None = None, issuer: str | None = None) -> AuthenticatedUser:
@ -535,19 +929,33 @@ def _request_scope(
return scope
async def _open_session(manager: StreamableHTTPSessionManager, user: AuthenticatedUser | None) -> str:
"""Create a new session as `user` and return its session ID."""
async def _call(manager: StreamableHTTPSessionManager, scope: Scope, body: bytes = b"") -> tuple[Message, bytes]:
"""Drive one request through the manager in process; return its `http.response.start` message and body."""
sent_messages: list[Message] = []
body_delivered = False
async def mock_send(message: Message) -> None:
async def send(message: Message) -> None:
sent_messages.append(message)
async def mock_receive() -> Message:
return {"type": "http.request", "body": b"", "more_body": False}
await manager.handle_request(_request_scope(user=user), mock_receive, mock_send)
async def receive() -> Message:
# Deliver the body once, then block like a client holding the connection
# open; a streaming response ends when the server closes it.
nonlocal body_delivered
if body_delivered:
await anyio.sleep_forever()
body_delivered = True
return {"type": "http.request", "body": body, "more_body": False}
await manager.handle_request(scope, receive, send)
response_start = next(msg for msg in sent_messages if msg["type"] == "http.response.start")
response_body = b"".join(msg.get("body", b"") for msg in sent_messages if msg["type"] == "http.response.body")
return response_start, response_body
async def _open_session(manager: StreamableHTTPSessionManager, user: AuthenticatedUser | None) -> str:
"""Create a new session as `user` with an initialize request and return its session ID."""
response_start, _ = await _call(manager, _request_scope(user=user), _INITIALIZE_BODY)
assert response_start["status"] == 200
headers = dict(response_start.get("headers", []))
return headers[MCP_SESSION_ID_HEADER.encode()].decode()
@ -556,26 +964,14 @@ async def _request_session(
manager: StreamableHTTPSessionManager, session_id: str, user: AuthenticatedUser | None, method: str = "POST"
) -> int:
"""Send a request for an existing session as `user` and return the response status."""
sent_messages: list[Message] = []
async def mock_send(message: Message) -> None:
sent_messages.append(message)
async def mock_receive() -> Message:
return {"type": "http.request", "body": b"", "more_body": False}
await manager.handle_request(
_request_scope(session_id=session_id, user=user, method=method), mock_receive, mock_send
)
response_start = next(msg for msg in sent_messages if msg["type"] == "http.response.start")
response_start, _ = await _call(manager, _request_scope(session_id=session_id, user=user, method=method))
return response_start["status"]
@pytest.fixture
async def manager_with_live_session():
"""A running manager around a real `Server`. Sessions remain registered until
`manager.run()` exits because `Server.run` blocks waiting for an initialize message."""
"""A running manager around a real `Server`. Sessions are opened with a real initialize and stay
registered until `manager.run()` exits because nothing in these tests ends them."""
manager = StreamableHTTPSessionManager(app=Server("test-session-credentials"))
async with manager.run():
yield manager

View file

@ -141,3 +141,19 @@ async def test_json_post_answers_500_when_session_terminates_mid_request() -> No
assert post.sent[0]["type"] == "http.response.start"
assert post.sent[0]["status"] == 500
@pytest.mark.anyio
async def test_terminated_transport_answers_404() -> None:
"""A request that still reaches a transport after its session was terminated is answered 404."""
transport = StreamableHTTPServerTransport(mcp_session_id="sid")
post = _AsgiPost(
b'{"jsonrpc":"2.0","id":"req-1","method":"ping"}',
[(b"accept", b"application/json, text/event-stream"), (b"content-type", b"application/json")],
)
async with transport.connect():
await transport.terminate()
await transport.handle_request(post.scope, post.receive, post.send)
assert post.sent[0]["type"] == "http.response.start"
assert post.sent[0]["status"] == 404

View file

@ -7,8 +7,10 @@ entirely in process.
from __future__ import annotations as _annotations
import json
import logging
import time
from collections.abc import AsyncIterator
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Any
@ -599,6 +601,97 @@ def test_streamable_http_transport_init_validation() -> None:
StreamableHTTPServerTransport(mcp_session_id="test\n")
@pytest.mark.parametrize("idle_timeout", [0, -1, float("inf"), float("nan")])
def test_streamable_http_transport_rejects_invalid_idle_timeout(idle_timeout: float) -> None:
"""A transport's idle timeout must be a positive, finite number of seconds; without one it never expires."""
with pytest.raises(ValueError) as exc_info:
StreamableHTTPServerTransport(mcp_session_id="valid-id", idle_timeout=idle_timeout)
assert str(exc_info.value) == "idle_timeout must be a positive, finite number of seconds"
assert StreamableHTTPServerTransport(mcp_session_id="valid-id").idle_scope is None
def test_streamable_http_transport_with_idle_timeout_can_be_created_outside_an_event_loop() -> None:
"""The idle scope is only created once connect() is entered, so a transport with a timeout can be
constructed without a running event loop."""
# A bare thread has no async context; this one does, courtesy of the suite's shared runner.
with ThreadPoolExecutor(max_workers=1) as pool:
transport = pool.submit(StreamableHTTPServerTransport, mcp_session_id="valid-id", idle_timeout=5).result()
assert transport.idle_scope is None
@pytest.mark.anyio
async def test_streamable_http_transport_creates_its_idle_scope_on_connect() -> None:
"""Entering connect() creates the idle scope the host enters around the session's message loop."""
transport = StreamableHTTPServerTransport(mcp_session_id="valid-id", idle_timeout=5)
async with transport.connect():
assert isinstance(transport.idle_scope, anyio.CancelScope)
await transport.terminate()
async def _post_to_transport(transport: StreamableHTTPServerTransport, body: dict[str, Any]) -> int:
"""POST `body` straight to `transport` in process, as a client that then holds the connection open,
and return the status it answered with."""
assert transport.mcp_session_id is not None
scope: Scope = {
"type": "http",
"method": "POST",
"path": "/mcp",
"query_string": b"",
"headers": [
(b"content-type", b"application/json"),
(b"accept", b"application/json, text/event-stream"),
(MCP_SESSION_ID_HEADER.encode(), transport.mcp_session_id.encode()),
],
}
sent: list[Message] = []
async def send(message: Message) -> None:
sent.append(message)
request_body, incoming = anyio.create_memory_object_stream[Message](1)
async with request_body, incoming:
await request_body.send({"type": "http.request", "body": json.dumps(body).encode(), "more_body": False})
with anyio.fail_after(5):
await transport.handle_request(scope, incoming.receive, send)
return next(message["status"] for message in sent if message["type"] == "http.response.start")
@pytest.mark.anyio
async def test_transport_whose_idle_period_ran_out_answers_as_terminated() -> None:
"""Once the idle scope has fired, a request that still reaches the transport is answered 404 and the
transport is terminated, instead of being dispatched into the message loop the host is leaving."""
transport = StreamableHTTPServerTransport(mcp_session_id="valid-id", idle_timeout=5)
ping = {"jsonrpc": "2.0", "id": 1, "method": "ping"}
async with transport.connect():
assert transport.idle_scope is not None
# Exactly what the scope's deadline passing does.
transport.idle_scope.cancel()
assert await _post_to_transport(transport, ping) == 404
assert transport.is_terminated
assert await _post_to_transport(transport, ping) == 404
@pytest.mark.anyio
async def test_transport_reports_stream_closure_when_host_exits_without_terminating(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A host that leaves connect() without terminating the transport closes the streams under the
message router, which reports it rather than passing it off as a client disconnect."""
caplog.set_level(logging.ERROR, logger="mcp.server.streamable_http")
transport = StreamableHTTPServerTransport(mcp_session_id="valid-id")
async with transport.connect():
pass
assert not transport.is_terminated
assert [
record.getMessage()
for record in caplog.records
if record.name == "mcp.server.streamable_http" and record.levelno == logging.ERROR
] == ["Unexpected closure of read stream in message router"]
@pytest.mark.anyio
async def test_session_termination(basic_app: Starlette) -> None:
"""DELETE terminates the session, after which requests for it return 404."""
@ -639,7 +732,7 @@ async def test_session_termination(basic_app: Starlette) -> None:
json={"jsonrpc": "2.0", "method": "ping", "id": 2},
)
assert response.status_code == 404
assert "Session has been terminated" in response.text
assert response.json()["error"]["message"] == "Session not found"
@pytest.mark.anyio
@ -1048,7 +1141,7 @@ async def test_streamable_http_client_session_termination(basic_app: Starlette)
with pytest.raises(MCPError) as exc_info: # pragma: no branch
await session.list_tools()
assert exc_info.value.error.code == INVALID_REQUEST
assert "terminated" in exc_info.value.error.message.lower()
assert exc_info.value.error.message == "Session not found"
@pytest.mark.anyio
@ -1111,7 +1204,7 @@ async def test_streamable_http_client_session_termination_204(
with pytest.raises(MCPError) as exc_info: # pragma: no branch
await session.list_tools()
assert exc_info.value.error.code == INVALID_REQUEST
assert "terminated" in exc_info.value.error.message.lower()
assert exc_info.value.error.message == "Session not found"
@pytest.mark.anyio