Files
modelcontextprotocol--pytho…/tests/interaction/transports/test_hosting_session.py
T
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

203 lines
8.7 KiB
Python

"""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"