"""Streamable HTTP session lifecycle: creation, routing, termination, and stateless mode. A test here speaks raw HTTP only when its assertion is the wire contract -- which header is issued, which status code answers which condition -- that the SDK `Client` cannot observe. Everything else is `Client`-driven against the same mounted session manager. Transport-agnostic behaviour is covered by the `connect`-fixture matrix. """ import re import anyio import httpx import pytest from inline_snapshot import snapshot from mcp_types import JSONRPCResponse, ListToolsResult, PaginatedRequestParams, Tool from mcp.server import Server, ServerRequestContext from tests.interaction._connect import ( base_headers, client_via_http, initialize_body, initialize_via_http, mounted_app, post_jsonrpc, ) from tests.interaction._requirements import requirement pytestmark = pytest.mark.anyio def _server() -> Server: """A minimal low-level server with one tool, so subsequent-request routing can be observed.""" async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: return ListToolsResult(tools=[Tool(name="noop", description="Does nothing.", input_schema={"type": "object"})]) return Server("hosted", on_list_tools=list_tools) @requirement("hosting:session:create") @requirement("hosting:session:id-charset") async def test_initialize_issues_a_visible_ascii_session_id() -> None: """An initialize POST without a session ID creates a session and returns a visible-ASCII Mcp-Session-Id.""" async with mounted_app(_server()) as (http, _): response, messages = await post_jsonrpc(http, initialize_body()) assert response.status_code == 200 session_id = response.headers.get("mcp-session-id") assert session_id is not None # The spec requires the session ID to consist only of visible ASCII (0x21-0x7E). assert re.fullmatch(r"[\x21-\x7E]+", session_id) assert isinstance(messages[0], JSONRPCResponse) assert messages[0].id == 1 @requirement("hosting:session:reuse") async def test_subsequent_requests_with_the_session_id_route_to_the_same_session() -> None: """Requests carrying the issued Mcp-Session-Id reuse that session's transport rather than creating another.""" async with mounted_app(_server()) as (http, manager): async with client_via_http(http) as client: await client.list_tools() await client.list_tools() # The session count is the only signal that distinguishes routing-to-existing from # silently creating a second session: both produce a successful result. assert len(manager._server_instances) == 1 @requirement("hosting:session:unknown-id") async def test_requests_with_an_unknown_session_id_return_404() -> None: """POST, GET, and DELETE each carrying an unknown Mcp-Session-Id are answered 404 by the manager.""" async with mounted_app(_server()) as (http, _): post = await http.post( "/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, headers=base_headers(session_id="not-a-session"), ) get = await http.get("/mcp", headers=base_headers(session_id="not-a-session")) delete = await http.delete("/mcp", headers=base_headers(session_id="not-a-session")) assert (post.status_code, post.json()) == snapshot( (404, {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Session not found"}}) ) assert (get.status_code, delete.status_code) == (404, 404) @requirement("hosting:session:missing-id") async def test_non_initialize_post_without_a_session_id_returns_400() -> None: """A non-initialize POST that omits Mcp-Session-Id in stateful mode is rejected with 400.""" async with mounted_app(_server()) as (http, _): await initialize_via_http(http) response = await http.post( "/mcp", json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, headers=base_headers() ) assert (response.status_code, response.json()) == snapshot( (400, {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Bad Request: Missing session ID"}}) ) @requirement("hosting:session:delete") @requirement("hosting:session:post-termination-404") async def test_delete_terminates_the_session_and_subsequent_requests_return_404() -> None: """DELETE with a valid Mcp-Session-Id terminates the session; further requests on that ID return 404.""" async with mounted_app(_server()) as (http, manager): session_id = await initialize_via_http(http) 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 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"}, }, ) ) @requirement("hosting:session:isolation") async def test_terminating_one_session_leaves_others_working() -> None: """Terminating one session on a manager does not disturb a concurrent session on the same manager.""" async with mounted_app(_server()) as (http, manager): async with client_via_http(http) as survivor: async with client_via_http(http) as terminated: await terminated.list_tools() assert len(manager._server_instances) == 2 # `terminated` has exited (its DELETE has been sent); `survivor` still answers. result = await survivor.list_tools() assert result.tools[0].name == "noop" @requirement("hosting:session:reinitialize") async def test_second_initialize_on_an_existing_session_is_accepted() -> None: """A second initialize POST carrying an existing session ID is processed rather than rejected. See the divergence on the requirement: the entry expects a rejection, but the SDK forwards the second initialize to the running server, which answers it as a fresh handshake. """ async with mounted_app(_server()) as (http, manager): session_id = await initialize_via_http(http) response, messages = await post_jsonrpc(http, initialize_body(request_id=2), session_id=session_id) assert len(manager._server_instances) == 1 assert response.status_code == snapshot(200) assert isinstance(messages[0], JSONRPCResponse) assert messages[0].id == 2 @requirement("hosting:stateless:no-session-id") @requirement("hosting:stateless:no-reuse") async def test_stateless_mode_never_issues_a_session_id() -> None: """A stateless server issues no Mcp-Session-Id and creates no persistent transport. The recording proves no request the SDK client sent carried an Mcp-Session-Id (the server cannot have issued one, or the client would echo it); the empty instance map proves the manager kept no transport between requests. """ requests: list[httpx.Request] = [] async def record(request: httpx.Request) -> None: requests.append(request) async with mounted_app(_server(), stateless_http=True, on_request=record) as (http, manager): async with client_via_http(http) as client: result = await client.list_tools() assert manager._server_instances == {} assert result.tools[0].name == "noop" assert all("mcp-session-id" not in request.headers for request in requests) assert "DELETE" not in {request.method for request in requests} @requirement("hosting:stateless:concurrent-clients") async def test_stateless_mode_serves_concurrent_clients_independently() -> None: """Two clients connected concurrently to the same stateless app each complete a round trip.""" results: dict[str, ListToolsResult] = {} async with mounted_app(_server(), stateless_http=True) as (http, _): async def list_via(label: str) -> None: async with client_via_http(http) as client: results[label] = await client.list_tools() with anyio.fail_after(5): async with anyio.create_task_group() as tg: # pragma: no branch tg.start_soon(list_via, "a") tg.start_soon(list_via, "b") assert results["a"].tools[0].name == "noop" assert results["b"].tools[0].name == "noop"