chore: import upstream snapshot with attribution
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) Has been cancelled
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) Has been cancelled
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user