Server-side 2026-07-28 stateless support: classifier, driver split, server/discover (#2928)

This commit is contained in:
Max 2026-06-21 19:34:17 +01:00 • committed by GitHub
commit 2397319a68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2256 additions and 1088 deletions

View file

@ -4,7 +4,8 @@ from pydantic import FileUrl
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.server.mcpserver import Context, MCPServer
from mcp.types import ListRootsResult, Root, TextContent
from mcp.shared.exceptions import MCPError
from mcp.types import INVALID_REQUEST, ListRootsResult, Root, TextContent
@pytest.mark.anyio
@ -37,10 +38,10 @@ async def test_list_roots_callback():
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "true"
# Test without list_roots callback
# Without a list_roots callback the client responds with an MCPError, which the
# tool body doesn't catch — the wrapper re-raises it as a top-level JSON-RPC
# error rather than wrapping it as an isError result.
async with Client(server) as client:
# Make a request to trigger sampling callback
result = await client.call_tool("test_list_roots", {"message": "test message"})
assert result.is_error is True
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Error executing tool test_list_roots: List roots not supported"
with pytest.raises(MCPError) as exc_info:
await client.call_tool("test_list_roots", {"message": "test message"})
assert exc_info.value.error.code == INVALID_REQUEST

View file

@ -201,3 +201,62 @@ async def test_invalid_json_response_sends_jsonrpc_error() -> None:
with pytest.raises(MCPError, match="Failed to parse JSON response"): # pragma: no branch
await session.list_tools()
def _create_non_2xx_json_body_app(status: int, body: bytes) -> Starlette:
"""Server that returns a fixed non-2xx status + ``application/json`` body for non-init requests."""
async def handle_mcp_request(request: Request) -> Response:
data = json.loads(await request.body())
if data.get("method") == "initialize":
return _init_json_response(data)
if "id" not in data:
return Response(status_code=202)
return Response(content=body, status_code=status, media_type="application/json")
return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])])
async def test_client_surfaces_jsonrpc_error_from_non_2xx_body_with_correlated_id() -> None:
"""SDK-defined: a JSON-RPC error in a non-2xx body is surfaced verbatim even when the
server set ``id: null`` — the client rewraps it under the pending request's id, so
the awaiting call resolves with the server's error code instead of the generic fallback."""
body = json.dumps(
{"jsonrpc": "2.0", "id": None, "error": {"code": types.METHOD_NOT_FOUND, "message": "nope"}}
).encode()
app = _create_non_2xx_json_body_app(400, body)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError) as exc:
await session.list_tools()
assert exc.value.error.code == types.METHOD_NOT_FOUND
async def test_client_falls_back_to_generic_error_when_non_2xx_body_is_a_jsonrpc_result() -> None:
"""SDK-defined: a non-2xx response whose JSON body parses as a JSON-RPC *result* (not an
error) falls through to the generic ``INTERNAL_ERROR`` fallback rather than being
treated as the request's reply."""
app = _create_non_2xx_json_body_app(400, b'{"jsonrpc":"2.0","id":1,"result":{}}')
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError) as exc:
await session.list_tools()
assert exc.value.error.code == types.INTERNAL_ERROR
async def test_client_falls_back_to_session_terminated_when_404_body_is_malformed_json() -> None:
"""SDK-defined: an unparseable ``application/json`` body on a 404 response is swallowed
and the status-derived ``INVALID_REQUEST`` (session-terminated) fallback resolves the
pending request — the parse failure never propagates."""
app = _create_non_2xx_json_body_app(404, b"not valid json{{{")
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError) as exc:
await session.list_tools()
assert exc.value.error.code == types.INVALID_REQUEST

View file

@ -3,7 +3,9 @@ import pytest
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.exceptions import MCPError
from mcp.types import (
INVALID_REQUEST,
CreateMessageRequestParams,
CreateMessageResult,
CreateMessageResultWithTools,
@ -47,13 +49,13 @@ async def test_sampling_callback():
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "true"
# Test without sampling callback
# Without a sampling callback the client responds with an MCPError, which the
# tool body doesn't catch — the wrapper re-raises it as a top-level JSON-RPC
# error rather than wrapping it as an isError result.
async with Client(server) as client:
# Make a request to trigger sampling callback
result = await client.call_tool("test_sampling", {"message": "Test message for sampling"})
assert result.is_error is True
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Error executing tool test_sampling: Sampling not supported"
with pytest.raises(MCPError) as exc_info:
await client.call_tool("test_sampling", {"message": "Test message for sampling"})
assert exc_info.value.error.code == INVALID_REQUEST
@pytest.mark.anyio

View file

@ -97,9 +97,9 @@ _TASKS_DEFERRAL = (
)
_MODERN_NOTIFY_DROP = (
"SingleExchangeDispatcher.notify() no-ops on the modern streamable-http driver; handler-emitted "
"logging/progress notifications never reach the per-request SSE response. Passes once SSE "
"response mode lands."
"The modern single-exchange dispatch context no-ops notify() on the streamable-http driver; "
"handler-emitted logging/progress notifications never reach the per-request SSE response. "
"Passes once SSE response mode lands."
)
@ -3046,6 +3046,50 @@ REQUIREMENTS: dict[str, Requirement] = {
transports=("streamable-http",),
note="Only observable over streamable HTTP: the modern entry's exception-to-JSONRPCError boundary.",
),
"hosting:http:modern:discover-response-shape": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/index",
behavior=(
"A 2026-07-28 server/discover response carries supportedVersions, capabilities, and "
"serverInfo, with supportedVersions naming the modern protocol revisions the server accepts."
),
added_in="2026-07-28",
transports=("streamable-http",),
note="Only observable over streamable HTTP: the raw result body is asserted at the wire.",
),
"hosting:http:modern:removed-method-status-404": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/index",
behavior=(
"A method that exists at earlier protocol revisions but is removed at 2026-07-28 is "
"answered METHOD_NOT_FOUND, and the modern entry maps that error code to HTTP 404."
),
added_in="2026-07-28",
transports=("streamable-http",),
note=(
"Only observable over streamable HTTP: the HTTP status is the assertion. Kernel-origin "
"METHOD_NOT_FOUND travels through the same status table as classifier-origin errors."
),
),
"hosting:http:modern:envelope-missing-key-status-400": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http",
behavior=(
"A 2026-07-28 request whose params._meta envelope omits a required reserved key is "
"rejected as INVALID_PARAMS at HTTP 400 before kernel dispatch."
),
added_in="2026-07-28",
transports=("streamable-http",),
note="Only observable over streamable HTTP: the HTTP status is the assertion.",
),
"hosting:http:modern:handler-error-status-via-table": Requirement(
source="sdk",
behavior=(
"A handler-raised MCPError on the 2026-07-28 entry reaches the wire as a top-level "
"JSON-RPC error with its data preserved, and the HTTP status is the error-code table "
"entry for that code (handler-origin and classifier-origin errors share one table)."
),
added_in="2026-07-28",
transports=("streamable-http",),
note="Only observable over streamable HTTP: the modern entry's JSONRPCError-to-HTTP-status mapping.",
),
# ═══════════════════════════════════════════════════════════════════════════
# Client transport: streamable HTTP
# ═══════════════════════════════════════════════════════════════════════════

View file

@ -14,9 +14,14 @@ from inline_snapshot import snapshot
from mcp.server import Server, ServerRequestContext
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
INVALID_PARAMS,
PARSE_ERROR,
PROTOCOL_VERSION_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
CallToolRequestParams,
CallToolResult,
EmptyResult,
@ -155,7 +160,12 @@ async def test_malformed_and_batched_bodies_return_400() -> None:
@requirement("hosting:http:protocol-version-400")
@requirement("hosting:http:protocol-version-default")
async def test_protocol_version_header_is_validated() -> None:
"""An unsupported MCP-Protocol-Version header returns 400; an absent header is accepted as the default."""
"""An unsupported MCP-Protocol-Version header returns 400; an absent header is accepted as the default.
An unrecognised header value routes to the modern entry (which owns rejection of unknown
versions), and a request without the per-request envelope is rejected at the first ladder
rung. Only known initialize-handshake versions and an absent header reach the legacy path.
"""
async with mounted_app(_server()) as (http, _):
session_id = await initialize_via_http(http)
@ -172,9 +182,7 @@ async def test_protocol_version_header_is_validated() -> None:
)
assert bad.status_code == 400
assert JSONRPCError.model_validate_json(bad.text).error.message.startswith(
"Bad Request: Unsupported protocol version: 1991-01-01."
)
assert JSONRPCError.model_validate_json(bad.text).error.code == INVALID_PARAMS
# 202 proves the request was accepted under the assumed default version (2025-03-26).
assert defaulted.status_code == 202
@ -185,18 +193,27 @@ async def test_unsupported_protocol_version_rejection_body_contains_the_sniffed_
SDK-defined: other SDKs detect this rejection by substring-matching ``Unsupported protocol
version`` in the response body, so the literal must survive any rewording of the surrounding
message. Asserted at the wire because the SDK client never surfaces the rejection body.
message. The unsupported value must appear in both the header and the envelope so the
classifier reaches its version-supported rung rather than reporting a header mismatch first.
"""
bad = "1991-01-01"
meta = {
PROTOCOL_VERSION_META_KEY: bad,
CLIENT_INFO_META_KEY: {"name": "t", "version": "0"},
CLIENT_CAPABILITIES_META_KEY: {},
}
async with mounted_app(_server()) as (http, _):
session_id = await initialize_via_http(http)
response = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 2, "method": "ping"},
headers=base_headers(session_id=session_id) | {"mcp-protocol-version": "1991-01-01"},
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {"_meta": meta}},
headers=base_headers() | {"mcp-protocol-version": bad},
)
assert response.status_code == 400
error = JSONRPCError.model_validate_json(response.text).error
assert error.code == UNSUPPORTED_PROTOCOL_VERSION
assert "Unsupported protocol version" in response.text
assert error.data == {"supported": list(MODERN_PROTOCOL_VERSIONS), "requested": bad}
@requirement("hosting:http:json-response-mode")

View file

@ -16,23 +16,29 @@ import httpx
import pytest
from inline_snapshot import snapshot
from mcp import MCPError
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server, ServerRequestContext
from mcp.types import (
CLIENT_CAPABILITIES_META_KEY,
INTERNAL_ERROR,
INVALID_PARAMS,
METHOD_NOT_FOUND,
MISSING_REQUIRED_CLIENT_CAPABILITY,
CallToolRequestParams,
CallToolResult,
EmptyResult,
Implementation,
JSONRPCError,
JSONRPCResponse,
ListToolsResult,
PaginatedRequestParams,
RequestParams,
TextContent,
Tool,
)
from tests.interaction._connect import BASE_URL, base_headers, initialize_body, initialize_via_http, mounted_app
from tests.interaction._connect import BASE_URL, base_headers, initialize_via_http, mounted_app
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@ -133,28 +139,30 @@ async def test_modern_response_carries_no_session_id_header() -> None:
@requirement("hosting:http:modern:initialize-removed")
async def test_modern_initialize_is_method_not_found() -> None:
"""A 2026-07-28 initialize request is answered with METHOD_NOT_FOUND.
"""A 2026-07-28 initialize request that carries a valid envelope is answered METHOD_NOT_FOUND at HTTP 404.
Spec-mandated under the draft: initialize is not a defined method at 2026-07-28, so the
method/version gate rejects it before any handler runs. Asserted at the wire because the SDK
client at 2026-07-28 never sends initialize, so only a raw POST can drive the negative.
Spec-mandated under the draft: initialize is not a defined method at 2026-07-28, so the kernel's
method/version gate rejects it before any handler runs. The body must carry the per-request
``_meta`` envelope so the classifier ladder admits it as far as kernel dispatch -- without the
envelope the request is INVALID_PARAMS at rung 1, never METHOD_NOT_FOUND. Asserted at the wire
because the SDK client at 2026-07-28 never sends initialize, so only a raw POST can drive the
negative.
"""
body = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"_meta": _meta_envelope()}}
async with mounted_app(_server()) as (http, _):
response = await http.post("/mcp", json=initialize_body(), headers=_modern_headers(method="initialize"))
response = await http.post("/mcp", json=body, headers=_modern_headers(method="initialize"))
assert response.status_code == 200
assert response.status_code == 404
assert JSONRPCError.model_validate(response.json()).error.code == METHOD_NOT_FOUND
@requirement("hosting:http:modern:legacy-fallthrough")
async def test_non_modern_version_header_falls_through_to_legacy_transport_unchanged() -> None:
"""The 2026-07-28 routing branch fires only on its exact header; everything else reaches legacy.
SDK-defined under the draft versioning rules: the modern entry must not change any 2025-era
byte. A 2025-era initialize on the same endpoint still completes (legacy serves it), and an
unrecognised ``MCP-Protocol-Version`` still falls through to the legacy gate and produces the
``Unsupported protocol version`` literal that peer SDKs substring-sniff. Asserted at the wire
because the literal is only observable in the raw response body.
async def test_legacy_version_header_falls_through_and_unrecognised_header_routes_to_modern() -> None:
"""SDK-defined under the draft versioning rules: only the known initialize-handshake protocol
versions reach the legacy transport, so a 2025-era ``initialize`` on the same endpoint still
completes unchanged. Any other ``MCP-Protocol-Version`` value routes to the modern entry,
where the validation ladder rejects it (a request without the per-request envelope fails the
first rung). The modern entry is therefore the single owner of unknown-version rejection.
"""
async with mounted_app(_server()) as (http, _):
# 2025-era initialize through the same endpoint: the modern branch must not intercept it.
@ -166,7 +174,7 @@ async def test_non_modern_version_header_falls_through_to_legacy_transport_uncha
)
assert unrecognised.status_code == 400
assert "Unsupported protocol version" in unrecognised.text
assert JSONRPCError.model_validate_json(unrecognised.text).error.code == INVALID_PARAMS
@requirement("hosting:http:modern:handler-exception-internal-error")
@ -198,6 +206,92 @@ async def test_modern_handler_exception_maps_to_internal_error_without_leaking_t
assert "kaboom" not in error.message
@requirement("hosting:http:modern:discover-response-shape")
async def test_modern_server_discover_returns_capabilities_and_supported_versions() -> None:
"""A 2026-07-28 server/discover POST returns capabilities, serverInfo, and supportedVersions.
Spec-mandated under the draft: server/discover is the 2026 advertisement method that replaces
the initialize-response payload, and ``supportedVersions`` is the field a client picks its
per-request envelope version from. Asserted at the wire because the SDK client never exposes
the raw result body.
"""
body = {"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": _meta_envelope()}}
async with mounted_app(_server()) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="server/discover"))
assert response.status_code == 200
result = JSONRPCResponse.model_validate(response.json()).result
assert result["supportedVersions"] == snapshot(["2026-07-28"])
assert result["serverInfo"]["name"] == "modern"
assert "capabilities" in result
@requirement("hosting:http:modern:removed-method-status-404")
async def test_modern_removed_method_is_method_not_found_at_http_404() -> None:
"""A 2026-07-28 ping (removed at 2026) is answered METHOD_NOT_FOUND and the HTTP status is 404.
Spec-mandated for the error code: ping is not a defined method at 2026-07-28 so the kernel's
method/version gate rejects it. SDK-defined for the HTTP status: kernel-origin METHOD_NOT_FOUND
travels through the same error-code-to-status table as classifier-origin errors. Asserted at the
wire because the HTTP status is the assertion.
"""
body = {"jsonrpc": "2.0", "id": 1, "method": "ping", "params": {"_meta": _meta_envelope()}}
async with mounted_app(_server()) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="ping"))
assert response.status_code == 404
assert JSONRPCError.model_validate(response.json()).error.code == METHOD_NOT_FOUND
@requirement("hosting:http:modern:envelope-missing-key-status-400")
async def test_modern_envelope_missing_required_meta_key_is_invalid_params_at_http_400() -> None:
"""A 2026-07-28 request whose ``_meta`` envelope omits a required key is INVALID_PARAMS at HTTP 400.
Spec-mandated under the draft transport: the per-request envelope must carry every reserved key,
so a missing ``clientCapabilities`` fails the classifier's first rung before any kernel dispatch.
Asserted at the wire because the HTTP status is the assertion.
"""
incomplete = _meta_envelope()
del incomplete[CLIENT_CAPABILITIES_META_KEY]
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": incomplete}}
async with mounted_app(_server()) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/list"))
assert response.status_code == 400
assert JSONRPCError.model_validate(response.json()).error.code == INVALID_PARAMS
@requirement("hosting:http:modern:handler-error-status-via-table")
async def test_modern_handler_raised_mcperror_maps_to_status_via_error_code_table() -> None:
"""A handler-raised ``MCPError`` reaches the wire as a top-level JSON-RPC error at the table-mapped HTTP status.
SDK-defined for the HTTP status: the modern entry maps every JSON-RPC ``error.code`` -- whether
classifier-origin or handler-origin -- through one error-code-to-status table, so a handler
raising ``MISSING_REQUIRED_CLIENT_CAPABILITY`` produces HTTP 400 with ``error.data`` preserved.
Spec-mandated for the error code: the named code and its ``requiredCapabilities`` data shape are
the spec's capability-gating contract. Registered via the low-level ``add_request_handler`` so
the high-level tool wrapper's error-swallowing is not on the path.
"""
async def cap_check(ctx: ServerRequestContext, params: RequestParams) -> EmptyResult:
raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message="sampling required",
data={"requiredCapabilities": ["sampling"]},
)
server = _server()
server.add_request_handler("test/cap-check", RequestParams, cap_check)
body = {"jsonrpc": "2.0", "id": 1, "method": "test/cap-check", "params": {"_meta": _meta_envelope()}}
async with mounted_app(server) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="test/cap-check"))
assert response.status_code == 400
error = JSONRPCError.model_validate(response.json()).error
assert error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert error.data == {"requiredCapabilities": ["sampling"]}
@requirement("hosting:http:modern:tools-call-stateless")
@requirement("lifecycle:stateless:request-envelope")
@requirement("lifecycle:stateless:caller-meta-preserved")

View file

@ -15,7 +15,9 @@ from pydantic import BaseModel
from mcp.client import ClientRequestContext
from mcp.server.elicitation import AcceptedElicitation
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.exceptions import MCPError
from mcp.types import (
INVALID_REQUEST,
CallToolResult,
ElicitRequestParams,
ElicitResult,
@ -92,15 +94,15 @@ async def test_tool_calls_over_stateless_streamable_http() -> None:
@requirement("transport:streamable-http:stateless-restrictions")
async def test_stateless_streamable_http_rejects_server_initiated_requests() -> None:
"""A handler that tries to call back to the client in stateless mode fails: there is no session."""
"""A handler that tries to call back to the client in stateless mode fails: there is no
back-channel for server-initiated requests. The resulting ``NoBackChannelError`` is an
``MCPError``, so it surfaces as a top-level JSON-RPC error rather than an
``isError`` result."""
async with connect_over_streamable_http(_smoke_server(), stateless_http=True) as client:
result = await client.call_tool("ask", {})
with pytest.raises(MCPError) as exc_info:
await client.call_tool("ask", {})
assert result.is_error is True
assert isinstance(result.content[0], TextContent)
# The exact message is the StatelessModeNotSupported exception text wrapped by the tool-error
# path; pin the stable prefix rather than the full exception prose.
assert result.content[0].text.startswith("Error executing tool ask:")
assert exc_info.value.error.code == INVALID_REQUEST
@requirement("transport:streamable-http:notifications")

View file

@ -0,0 +1,151 @@
"""Direct-handler tests for the auto-derived `server/discover` handler.
These call the registered handler via the public `Server.get_request_handler`
accessor without spinning up a `ServerRunner` or any transport, so they verify
the handler's contract in isolation from the dispatch pipeline.
"""
import importlib.metadata
from typing import Any, cast
import pytest
from mcp import types
from mcp.server import Server, ServerRequestContext
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
# `Server._handle_discover` ignores its `ctx` argument entirely (it derives the
# result from server state), so a sentinel keeps the call site type-correct
# without dragging session machinery into a unit test.
_UNUSED_CTX = cast("ServerRequestContext[Any]", None)
async def _discover(server: Server[Any]) -> types.DiscoverResult:
entry = server.get_request_handler("server/discover")
assert entry is not None
result = await entry.handler(_UNUSED_CTX, types.RequestParams())
assert isinstance(result, types.DiscoverResult)
return result
def test_registered_by_default() -> None:
"""SDK-defined: a bare `Server` registers a `server/discover` handler out of
the box, typed for the base `RequestParams`."""
server = Server("test-server")
entry = server.get_request_handler("server/discover")
assert entry is not None
assert entry.params_type is types.RequestParams
@pytest.mark.anyio
async def test_supported_versions_is_modern_set() -> None:
"""`supportedVersions` is exactly the modern envelope set, not the full
legacy-compat list (D-008)."""
result = await _discover(Server("test-server"))
assert result.supported_versions == list(MODERN_PROTOCOL_VERSIONS)
@pytest.mark.anyio
async def test_server_info_reflects_constructor_fields() -> None:
"""SDK-defined: `serverInfo` is built field-for-field from the `Server`
constructor arguments."""
icons = [types.Icon(src="https://example.test/icon.png")]
server = Server(
"info-server",
version="9.9.9",
title="Info Server",
description="A server for testing discover.",
website_url="https://example.test",
icons=icons,
)
result = await _discover(server)
assert result.server_info == types.Implementation(
name="info-server",
version="9.9.9",
title="Info Server",
description="A server for testing discover.",
website_url="https://example.test",
icons=icons,
)
@pytest.mark.anyio
async def test_server_info_version_falls_back_to_package() -> None:
"""SDK-defined: when no explicit version is supplied, `serverInfo.version`
falls back to the installed `mcp` package version."""
result = await _discover(Server("unversioned"))
assert result.server_info.version == importlib.metadata.version("mcp")
@pytest.mark.anyio
async def test_instructions_threaded_through() -> None:
"""SDK-defined: the `instructions` constructor argument is passed through
verbatim, defaulting to `None` when omitted."""
server = Server("inst-server", instructions="Read the docs first.")
result = await _discover(server)
assert result.instructions == "Read the docs first."
bare = await _discover(Server("bare"))
assert bare.instructions is None
@pytest.mark.anyio
async def test_capabilities_derived_from_registered_handlers() -> None:
"""SDK-defined: capabilities are computed at handler call time from the
live registry, so post-construction `add_request_handler` calls are
reflected."""
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
raise NotImplementedError
async def list_prompts(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListPromptsResult:
raise NotImplementedError
server = Server("cap-server", on_list_tools=list_tools)
before = await _discover(server)
assert before.capabilities.tools is not None
assert before.capabilities.prompts is None
server.add_request_handler("prompts/list", types.PaginatedRequestParams, list_prompts)
after = await _discover(server)
assert after.capabilities.tools is not None
assert after.capabilities.prompts is not None
@pytest.mark.anyio
async def test_discover_result_defaults_to_immediately_stale_private_cache() -> None:
"""SDK-defined: `DiscoverResult` is cacheable; the auto-derived handler
relies on the model defaults (immediately-stale, private)."""
result = await _discover(Server("cache-server"))
assert result.ttl_ms == 0
assert result.cache_scope == "private"
@pytest.mark.anyio
async def test_overridable_via_add_request_handler() -> None:
"""SDK-defined: a custom `server/discover` handler registered via
`add_request_handler` replaces the auto-derived default wholesale."""
server = Server("custom-server", version="1.0.0")
custom = types.DiscoverResult(
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
capabilities=types.ServerCapabilities(),
server_info=types.Implementation(name="custom-server", version="1.0.0"),
instructions="overridden",
ttl_ms=60_000,
cache_scope="public",
)
async def custom_discover(
ctx: ServerRequestContext[Any], params: types.RequestParams | None
) -> types.DiscoverResult:
return custom
server.add_request_handler("server/discover", types.RequestParams, custom_discover)
result = await _discover(server)
assert result is custom

View file

@ -1,5 +1,9 @@
from mcp.server.mcpserver import Context
import pytest
from mcp import Client, types
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.tools.base import Tool
from mcp.shared.exceptions import MCPError
def test_context_detected_in_union_annotation():
@ -8,3 +12,45 @@ def test_context_detected_in_union_annotation():
tool = Tool.from_function(my_tool)
assert tool.context_kwarg == "ctx"
@pytest.mark.anyio
async def test_mcperror_raised_from_a_tool_surfaces_as_a_top_level_jsonrpc_error_with_code_and_data_intact():
"""SDK-defined: ``MCPError`` carries JSON-RPC ``ErrorData(code, message, data)``
and means "respond with a protocol error". The tool wrapper re-raises it so
the kernel writes a top-level JSON-RPC error - ``code`` and ``data`` survive
the round-trip rather than being flattened into ``CallToolResult(isError=True)``."""
mcp = MCPServer(name="srv")
@mcp.tool()
async def needs_sampling() -> str:
raise MCPError(
types.MISSING_REQUIRED_CLIENT_CAPABILITY,
"sampling capability required",
data={"requiredCapabilities": ["sampling"]},
)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("needs_sampling", {})
assert exc_info.value.error.code == types.MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data == {"requiredCapabilities": ["sampling"]}
@pytest.mark.anyio
async def test_non_mcperror_exception_raised_from_a_tool_is_wrapped_as_an_is_error_result():
"""SDK-defined: ordinary exceptions from a tool body are execution failures
the LLM should see, so they become ``CallToolResult(isError=True)`` rather
than a protocol-level JSON-RPC error. Pins the other arm of the same branch."""
mcp = MCPServer(name="srv")
@mcp.tool()
async def boom() -> str:
raise RuntimeError("execution failure")
async with Client(mcp) as client:
result = await client.call_tool("boom", {})
assert isinstance(result, types.CallToolResult)
assert result.is_error is True

View file

@ -1,9 +1,11 @@
"""Tests for `Connection`.
`Connection` wraps an `Outbound` (the standalone stream). Its `notify` is
best-effort (never raises); `send_raw_request` is gated on
`has_standalone_channel`. Tested with a stub `Outbound` so we can assert wire
shape and inject failures.
`Connection` wraps an `Outbound` (the standalone stream). Construct it via the
`from_envelope` / `for_loop` factories so `protocol_version` is always
populated and `has_standalone_channel` is derived from the held outbound. Its
`notify` is best-effort (never raises); `send_raw_request` raises
`NoBackChannelError` structurally from the no-channel sentinel. Tested with a
stub `Outbound` so we can assert wire shape and inject failures.
"""
import logging
@ -17,6 +19,7 @@ from pydantic import BaseModel, ValidationError
from mcp.server.connection import Connection
from mcp.shared.dispatcher import CallOptions
from mcp.shared.exceptions import NoBackChannelError
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.types import (
LATEST_PROTOCOL_VERSION,
ClientCapabilities,
@ -25,7 +28,6 @@ from mcp.types import (
ElicitationCapability,
EmptyResult,
Implementation,
InitializeRequestParams,
ListRootsRequest,
ListRootsResult,
PingRequest,
@ -37,13 +39,8 @@ from mcp.types import (
SamplingToolsCapability,
)
def _client_params(capabilities: ClientCapabilities) -> InitializeRequestParams:
return InitializeRequestParams(
protocol_version=LATEST_PROTOCOL_VERSION,
capabilities=capabilities,
client_info=Implementation(name="t", version="0"),
)
_CLIENT_INFO = Implementation(name="t", version="0")
_MODERN = MODERN_PROTOCOL_VERSIONS[0]
class StubOutbound:
@ -67,10 +64,83 @@ class StubOutbound:
self.notifications.append((method, params))
# --- factories -----------------------------------------------------------------
def test_from_envelope_is_born_ready_with_no_back_channel():
"""SDK-defined: `from_envelope` populates `protocol_version`, sets `initialized`,
and holds the no-channel sentinel so `has_standalone_channel` derives False."""
conn = Connection.from_envelope(_MODERN, None, None)
assert conn.protocol_version == _MODERN
assert conn.initialized.is_set()
assert conn.initialize_accepted is True
assert conn.has_standalone_channel is False
assert conn.client_params is None
assert conn.session_id is None
def test_from_envelope_records_client_params_when_both_info_and_caps_supplied():
"""SDK-defined: when both client info and capabilities are supplied,
`from_envelope` synthesizes `client_params` so capability checks can run."""
caps = ClientCapabilities(sampling=SamplingCapability())
conn = Connection.from_envelope(_MODERN, _CLIENT_INFO, caps)
assert conn.client_params is not None
assert conn.client_params.client_info.name == "t"
assert conn.client_params.capabilities.sampling is not None
assert conn.client_params.protocol_version == _MODERN
@pytest.mark.parametrize(
("info", "caps"),
[(None, ClientCapabilities()), (_CLIENT_INFO, None)],
)
def test_from_envelope_leaves_client_params_none_when_either_is_missing(
info: Implementation | None, caps: ClientCapabilities | None
):
"""SDK-defined: `client_params` is only synthesized when both info and
caps are present; either missing leaves it `None`."""
conn = Connection.from_envelope(_MODERN, info, caps)
assert conn.client_params is None
def test_from_envelope_with_explicit_outbound_has_standalone_channel():
"""SDK-defined: duplex modern transports pass an outbound; `has_standalone_channel`
derives True since the held outbound is not the no-channel sentinel."""
out = StubOutbound()
conn = Connection.from_envelope(_MODERN, None, None, outbound=out)
assert conn.has_standalone_channel is True
assert conn.outbound is out
assert conn.initialized.is_set()
def test_for_loop_seeds_version_from_hint_or_latest_and_is_not_born_ready():
"""SDK-defined: `for_loop` seeds `protocol_version` from the hint when given,
else `LATEST_PROTOCOL_VERSION`; the connection awaits the initialize handshake."""
out = StubOutbound()
conn = Connection.for_loop(out)
assert conn.protocol_version == LATEST_PROTOCOL_VERSION
assert conn.has_standalone_channel is True
assert not conn.initialized.is_set()
assert conn.initialize_accepted is False
assert conn.client_params is None
hinted = Connection.for_loop(out, protocol_version_hint=_MODERN)
assert hinted.protocol_version == _MODERN
def test_for_loop_records_session_id_when_supplied():
"""SDK-defined: `for_loop` stores the `session_id` kwarg verbatim."""
conn = Connection.for_loop(StubOutbound(), session_id="sess-1")
assert conn.session_id == "sess-1"
# --- outbound channel ----------------------------------------------------------
@pytest.mark.anyio
async def test_connection_notify_forwards_to_outbound():
out = StubOutbound()
conn = Connection(out, has_standalone_channel=True)
conn = Connection.for_loop(out)
await conn.notify("notifications/message", {"level": "info", "data": "hi"})
assert out.notifications == [("notifications/message", {"level": "info", "data": "hi"})]
@ -79,24 +149,24 @@ async def test_connection_notify_forwards_to_outbound():
async def test_connection_notify_swallows_broken_stream_and_debug_logs(caplog: pytest.LogCaptureFixture):
caplog.set_level(logging.DEBUG, logger="mcp.server.connection")
out = StubOutbound(raise_on_send=anyio.BrokenResourceError)
conn = Connection(out, has_standalone_channel=True)
conn = Connection.for_loop(out)
await conn.notify("notifications/message", {"data": "x"}) # must not raise
assert "stream closed" in caplog.text.lower()
@pytest.mark.anyio
async def test_connection_notify_drops_when_no_standalone_channel(caplog: pytest.LogCaptureFixture):
"""SDK-defined: the no-channel sentinel debug-logs and drops; `notify` never raises."""
caplog.set_level(logging.DEBUG, logger="mcp.server.connection")
out = StubOutbound()
conn = Connection(out, has_standalone_channel=False)
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
await conn.notify("notifications/message", {"data": "x"}) # must not raise
assert out.notifications == []
assert "no standalone channel" in caplog.text.lower()
@pytest.mark.anyio
async def test_connection_send_raw_request_raises_nobackchannel_when_no_standalone_channel():
conn = Connection(StubOutbound(), has_standalone_channel=False)
"""SDK-defined: the no-channel sentinel raises structurally; `Connection` does no pre-check."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
with pytest.raises(NoBackChannelError):
await conn.send_raw_request("ping", None)
@ -104,7 +174,7 @@ async def test_connection_send_raw_request_raises_nobackchannel_when_no_standalo
@pytest.mark.anyio
async def test_connection_send_raw_request_forwards_when_standalone_channel_present():
out = StubOutbound()
conn = Connection(out, has_standalone_channel=True)
conn = Connection.for_loop(out)
result = await conn.send_raw_request("ping", None)
assert out.requests == [("ping", None)]
assert result == {}
@ -113,7 +183,7 @@ async def test_connection_send_raw_request_forwards_when_standalone_channel_pres
@pytest.mark.anyio
async def test_connection_send_request_with_spec_type_infers_result_type():
out = StubOutbound(result={"roots": [{"uri": "file:///ws"}]})
conn = Connection(out, has_standalone_channel=True)
conn = Connection.for_loop(out)
result = await conn.send_request(ListRootsRequest())
method, _ = out.requests[0]
assert method == "roots/list"
@ -126,7 +196,7 @@ async def test_connection_send_request_validates_result_alias_only():
"""Peer results validate alias-only; a snake_case key from the wire is
ignored as extra, not populated by Python field name."""
snake = {"role": "assistant", "content": {"type": "text", "text": "x"}, "model": "m", "stop_reason": "endTurn"}
conn = Connection(StubOutbound(result=snake), has_standalone_channel=True)
conn = Connection.for_loop(StubOutbound(result=snake))
result = await conn.send_request(CreateMessageRequest(params=CreateMessageRequestParams(messages=[], max_tokens=1)))
assert result.stop_reason is None
@ -134,14 +204,14 @@ async def test_connection_send_request_validates_result_alias_only():
@pytest.mark.anyio
async def test_connection_send_request_with_result_type_kwarg_validates_custom_type():
out = StubOutbound(result={})
conn = Connection(out, has_standalone_channel=True)
conn = Connection.for_loop(out)
result = await conn.send_request(PingRequest(), result_type=EmptyResult)
assert isinstance(result, EmptyResult)
@pytest.mark.anyio
async def test_connection_send_request_nonconforming_result_raises_validation_error():
conn = Connection(StubOutbound(result={"bogus": 1}), has_standalone_channel=True)
conn = Connection.for_loop(StubOutbound(result={"bogus": 1}))
with pytest.raises(ValidationError):
await conn.send_request(ListRootsRequest())
@ -150,7 +220,7 @@ async def test_connection_send_request_nonconforming_result_raises_validation_er
async def test_send_request_validates_the_client_result_against_the_surface_schema():
"""A spec-method result that fails the per-version surface schema raises
`ValidationError` even when the caller's `result_type` would accept it."""
conn = Connection(StubOutbound(result={"roots": "nope"}), has_standalone_channel=True)
conn = Connection.for_loop(StubOutbound(result={"roots": "nope"}))
with pytest.raises(ValidationError):
await conn.send_request(ListRootsRequest(), result_type=EmptyResult)
@ -158,8 +228,8 @@ async def test_send_request_validates_the_client_result_against_the_surface_sche
@pytest.mark.anyio
async def test_send_request_passes_a_spec_valid_client_result():
"""A spec-valid client result passes the surface gate and parses to the typed model."""
conn = Connection(StubOutbound(result={"roots": [{"uri": "file:///ws"}]}), has_standalone_channel=True)
conn.protocol_version = "2025-11-25"
conn = Connection.for_loop(StubOutbound(result={"roots": [{"uri": "file:///ws"}]}))
assert conn.protocol_version == LATEST_PROTOCOL_VERSION
result = await conn.send_request(ListRootsRequest())
assert isinstance(result, ListRootsResult)
assert str(result.roots[0].uri) == "file:///ws"
@ -178,8 +248,7 @@ class _CustomResult(BaseModel):
async def test_send_request_skips_the_surface_gate_when_method_absent_at_version():
"""Surface row absent for the negotiated version: gate is bypassed and only
the inferred result type validates."""
conn = Connection(StubOutbound(result={}), has_standalone_channel=True)
conn.protocol_version = "2026-07-28"
conn = Connection.for_loop(StubOutbound(result={}), protocol_version_hint=_MODERN)
result = await conn.send_request(PingRequest())
assert isinstance(result, EmptyResult)
@ -187,8 +256,7 @@ async def test_send_request_skips_the_surface_gate_when_method_absent_at_version
@pytest.mark.anyio
async def test_send_request_with_a_custom_method_skips_the_surface_gate():
"""Non-spec methods are not blocked by the surface gate; `result_type` validates."""
conn = Connection(StubOutbound(result={"value": 7}), has_standalone_channel=True)
conn.protocol_version = "2025-11-25"
conn = Connection.for_loop(StubOutbound(result={"value": 7}))
result = await conn.send_request(_CustomRequest(), result_type=_CustomResult)
assert isinstance(result, _CustomResult)
assert result.value == 7
@ -197,7 +265,7 @@ async def test_send_request_with_a_custom_method_skips_the_surface_gate():
@pytest.mark.anyio
async def test_connection_ping_sends_ping_on_standalone():
out = StubOutbound()
conn = Connection(out, has_standalone_channel=True)
conn = Connection.for_loop(out)
await conn.ping()
assert out.requests == [("ping", None)]
@ -205,7 +273,7 @@ async def test_connection_ping_sends_ping_on_standalone():
@pytest.mark.anyio
async def test_connection_log_sends_logging_message_notification():
out = StubOutbound()
conn = Connection(out, has_standalone_channel=True)
conn = Connection.for_loop(out)
await conn.log("info", {"k": "v"}, logger="my.logger") # pyright: ignore[reportDeprecated]
method, params = out.notifications[0]
assert method == "notifications/message"
@ -218,7 +286,7 @@ async def test_connection_log_sends_logging_message_notification():
@pytest.mark.anyio
async def test_connection_log_with_meta_includes_meta_in_params():
out = StubOutbound()
conn = Connection(out, has_standalone_channel=True)
conn = Connection.for_loop(out)
await conn.log("info", "x", meta={"traceId": "abc"}) # pyright: ignore[reportDeprecated]
_, params = out.notifications[0]
assert params is not None
@ -228,7 +296,7 @@ async def test_connection_log_with_meta_includes_meta_in_params():
@pytest.mark.anyio
async def test_connection_list_changed_notifications_send_correct_methods():
out = StubOutbound()
conn = Connection(out, has_standalone_channel=True)
conn = Connection.for_loop(out)
await conn.send_tool_list_changed()
await conn.send_prompt_list_changed()
await conn.send_resource_list_changed()
@ -246,14 +314,21 @@ async def test_connection_list_changed_notifications_send_correct_methods():
@pytest.mark.anyio
async def test_connection_send_tool_list_changed_with_meta_includes_meta_only_params():
out = StubOutbound()
conn = Connection(out, has_standalone_channel=True)
conn = Connection.for_loop(out)
await conn.send_tool_list_changed(meta={"k": 1})
assert out.notifications == [("notifications/tools/list_changed", {"_meta": {"k": 1}})]
def test_connection_check_capability_false_before_initialized():
conn = Connection(StubOutbound(), has_standalone_channel=True)
# --- check_capability ----------------------------------------------------------
def test_connection_check_capability_false_when_no_client_params_recorded():
"""SDK-defined: `check_capability` returns False when no `client_params`
were recorded, regardless of which factory built the connection."""
conn = Connection.for_loop(StubOutbound())
assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is False
# Same for a born-ready connection that supplied neither info nor caps.
assert Connection.from_envelope(_MODERN, None, None).check_capability(ClientCapabilities()) is False
@pytest.mark.parametrize(
@ -281,6 +356,11 @@ def test_connection_check_capability_false_before_initialized():
ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())),
True,
),
(
ClientCapabilities(sampling=SamplingCapability(context=SamplingContextCapability())),
ClientCapabilities(sampling=SamplingCapability(context=SamplingContextCapability())),
True,
),
(ClientCapabilities(experimental=None), ClientCapabilities(experimental={"a": {}}), False),
(ClientCapabilities(experimental={"a": {}}), ClientCapabilities(experimental={"b": {}}), False),
(ClientCapabilities(experimental={"a": {"x": 1}}), ClientCapabilities(experimental={"a": {"x": 2}}), False),
@ -288,17 +368,16 @@ def test_connection_check_capability_false_before_initialized():
],
)
def test_check_capability_per_field_branches(have: ClientCapabilities, want: ClientCapabilities, expected: bool):
conn = Connection(StubOutbound(), has_standalone_channel=True)
conn.client_params = _client_params(have)
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, _CLIENT_INFO, have)
assert conn.check_capability(want) is expected
def test_connection_check_capability_true_when_client_declares_it():
conn = Connection(StubOutbound(), has_standalone_channel=True)
conn.client_params = _client_params(
ClientCapabilities(sampling=SamplingCapability(), roots=RootsCapability(list_changed=True))
conn = Connection.from_envelope(
LATEST_PROTOCOL_VERSION,
_CLIENT_INFO,
ClientCapabilities(sampling=SamplingCapability(), roots=RootsCapability(list_changed=True)),
)
conn.initialized.set()
assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is True
assert conn.check_capability(ClientCapabilities(roots=RootsCapability(list_changed=True))) is True
assert conn.check_capability(ClientCapabilities(elicitation=ElicitationCapability())) is False

View file

@ -39,7 +39,7 @@ async def test_server_run_exits_cleanly_when_transport_yields_exception_then_clo
with anyio.fail_after(5):
# stateless=True so server.run doesn't wait for initialize handshake.
# Before the fix, this raised ExceptionGroup(ClosedResourceError).
await server.run(read_recv, write_send, server.create_initialization_options(), stateless=True)
await server.run(read_recv, write_send, server.create_initialization_options())
# write_send was closed inside run's `async with`; receive_nowait raises
# EndOfStream iff the buffer is empty (i.e., server wrote nothing).

View file

@ -1,49 +1,63 @@
"""Tests for `ServerRunner`.
"""Tests for `ServerRunner` and the free-function drivers.
End-to-end over `JSONRPCDispatcher` with a real lowlevel `Server` as the
registry. The `connected_runner` helper starts both sides and (by default)
performs the initialize handshake, so each test exercises only the behaviour
under test.
The kernel tests run end-to-end over `JSONRPCDispatcher` with a real lowlevel
`Server` as the registry. The `connected_runner` helper starts both sides and
(by default) performs the initialize handshake, so each test exercises only the
behaviour under test. Driver tests (`serve_connection`, `serve_one`,
`to_jsonrpc_response`, `aclose_shielded`) follow at the bottom.
"""
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from functools import partial
from typing import Any, cast
import anyio
import anyio.abc
import pytest
from opentelemetry.trace import SpanKind, StatusCode
import mcp.server.runner
from mcp.server.connection import Connection
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel.server import NotificationOptions, Server
from mcp.server.models import InitializationOptions
from mcp.server.runner import ServerRunner, _extract_meta, _resolve_protocol_version, otel_middleware
from mcp.server.runner import (
ServerRunner,
_extract_meta,
aclose_shielded,
otel_middleware,
serve_connection,
serve_one,
to_jsonrpc_response,
)
from mcp.server.session import ServerSession
from mcp.shared.dispatcher import DispatchContext, DispatchMiddleware, OnRequest
from mcp.shared.dispatcher import CallOptions, DispatchContext, DispatchMiddleware, OnRequest
from mcp.shared.exceptions import MCPError
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata
from mcp.shared.message import MessageMetadata
from mcp.shared.peer import dump_params
from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS
from mcp.types import (
INTERNAL_ERROR,
INVALID_PARAMS,
LATEST_PROTOCOL_VERSION,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
CallToolRequestParams,
ClientCapabilities,
ErrorData,
Implementation,
InitializeRequestParams,
JSONRPCError,
JSONRPCRequest,
JSONRPCResponse,
ListToolsResult,
NotificationParams,
PaginatedRequestParams,
ProgressNotificationParams,
RequestParams,
RequestParamsMeta,
SetLevelRequestParams,
Tool,
)
@ -84,36 +98,46 @@ async def connected_runner(
server: SrvT,
*,
initialized: bool = True,
stateless: bool = False,
has_standalone_channel: bool = True,
init_options: InitializationOptions | None = None,
session_id: str | None = None,
dispatch_middleware: list[DispatchMiddleware] | None = None,
connection: Connection | None = None,
) -> AsyncIterator[tuple[JSONRPCDispatcher[TransportContext], ServerRunner[dict[str, Any]]]]:
"""Yield `(client, runner)` running over an in-memory JSON-RPC dispatcher pair.
Starts the client (echo handlers) and `runner.run()` in a task group, wraps
the body in `anyio.fail_after(5)`, and cancels on exit. When
`initialized` is true the helper performs the real `initialize` request
before yielding, so tests start past the init-gate via the public path.
Starts the client (echo handlers) and the server-side dispatcher loop
(kernel `on_request`/`on_notify` + `aclose_shielded` teardown - the
`serve_connection` shape) in a task group, wraps the body in
`anyio.fail_after(5)`, and cancels on exit. When `initialized` is true the
helper performs the real `initialize` request before yielding, so tests
start past the init-gate via the public path.
`connection` defaults to `Connection.for_loop(server_dispatcher)`. Pass a
factory-built connection (e.g. `Connection.from_envelope(...)`) to exercise
the born-ready path; the kernel reads it as a fact and is mode-agnostic.
"""
client, server_d, close = jsonrpc_pair()
assert isinstance(client, JSONRPCDispatcher) and isinstance(server_d, JSONRPCDispatcher)
if connection is None:
connection = Connection.for_loop(server_d)
runner = ServerRunner(
server=server,
dispatcher=server_d,
connection=connection,
lifespan_state={},
has_standalone_channel=has_standalone_channel,
init_options=init_options,
session_id=session_id,
stateless=stateless,
dispatch_middleware=dispatch_middleware or [],
)
c_req, c_notify = echo_handlers(Recorder())
body_exc: BaseException | None = None
async def _drive(*, task_status: anyio.abc.TaskStatus[None]) -> None:
try:
await server_d.run(runner.on_request, runner.on_notify, task_status=task_status)
finally:
await aclose_shielded(connection)
async with anyio.create_task_group() as tg:
await tg.start(client.run, c_req, c_notify)
await tg.start(runner.run)
await tg.start(_drive)
try:
with anyio.fail_after(5):
if initialized:
@ -219,11 +243,21 @@ async def test_runner_routes_to_handler_and_builds_context(server: SrvT):
assert isinstance(ctx, ServerRequestContext)
assert ctx.lifespan_context == {}
assert isinstance(ctx.session, ServerSession)
assert ctx.session is runner.session
assert ctx.session.protocol_version == runner.connection.protocol_version
assert ctx.request_id is not None
assert ctx.protocol_version == LATEST_PROTOCOL_VERSION
@pytest.mark.anyio
async def test_runner_builds_a_fresh_session_per_request(server: SrvT):
"""`ctx.session` is built per-request from the per-request `DispatchContext`
and the connection's standalone outbound; it is not connection-scoped."""
async with connected_runner(server) as (client, _):
await client.send_raw_request("tools/list", None)
await client.send_raw_request("tools/list", None)
assert _seen_ctx[0].session is not _seen_ctx[1].session
@pytest.mark.anyio
async def test_runner_spec_method_with_no_handler_raises_method_not_found(server: SrvT):
async with connected_runner(server) as (client, _):
@ -605,7 +639,6 @@ async def test_runner_middleware_raise_after_call_next_on_initialize_leaves_conn
assert await client.send_raw_request("ping", None) == {}
assert runner.connection.initialize_accepted is False
assert runner.connection.client_params is None
assert runner.connection.protocol_version is None
assert not runner.connection.initialized.is_set()
@ -656,65 +689,6 @@ async def test_runner_server_middleware_wraps_notifications(server: SrvT):
]
def test_resolve_protocol_version_handshake_committed_value_wins():
md = ServerMessageMetadata(protocol_version="2025-03-26")
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "2025-03-26"}
assert _resolve_protocol_version("2025-06-18", meta, md) == "2025-06-18"
def test_resolve_protocol_version_reads_per_request_meta_when_no_handshake():
md = ServerMessageMetadata(protocol_version="2025-03-26")
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "2025-06-18"}
assert _resolve_protocol_version(None, meta, md) == "2025-06-18"
def test_resolve_protocol_version_skips_unsupported_meta_value():
md = ServerMessageMetadata(protocol_version="2025-03-26")
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "1900-01-01"}
assert _resolve_protocol_version(None, meta, md) == "2025-03-26"
def test_resolve_protocol_version_skips_non_string_meta_value():
md = ServerMessageMetadata(protocol_version="2025-03-26")
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: 42}
assert _resolve_protocol_version(None, meta, md) == "2025-03-26"
def test_resolve_protocol_version_reads_transport_hint_when_no_handshake_or_meta():
md = ServerMessageMetadata(protocol_version="2025-06-18")
assert _resolve_protocol_version(None, None, md) == "2025-06-18"
assert _resolve_protocol_version(None, {}, md) == "2025-06-18"
def test_resolve_protocol_version_skips_unsupported_transport_hint():
"""The `initialize` params version reaches the metadata unvalidated; surface validation must never see it."""
md = ServerMessageMetadata(protocol_version="1900-01-01")
assert _resolve_protocol_version(None, None, md) == "2025-11-25"
def test_resolve_protocol_version_terminal_default_with_no_signals():
assert _resolve_protocol_version(None, None, None) == "2025-11-25"
assert _resolve_protocol_version(None, None, ServerMessageMetadata()) == "2025-11-25"
assert _resolve_protocol_version(None, None, ClientMessageMetadata()) == "2025-11-25"
@pytest.mark.anyio
async def test_runner_ctx_protocol_version_is_terminal_default_on_stateless_in_memory(server: SrvT):
async with connected_runner(server, initialized=False, stateless=True) as (client, runner):
await client.send_raw_request("tools/list", None)
ctx = _seen_ctx[0]
assert ctx.protocol_version == "2025-11-25"
assert ctx.session.protocol_version is None
assert runner.connection.protocol_version is None
@pytest.mark.anyio
async def test_runner_ctx_protocol_version_tracks_per_request_meta_on_stateless(server: SrvT):
async with connected_runner(server, initialized=False, stateless=True) as (client, _):
await client.send_raw_request("tools/list", {"_meta": {PROTOCOL_VERSION_META_KEY: "2025-06-18"}})
assert _seen_ctx[0].protocol_version == "2025-06-18"
def test_extract_meta_returns_none_for_absent_or_malformed():
"""Context construction is independent of `_meta` validity; the params
validation inside `call_next()` is what surfaces the error."""
@ -837,21 +811,16 @@ async def test_runner_handler_returning_unsupported_type_surfaces_as_error(serve
@pytest.mark.anyio
async def test_runner_stateless_skips_init_gate(server: SrvT):
async with connected_runner(server, initialized=False, stateless=True, has_standalone_channel=False) as (client, _):
result = await client.send_raw_request("tools/list", None)
assert result["tools"][0]["name"] == "t"
@pytest.mark.anyio
async def test_runner_stateless_connection_initialized_event_set_on_construction(server: SrvT):
"""`connection.initialized` mirrors the gate flag in stateless mode so
`await connection.initialized.wait()` does not hang when no handshake
arrives."""
async with connected_runner(server, initialized=False, stateless=True, has_standalone_channel=False) as (_, runner):
async def test_runner_with_born_ready_connection_skips_init_gate(server: SrvT):
"""A `Connection.from_envelope` connection is born ready: the kernel's
init-gate is open without any handshake. The kernel is mode-agnostic - the
same `on_request` reads `connection.initialize_accepted` as a fact."""
born_ready = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
async with connected_runner(server, initialized=False, connection=born_ready) as (client, runner):
assert runner.connection.initialize_accepted is True
assert runner.connection.initialized.is_set()
await runner.connection.initialized.wait()
result = await client.send_raw_request("tools/list", None)
assert result["tools"][0]["name"] == "t"
@pytest.mark.anyio
@ -954,6 +923,33 @@ async def test_runner_spec_method_absent_at_the_negotiated_version_is_method_not
assert exc.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="server/discover")
@pytest.mark.anyio
async def test_on_request_rejects_initialize_at_modern_version_with_method_not_found(server: SrvT):
"""Spec-mandated: `initialize` has no `CLIENT_REQUESTS` row at the modern
version; kernel dispatch (not the inbound classifier) rejects it."""
born_ready = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None)
async with connected_runner(server, initialized=False, connection=born_ready) as (client, runner):
assert runner.connection.protocol_version == MODERN_PROTOCOL_VERSIONS[0]
with pytest.raises(MCPError) as exc:
await client.send_raw_request("initialize", _initialize_params())
assert exc.value.error.code == METHOD_NOT_FOUND
@pytest.mark.anyio
async def test_on_request_dispatches_custom_method_registered_via_add_request_handler(server: SrvT):
"""SDK-defined: a method outside `SPEC_CLIENT_METHODS` skips the version
gate and reaches its registered handler at any negotiated version."""
async def echo(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
return {"echoed": True}
server.add_request_handler("myorg/echo", RequestParams, echo)
born_ready = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None)
async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
result = await client.send_raw_request("myorg/echo", None)
assert result == {"echoed": True}
@pytest.mark.anyio
async def test_runner_middleware_short_circuit_on_a_wrong_version_spec_method_skips_the_sieve(server: SrvT):
"""A server-tier middleware that returns without calling `call_next` for a
@ -1205,3 +1201,194 @@ async def test_runner_exit_stack_fast_cleanup_completes_within_grace(
await client.send_raw_request("tools/list", None)
assert cleaned == [2, 1]
assert "abandoning remaining callbacks" not in caplog.text
# --- to_jsonrpc_response -------------------------------------------------------
@pytest.mark.anyio
async def test_to_jsonrpc_response_wraps_success_as_jsonrpc_response():
"""SDK-defined: a handler coroutine resolving to a result dict is wrapped as a
`JSONRPCResponse` carrying the supplied id and the dict verbatim as `result`."""
async def ok() -> dict[str, Any]:
return {"k": "v"}
reply = await to_jsonrpc_response(7, ok())
assert isinstance(reply, JSONRPCResponse)
assert reply.id == 7
assert reply.result == {"k": "v"}
@pytest.mark.anyio
async def test_to_jsonrpc_response_maps_mcp_error_to_jsonrpc_error():
"""SDK-defined: an `MCPError` raised by the handler coroutine is wrapped as a
`JSONRPCError` whose `error` carries the same code, message, and data."""
async def fail() -> dict[str, Any]:
raise MCPError(code=METHOD_NOT_FOUND, message="nope", data="x")
reply = await to_jsonrpc_response("rid", fail())
assert isinstance(reply, JSONRPCError)
assert reply.id == "rid"
assert reply.error == ErrorData(code=METHOD_NOT_FOUND, message="nope", data="x")
@pytest.mark.anyio
async def test_to_jsonrpc_response_maps_validation_error_to_invalid_params():
"""SDK-defined: a pydantic `ValidationError` escaping the handler coroutine is
mapped to `INVALID_PARAMS` with a generic message (validator detail does not
reach the wire)."""
async def fail() -> dict[str, Any]:
Tool.model_validate({"name": 123}) # raises ValidationError
raise NotImplementedError
reply = await to_jsonrpc_response(1, fail())
assert isinstance(reply, JSONRPCError)
assert reply.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
@pytest.mark.anyio
async def test_to_jsonrpc_response_maps_unmapped_exception_to_internal_error_and_logs(
caplog: pytest.LogCaptureFixture,
):
"""SDK-defined: an unmapped exception is logged server-side and surfaced as
`INTERNAL_ERROR` with a generic message; the exception text never reaches the
wire."""
async def fail() -> dict[str, Any]:
raise RuntimeError("boom")
reply = await to_jsonrpc_response(1, fail())
assert isinstance(reply, JSONRPCError)
assert reply.error.code == INTERNAL_ERROR
# Handler internals never reach the wire.
assert "boom" not in reply.error.message
assert "request handler raised" in caplog.text
# --- aclose_shielded -----------------------------------------------------------
@pytest.mark.anyio
async def test_aclose_shielded_runs_callbacks_under_outer_cancellation():
"""The shield lets per-connection cleanup run even when the enclosing scope
is being cancelled."""
cleaned: list[int] = []
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
async def _append() -> None:
await anyio.sleep(0)
cleaned.append(1)
conn.exit_stack.push_async_callback(_append)
with anyio.CancelScope() as scope:
scope.cancel()
await aclose_shielded(conn)
assert cleaned == [1]
# --- serve_one / serve_connection ---------------------------------------------
@dataclass
class _StubDispatchContext:
"""Minimal `DispatchContext` for `serve_one` driver tests.
The modern entry hands a per-request context to `serve_one`; this stub
satisfies the protocol structurally with no real back-channel.
"""
request_id: int | str | None
transport: TransportContext = field(default_factory=lambda: TransportContext(kind="direct", can_send_request=False))
message_metadata: MessageMetadata = None
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
can_send_request: bool = False
async def send_raw_request(
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
) -> dict[str, Any]:
raise NotImplementedError
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
raise NotImplementedError
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
raise NotImplementedError
async def _append_async(dst: list[int], v: int) -> None:
dst.append(v)
_LIFESPAN: dict[str, Any] = {}
@pytest.mark.anyio
async def test_serve_one_runs_handler_and_returns_jsonrpc_response(server: SrvT):
"""The single-exchange driver: builds the kernel, runs `on_request` once,
wraps via `to_jsonrpc_response`, and tears down `connection.exit_stack`."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
cleaned: list[int] = []
conn.exit_stack.push_async_callback(_append_async, cleaned, 1)
request = JSONRPCRequest(jsonrpc="2.0", id=9, method="tools/list", params=None)
reply = await serve_one(server, request, connection=conn, dctx=_StubDispatchContext(9), lifespan_state=_LIFESPAN)
assert isinstance(reply, JSONRPCResponse)
assert reply.id == 9
assert reply.result["tools"][0]["name"] == "t"
assert cleaned == [1]
ctx = _seen_ctx[0]
assert ctx.protocol_version == LATEST_PROTOCOL_VERSION
@pytest.mark.anyio
async def test_serve_one_maps_error_to_jsonrpc_error_and_still_closes_exit_stack(server: SrvT):
"""SDK-defined: a kernel-produced error (here `METHOD_NOT_FOUND` for an
unregistered method) is wrapped as a `JSONRPCError`, and the per-request
exit stack is closed on the error path too."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
cleaned: list[int] = []
conn.exit_stack.push_async_callback(_append_async, cleaned, 1)
request = JSONRPCRequest(jsonrpc="2.0", id=2, method="resources/list", params=None)
reply = await serve_one(server, request, connection=conn, dctx=_StubDispatchContext(2), lifespan_state=_LIFESPAN)
assert isinstance(reply, JSONRPCError)
assert reply.error.code == METHOD_NOT_FOUND
assert cleaned == [1]
@pytest.mark.anyio
async def test_serve_one_reads_connection_protocol_version_as_a_fact(server: SrvT):
"""`serve_one` builds the kernel over the entry's `Connection`; the kernel
reads `connection.protocol_version` for the version gate. A `from_envelope`
connection at a modern version rejects a method absent there."""
conn = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None)
request = JSONRPCRequest(jsonrpc="2.0", id=1, method="logging/setLevel", params={"level": "info"})
reply = await serve_one(server, request, connection=conn, dctx=_StubDispatchContext(1), lifespan_state=_LIFESPAN)
assert isinstance(reply, JSONRPCError)
assert reply.error.code == METHOD_NOT_FOUND
@pytest.mark.anyio
async def test_serve_connection_drives_dispatcher_loop_and_tears_down(server: SrvT):
"""The loop-mode driver: `serve_connection` builds the kernel, hands
`on_request`/`on_notify` to `dispatcher.run()`, and `aclose_shielded`s the
connection on the way out."""
client, server_d, close = jsonrpc_pair()
assert isinstance(client, JSONRPCDispatcher) and isinstance(server_d, JSONRPCDispatcher)
conn = Connection.for_loop(server_d)
cleaned: list[int] = []
conn.exit_stack.push_async_callback(_append_async, cleaned, 1)
c_req, c_notify = echo_handlers(Recorder())
async with anyio.create_task_group() as tg:
await tg.start(client.run, c_req, c_notify)
await tg.start(partial(serve_connection, server, server_d, connection=conn, lifespan_state=_LIFESPAN))
with anyio.fail_after(5):
await client.send_raw_request("initialize", _initialize_params())
result = await client.send_raw_request("tools/list", None)
assert result["tools"][0]["name"] == "t"
assert cleaned == []
close()
assert cleaned == [1]
assert conn.protocol_version == LATEST_PROTOCOL_VERSION
assert conn.client_params is not None

View file

@ -31,21 +31,20 @@ class _Lifespan:
@pytest.mark.anyio
async def test_context_exposes_lifespan_and_connection_and_forwards_base_context():
captured: list[Context[_Lifespan]] = []
conn = Connection.__new__(Connection) # placeholder until running_pair gives us the dispatcher
conn_holder: list[Connection] = []
async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=conn)
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=conn_holder[0])
captured.append(ctx)
return {}
async with running_pair(direct_pair, server_on_request=server_on_request) as (client, server, *_):
# Now we have the server dispatcher; build the real Connection bound to it.
conn.__init__(server, has_standalone_channel=True, session_id="sess-1")
conn_holder.append(Connection.for_loop(server, session_id="sess-1"))
with anyio.fail_after(5):
await client.send_raw_request("t", None)
ctx = captured[0]
assert ctx.lifespan.name == "app"
assert ctx.connection is conn
assert ctx.connection is conn_holder[0]
assert ctx.transport.kind == "direct"
assert ctx.can_send_request is True
assert ctx.session_id == "sess-1"
@ -58,9 +57,7 @@ async def test_context_log_sends_request_scoped_message_notification():
_, c_notify = echo_handlers(crec)
async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
ctx: Context[_Lifespan] = Context(
dctx, lifespan=_Lifespan("app"), connection=Connection(dctx, has_standalone_channel=True)
)
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=Connection.for_loop(dctx))
await ctx.log("debug", "hello") # pyright: ignore[reportDeprecated]
return {}
@ -82,9 +79,7 @@ async def test_context_log_includes_logger_and_meta_when_supplied():
_, c_notify = echo_handlers(crec)
async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
ctx: Context[_Lifespan] = Context(
dctx, lifespan=_Lifespan("app"), connection=Connection(dctx, has_standalone_channel=True)
)
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=Connection.for_loop(dctx))
await ctx.log("info", "x", logger="my.log", meta={"traceId": "t"}) # pyright: ignore[reportDeprecated]
return {}

View file

@ -1,41 +1,38 @@
"""Tests for `ServerSession`.
`ServerSession` is a thin proxy over a dispatcher and a `Connection`. Tested
with a stub dispatcher so we can assert what reaches the wire (method, params,
`CallOptions`, related-request-id) without standing up a full transport.
`ServerSession` is a thin per-request proxy over two `Outbound` channels and a
`Connection`. Tested with stub outbounds so we can assert what reaches the wire
(method, params, `CallOptions`) and which channel it routed to, without standing
up a transport.
"""
from collections.abc import Mapping
from typing import Any, cast
from typing import Any
import pytest
from pydantic import ValidationError
from mcp import types
from mcp.server import Server, ServerRequestContext
from mcp.server.connection import Connection
from mcp.server.session import ServerSession
from mcp.shared.dispatcher import CallOptions
from mcp.shared.exceptions import NoBackChannelError
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.dispatcher import CallOptions, Outbound
from mcp.shared.message import ServerMessageMetadata
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.types import (
LATEST_PROTOCOL_VERSION,
ClientCapabilities,
Implementation,
InitializeRequestParams,
SamplingCapability,
SamplingToolsCapability,
)
from .test_runner import connected_runner
class StubDispatcher:
class StubOutbound:
"""Records `send_raw_request` / `notify` calls and returns a canned result."""
def __init__(self, result: dict[str, Any] | None = None) -> None:
self.requests: list[tuple[str, Mapping[str, Any] | None, CallOptions | None, Any]] = []
self.requests: list[tuple[str, Mapping[str, Any] | None, CallOptions | None]] = []
self.notifications: list[tuple[str, Mapping[str, Any] | None]] = []
self.result = result if result is not None else {}
async def send_raw_request(
@ -43,40 +40,36 @@ class StubDispatcher:
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
*,
_related_request_id: Any = None,
) -> dict[str, Any]:
self.requests.append((method, params, opts, _related_request_id))
self.requests.append((method, params, opts))
return self.result
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
raise NotImplementedError
self.notifications.append((method, params))
def _make_session(
dispatcher: StubDispatcher,
outbound: StubOutbound,
*,
capabilities: ClientCapabilities | None = None,
has_standalone_channel: bool = True,
protocol_version: str | None = None,
protocol_version: str = LATEST_PROTOCOL_VERSION,
) -> ServerSession:
conn = Connection(dispatcher, has_standalone_channel=has_standalone_channel)
conn.protocol_version = protocol_version
if capabilities is not None:
conn.client_params = InitializeRequestParams(
protocol_version=LATEST_PROTOCOL_VERSION,
capabilities=capabilities,
client_info=Implementation(name="c", version="0"),
)
# cast: `ServerSession` is typed to take `JSONRPCDispatcher` but only ever
# calls `send_raw_request` / `notify`, so the stub is structurally sufficient.
return ServerSession(cast("JSONRPCDispatcher[Any]", dispatcher), conn)
"""Single-channel session: the stub is both request and standalone outbound."""
client_info = Implementation(name="c", version="0") if capabilities is not None else None
conn = Connection.from_envelope(protocol_version, client_info, capabilities, outbound=outbound)
return ServerSession(outbound, conn)
def _two_channel_session(request_ch: Outbound, standalone_ch: Outbound) -> ServerSession:
"""Distinct request/standalone outbounds so routing assertions can tell the channels apart."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None, outbound=standalone_ch)
return ServerSession(request_ch, conn)
@pytest.mark.anyio
async def test_send_request_forwards_timeout_and_progress_callback_as_call_options():
dispatcher = StubDispatcher(result={"roots": []})
session = _make_session(dispatcher)
outbound = StubOutbound(result={"roots": []})
session = _make_session(outbound)
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
raise NotImplementedError
@ -85,57 +78,78 @@ async def test_send_request_forwards_timeout_and_progress_callback_as_call_optio
types.ListRootsRequest(),
types.ListRootsResult,
request_read_timeout_seconds=2.5,
metadata=ServerMessageMetadata(related_request_id=7),
progress_callback=on_progress,
)
assert isinstance(result, types.ListRootsResult)
method, _params, opts, related = dispatcher.requests[0]
method, _params, opts = outbound.requests[0]
assert method == "roots/list"
assert opts == {"timeout": 2.5, "on_progress": on_progress}
assert related == 7
@pytest.mark.anyio
async def test_send_request_omits_call_options_when_none_given():
dispatcher = StubDispatcher(result={"roots": []})
session = _make_session(dispatcher)
outbound = StubOutbound(result={"roots": []})
session = _make_session(outbound)
await session.send_request(types.ListRootsRequest(), types.ListRootsResult)
_method, _params, opts, related = dispatcher.requests[0]
_method, _params, opts = outbound.requests[0]
assert opts is None
assert related is None
@pytest.mark.anyio
async def test_send_request_timeout_zero_is_forwarded():
"""0 is a real timeout (fail at the first checkpoint, `anyio.fail_after(0)`
semantics) and must reach the dispatcher; only `None` means "no timeout"."""
dispatcher = StubDispatcher(result={})
session = _make_session(dispatcher)
semantics) and must reach the channel; only `None` means "no timeout"."""
outbound = StubOutbound(result={})
session = _make_session(outbound)
await session.send_request(types.PingRequest(), types.EmptyResult, request_read_timeout_seconds=0.0)
assert dispatcher.requests[0][2] == {"timeout": 0.0}
assert outbound.requests[0][2] == {"timeout": 0.0}
@pytest.mark.anyio
async def test_send_request_without_back_channel_or_related_id_fails_fast():
"""No standalone channel and no related request to ride on: raise instead
of parking forever on a response that cannot arrive."""
dispatcher = StubDispatcher(result={})
session = _make_session(dispatcher, has_standalone_channel=False)
with pytest.raises(NoBackChannelError):
await session.send_request(types.PingRequest(), types.EmptyResult)
assert dispatcher.requests == []
# With a related request id the message rides that request's stream.
await session.send_request(
types.PingRequest(), types.EmptyResult, metadata=ServerMessageMetadata(related_request_id=3)
async def test_send_request_without_related_id_routes_to_standalone_channel():
"""SDK-defined: no `related_request_id` routes the request onto the connection's standalone channel."""
request_ch = StubOutbound()
standalone_ch = StubOutbound(result={"roots": []})
session = _two_channel_session(request_ch, standalone_ch)
await session.send_request(types.ListRootsRequest(), types.ListRootsResult)
assert request_ch.requests == []
assert standalone_ch.requests[0][0] == "roots/list"
@pytest.mark.anyio
async def test_send_request_with_related_id_routes_to_request_channel():
"""SDK-defined: with `related_request_id` the request rides the per-request channel
(the originating POST's response stream over streamable HTTP)."""
request_ch = StubOutbound(result={"action": "cancel"})
standalone_ch = StubOutbound()
session = _two_channel_session(request_ch, standalone_ch)
result = await session.send_request(
types.ElicitRequest(params=types.ElicitRequestFormParams(message="q", requested_schema={})),
types.ElicitResult,
metadata=ServerMessageMetadata(related_request_id=7),
)
assert dispatcher.requests[0][3] == 3
assert isinstance(result, types.ElicitResult)
assert standalone_ch.requests == []
assert request_ch.requests[0][0] == "elicitation/create"
@pytest.mark.anyio
async def test_send_notification_routes_by_related_request_id():
"""SDK-defined: notifications select channel by `related_request_id` exactly like requests."""
request_ch = StubOutbound()
standalone_ch = StubOutbound()
session = _two_channel_session(request_ch, standalone_ch)
await session.send_tool_list_changed()
await session.send_progress_notification("tok", 0.5, related_request_id="req-1")
assert [m for m, _ in standalone_ch.notifications] == ["notifications/tools/list_changed"]
assert [m for m, _ in request_ch.notifications] == ["notifications/progress"]
@pytest.mark.anyio
async def test_send_request_validates_the_client_result_against_the_surface_schema():
"""A spec-method result that fails the per-version surface schema raises
`ValidationError` even when the caller's `result_type` would accept it."""
session = _make_session(StubDispatcher(result={"roots": "nope"}))
session = _make_session(StubOutbound(result={"roots": "nope"}))
with pytest.raises(ValidationError):
await session.send_request(types.ListRootsRequest(), types.EmptyResult)
@ -143,7 +157,7 @@ async def test_send_request_validates_the_client_result_against_the_surface_sche
@pytest.mark.anyio
async def test_send_request_passes_a_spec_valid_client_result():
"""A spec-valid client result passes the surface gate and parses to the typed model."""
session = _make_session(StubDispatcher(result={"roots": [{"uri": "file:///ws"}]}))
session = _make_session(StubOutbound(result={"roots": [{"uri": "file:///ws"}]}))
result = await session.send_request(types.ListRootsRequest(), types.ListRootsResult)
assert isinstance(result, types.ListRootsResult)
assert str(result.roots[0].uri) == "file:///ws"
@ -151,9 +165,9 @@ async def test_send_request_passes_a_spec_valid_client_result():
@pytest.mark.anyio
async def test_send_request_skips_the_surface_gate_when_method_absent_at_version():
"""Surface row absent for the negotiated version: gate is bypassed and only
"""Surface row absent for the connection's version: gate is bypassed and only
`result_type` validates."""
session = _make_session(StubDispatcher(result={}), protocol_version="2026-07-28")
session = _make_session(StubOutbound(result={}), protocol_version=MODERN_PROTOCOL_VERSIONS[0])
result = await session.send_request(types.PingRequest(), types.EmptyResult)
assert isinstance(result, types.EmptyResult)
@ -163,7 +177,7 @@ async def test_send_request_validates_result_alias_only():
"""Peer results validate alias-only; a snake_case key from the wire is
ignored as extra, not populated by Python field name."""
snake = {"role": "assistant", "content": {"type": "text", "text": "x"}, "model": "m", "stop_reason": "endTurn"}
session = _make_session(StubDispatcher(result=snake))
session = _make_session(StubOutbound(result=snake))
request = types.CreateMessageRequest(params=types.CreateMessageRequestParams(messages=[], max_tokens=1))
result = await session.send_request(request, types.CreateMessageResult)
assert result.stop_reason is None
@ -171,9 +185,9 @@ async def test_send_request_validates_result_alias_only():
@pytest.mark.anyio
async def test_create_message_with_tools_returns_with_tools_result():
dispatcher = StubDispatcher(result={"role": "assistant", "content": [{"type": "text", "text": "ok"}], "model": "m"})
outbound = StubOutbound(result={"role": "assistant", "content": [{"type": "text", "text": "ok"}], "model": "m"})
session = _make_session(
dispatcher, capabilities=ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability()))
outbound, capabilities=ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability()))
)
result = await session.create_message( # pyright: ignore[reportDeprecated]
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hi"))],
@ -181,74 +195,22 @@ async def test_create_message_with_tools_returns_with_tools_result():
tools=[types.Tool(name="t", input_schema={"type": "object"})],
)
assert isinstance(result, types.CreateMessageResultWithTools)
method, params, _opts, _related = dispatcher.requests[0]
method, params, _opts = outbound.requests[0]
assert method == "sampling/createMessage"
assert params is not None and params["tools"][0]["name"] == "t"
def test_check_client_capability_delegates_to_connection():
dispatcher = StubDispatcher()
session = _make_session(dispatcher, capabilities=ClientCapabilities(sampling=SamplingCapability()))
outbound = StubOutbound()
session = _make_session(outbound, capabilities=ClientCapabilities(sampling=SamplingCapability()))
assert session.check_client_capability(ClientCapabilities(sampling=SamplingCapability())) is True
assert session.check_client_capability(ClientCapabilities(experimental={"x": {}})) is False
def _runner_server(seen_versions: list[str | None]) -> Server[dict[str, Any]]:
"""A lowlevel Server whose tools/list handler records `ctx.session.protocol_version`."""
async def list_tools(
ctx: ServerRequestContext[dict[str, Any], Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
seen_versions.append(ctx.session.protocol_version)
return types.ListToolsResult(tools=[])
return Server(name="test-server", version="0.0.1", on_list_tools=list_tools)
def _init_params(protocol_version: str) -> dict[str, Any]:
return InitializeRequestParams(
protocol_version=protocol_version,
capabilities=ClientCapabilities(),
client_info=Implementation(name="test-client", version="1.0"),
).model_dump(by_alias=True, exclude_none=True)
@pytest.mark.anyio
async def test_protocol_version_is_none_before_initialize():
"""No negotiated version is readable before the initialize handshake."""
async with connected_runner(_runner_server([]), initialized=False) as (_client, runner):
assert runner.session.protocol_version is None
@pytest.mark.anyio
async def test_protocol_version_is_negotiated_version_after_initialize():
"""A supported requested version is echoed back and readable on the session,
both directly and from inside a handler via `ctx.session`."""
seen: list[str | None] = []
async with connected_runner(_runner_server(seen), initialized=False) as (client, runner):
result = await client.send_raw_request("initialize", _init_params("2025-03-26"))
assert result["protocolVersion"] == "2025-03-26"
assert runner.session.protocol_version == "2025-03-26"
await client.send_raw_request("tools/list", None)
assert seen == ["2025-03-26"]
@pytest.mark.anyio
async def test_protocol_version_reads_latest_when_requested_version_unsupported():
"""An unsupported requested version negotiates down to LATEST_PROTOCOL_VERSION."""
async with connected_runner(_runner_server([]), initialized=False) as (client, runner):
result = await client.send_raw_request("initialize", _init_params("1999-01-01"))
assert result["protocolVersion"] == LATEST_PROTOCOL_VERSION
assert runner.session.protocol_version == LATEST_PROTOCOL_VERSION
@pytest.mark.anyio
async def test_protocol_version_is_none_on_stateless_connection():
"""Stateless connections never see a handshake: requests flow, but the
negotiated version legitimately stays None."""
seen: list[str | None] = []
async with connected_runner(_runner_server(seen), initialized=False, stateless=True) as (client, runner):
result = await client.send_raw_request("tools/list", None)
assert result == {"tools": []}
assert seen == [None]
assert runner.session.protocol_version is None
def test_protocol_version_proxies_connection():
"""SDK-defined: `session.protocol_version` reads through to the held `Connection`."""
_ARBITRARY_VERSION = "sentinel-version" # identity-only: any string the connection holds
conn = Connection.from_envelope(_ARBITRARY_VERSION, None, None)
session = ServerSession(StubOutbound(), conn)
assert session.protocol_version == _ARBITRARY_VERSION
assert session.client_params is None

View file

@ -1,187 +1,170 @@
"""Tests for stateless HTTP mode limitations.
"""Tests for the no-back-channel path (stateless HTTP).
Stateless HTTP mode does not support server-to-client requests because there
is no persistent connection for bidirectional communication. These tests verify
that appropriate errors are raised when attempting to use unsupported features.
A `Connection.from_envelope(...)` connection installs the no-channel sentinel
as its standalone outbound, so server-to-client requests with no related
request to ride on raise `NoBackChannelError` from the channel itself.
See: https://github.com/modelcontextprotocol/python-sdk/issues/1097
"""
from collections.abc import Mapping
from typing import Any
from unittest.mock import Mock
import anyio
import pytest
from mcp import types
from mcp.server.connection import Connection
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel.server import Server
from mcp.server.session import ServerSession
from mcp.shared.exceptions import NoBackChannelError, StatelessModeNotSupported
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import SessionMessage
from mcp.types import JSONRPCRequest, JSONRPCResponse, ListToolsResult, PaginatedRequestParams
from mcp.shared.dispatcher import CallOptions
from mcp.shared.exceptions import NoBackChannelError
from mcp.types import LATEST_PROTOCOL_VERSION
def _make_session(*, stateless: bool) -> ServerSession:
"""A `ServerSession` with a mock dispatcher; the stateless guard fires before any send."""
return ServerSession(
Mock(spec=JSONRPCDispatcher),
Connection(Mock(), has_standalone_channel=False),
stateless=stateless,
)
class StubOutbound:
"""Records `send_raw_request` / `notify` calls and returns a canned result."""
def __init__(self, result: dict[str, Any] | None = None) -> None:
self.requests: list[tuple[str, Mapping[str, Any] | None, CallOptions | None]] = []
self.notifications: list[tuple[str, Mapping[str, Any] | None]] = []
self.result = result if result is not None else {}
async def send_raw_request(
self,
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
) -> dict[str, Any]:
self.requests.append((method, params, opts))
return self.result
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
self.notifications.append((method, params))
def _no_channel_session(request_ch: StubOutbound | None = None) -> tuple[ServerSession, StubOutbound]:
"""A session whose standalone channel is the connection's no-channel
sentinel; the request channel is a working stub."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
assert conn.has_standalone_channel is False
request = request_ch if request_ch is not None else StubOutbound()
return ServerSession(request, conn), request
@pytest.fixture
def stateless_session() -> ServerSession:
return _make_session(stateless=True)
def no_channel_session() -> ServerSession:
session, _ = _no_channel_session()
return session
@pytest.mark.anyio
async def test_list_roots_fails_in_stateless_mode(stateless_session: ServerSession):
"""Test that list_roots raises StatelessModeNotSupported in stateless mode."""
with pytest.raises(StatelessModeNotSupported, match="list_roots"):
await stateless_session.list_roots() # pyright: ignore[reportDeprecated]
async def test_list_roots_raises_no_back_channel(no_channel_session: ServerSession):
"""SDK-defined: `list_roots` has no `related_request_id` so it always rides
the standalone channel, which raises here."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.list_roots() # pyright: ignore[reportDeprecated]
assert exc.value.method == "roots/list"
@pytest.mark.anyio
async def test_create_message_fails_in_stateless_mode(stateless_session: ServerSession):
"""Test that create_message raises StatelessModeNotSupported in stateless mode."""
with pytest.raises(StatelessModeNotSupported, match="sampling"):
await stateless_session.create_message( # pyright: ignore[reportDeprecated]
messages=[
types.SamplingMessage(
role="user",
content=types.TextContent(type="text", text="hello"),
)
],
async def test_send_ping_raises_no_back_channel(no_channel_session: ServerSession):
"""SDK-defined: `send_ping` rides the standalone channel and raises when there is none."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.send_ping()
assert exc.value.method == "ping"
@pytest.mark.anyio
async def test_create_message_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
"""SDK-defined: `create_message` without a related id rides the standalone channel and raises."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.create_message( # pyright: ignore[reportDeprecated]
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hi"))],
max_tokens=100,
)
assert exc.value.method == "sampling/createMessage"
@pytest.mark.anyio
async def test_elicit_form_fails_in_stateless_mode(stateless_session: ServerSession):
"""Test that elicit_form raises StatelessModeNotSupported in stateless mode."""
with pytest.raises(StatelessModeNotSupported, match="elicitation"):
await stateless_session.elicit_form(
message="Please provide input",
requested_schema={"type": "object", "properties": {}},
)
async def test_elicit_form_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
"""SDK-defined: `elicit_form` without a related id rides the standalone channel and raises."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.elicit_form(message="m", requested_schema={"type": "object", "properties": {}})
assert exc.value.method == "elicitation/create"
@pytest.mark.anyio
async def test_elicit_url_fails_in_stateless_mode(stateless_session: ServerSession):
"""Test that elicit_url raises StatelessModeNotSupported in stateless mode."""
with pytest.raises(StatelessModeNotSupported, match="elicitation"):
await stateless_session.elicit_url(
message="Please authenticate",
url="https://example.com/auth",
elicitation_id="test-123",
)
async def test_elicit_url_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
"""SDK-defined: `elicit_url` without a related id rides the standalone channel and raises."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.elicit_url(message="m", url="https://example.com/auth", elicitation_id="e-1")
assert exc.value.method == "elicitation/create"
@pytest.mark.anyio
async def test_elicit_deprecated_fails_in_stateless_mode(stateless_session: ServerSession):
"""Test that the deprecated elicit method also fails in stateless mode."""
with pytest.raises(StatelessModeNotSupported, match="elicitation"):
await stateless_session.elicit(
message="Please provide input",
requested_schema={"type": "object", "properties": {}},
)
async def test_elicit_deprecated_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
"""SDK-defined: the deprecated `elicit` alias routes the same as `elicit_form` and raises."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.elicit(message="m", requested_schema={"type": "object", "properties": {}})
assert exc.value.method == "elicitation/create"
@pytest.mark.anyio
async def test_stateless_error_message_is_actionable(stateless_session: ServerSession):
"""Test that the error message provides actionable guidance."""
with pytest.raises(StatelessModeNotSupported) as exc_info:
await stateless_session.list_roots() # pyright: ignore[reportDeprecated]
error_message = str(exc_info.value)
# Should mention it's stateless mode
assert "stateless HTTP mode" in error_message
# Should explain why it doesn't work
assert "server-to-client requests" in error_message
# Should tell user how to fix it
assert "stateless_http=False" in error_message
async def test_send_request_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
"""SDK-defined: the generic `send_request` path with no metadata routes standalone and raises."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.send_request(types.ListRootsRequest(), types.ListRootsResult)
assert exc.value.method == "roots/list"
@pytest.mark.anyio
async def test_exception_has_method_attribute(stateless_session: ServerSession):
"""Test that the exception has a method attribute for programmatic access."""
with pytest.raises(StatelessModeNotSupported) as exc_info:
await stateless_session.list_roots() # pyright: ignore[reportDeprecated]
assert exc_info.value.method == "list_roots"
@pytest.fixture
def stateful_session() -> ServerSession:
return _make_session(stateless=False)
async def test_elicit_form_with_related_id_rides_the_request_channel():
"""SDK-defined: with a related request the message rides the per-request
channel, so the no-channel standalone is never touched and the call succeeds."""
session, request_ch = _no_channel_session(StubOutbound(result={"action": "cancel"}))
result = await session.elicit_form(
message="m", requested_schema={"type": "object", "properties": {}}, related_request_id=3
)
assert isinstance(result, types.ElicitResult)
assert request_ch.requests[0][0] == "elicitation/create"
@pytest.mark.anyio
async def test_stateful_mode_does_not_raise_stateless_error(
stateful_session: ServerSession, monkeypatch: pytest.MonkeyPatch
):
"""Test that StatelessModeNotSupported is not raised in stateful mode.
async def test_send_log_message_with_related_id_rides_the_request_channel():
"""SDK-defined: the deprecated ``send_log_message`` notification with a related id
rides the per-request channel, so it is delivered even with no standalone back-channel."""
session, request_ch = _no_channel_session()
await session.send_log_message( # pyright: ignore[reportDeprecated]
level="info", data="hello", logger="test", related_request_id=3
)
assert request_ch.notifications == [("notifications/message", {"level": "info", "data": "hello", "logger": "test"})]
We mock send_request to avoid blocking on I/O while still verifying
that the stateless check passes.
"""
send_request_called = False
async def mock_send_request(*_: Any, **__: Any) -> types.ListRootsResult:
nonlocal send_request_called
send_request_called = True
return types.ListRootsResult(roots=[])
@pytest.mark.anyio
async def test_unrelated_notification_is_dropped_silently():
"""SDK-defined: notifications on the no-channel standalone are best-effort — dropped, never raised."""
session, request_ch = _no_channel_session()
await session.send_tool_list_changed()
assert request_ch.notifications == []
monkeypatch.setattr(stateful_session, "send_request", mock_send_request)
# This should NOT raise StatelessModeNotSupported
result = await stateful_session.list_roots() # pyright: ignore[reportDeprecated]
assert send_request_called
@pytest.mark.anyio
async def test_loop_connection_outbound_does_not_raise_no_back_channel():
"""SDK-defined: a `for_loop` connection holds a real outbound, so the
standalone path reaches the channel rather than raising."""
standalone = StubOutbound(result={"roots": []})
conn = Connection.for_loop(standalone)
assert conn.has_standalone_channel is True
session = ServerSession(StubOutbound(), conn)
result = await session.list_roots() # pyright: ignore[reportDeprecated]
assert isinstance(result, types.ListRootsResult)
assert standalone.requests[0][0] == "roots/list"
@pytest.mark.anyio
async def test_server_run_stateless_wires_no_standalone_channel():
"""`Server.run(stateless=True)` must wire `Connection.has_standalone_channel=False`.
Stateless HTTP has no standalone GET stream, so server-initiated requests on
the connection must fail fast with `NoBackChannelError` rather than write to
a channel that will never deliver a response. The `ServerSession` typed
helpers carry their own stateless guard (tested above); this pins the
`Connection` wiring that `Server.run` produces.
"""
captured: list[Connection] = []
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
# `ServerRequestContext` doesn't expose `connection` directly yet (it
# will after the Context rework); reach it via the session for now.
captured.append(ctx.session._connection) # pyright: ignore[reportPrivateUsage]
return ListToolsResult(tools=[])
server: Server[Any] = Server("test", on_list_tools=list_tools)
to_server, server_read = anyio.create_memory_object_stream[SessionMessage | Exception](10)
server_write, from_server = anyio.create_memory_object_stream[SessionMessage](10)
async def run_server() -> None:
await server.run(server_read, server_write, server.create_initialization_options(), stateless=True)
async with anyio.create_task_group() as tg, to_server, server_read, server_write, from_server:
tg.start_soon(run_server)
# stateless=True skips the init gate, so tools/list routes immediately.
await to_server.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list")))
with anyio.fail_after(5):
response = (await from_server.receive()).message
assert isinstance(response, JSONRPCResponse)
tg.cancel_scope.cancel()
assert len(captured) == 1
conn = captured[0]
assert conn.has_standalone_channel is False
with pytest.raises(NoBackChannelError):
async def test_from_envelope_connection_ping_raises_no_back_channel():
"""SDK-defined: `Connection`'s own helpers route through the same sentinel,
so `ping` on a `from_envelope` connection raises."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
with pytest.raises(NoBackChannelError) as exc:
await conn.ping()
assert exc.value.method == "ping"

View file

@ -103,11 +103,12 @@ async def running_manager():
@pytest.mark.anyio
async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[StreamableHTTPSessionManager, Server]):
manager, app = running_manager
manager, _app = running_manager
mock_mcp_run = AsyncMock(return_value=None)
# This will be called by StreamableHTTPSessionManager's run_server -> self.app.run
app.run = mock_mcp_run
# The manager's `run_server` task drives `serve_loop` directly (the manager
# owns lifespan); patch that seam so the loop returns immediately and we
# can observe the cleanup that follows.
mock_serve = AsyncMock(return_value=None)
sent_messages: list[Message] = []
@ -125,7 +126,8 @@ async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[
return {"type": "http.request", "body": b"", "more_body": False}
# Trigger session creation
await manager.handle_request(scope, mock_receive, mock_send)
with patch("mcp.server.streamable_http_manager.serve_loop", mock_serve):
await manager.handle_request(scope, mock_receive, mock_send)
# Extract session ID from response headers
session_id = None
@ -140,10 +142,9 @@ async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[
assert session_id is not None, "Session ID not found in response headers"
# Ensure MCPServer.run was called
mock_mcp_run.assert_called_once()
mock_serve.assert_called_once()
# At this point, mock_mcp_run has completed, and the finally block in
# At this point, mock_serve has completed, and the finally block in
# StreamableHTTPSessionManager's run_server should have executed.
# To ensure the task spawned by handle_request finishes and cleanup occurs:
@ -158,10 +159,9 @@ async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[
@pytest.mark.anyio
async def test_stateful_session_cleanup_on_exception(running_manager: tuple[StreamableHTTPSessionManager, Server]):
manager, app = running_manager
manager, _app = running_manager
mock_mcp_run = AsyncMock(side_effect=TestException("Simulated crash"))
app.run = mock_mcp_run
mock_serve = AsyncMock(side_effect=TestException("Simulated crash"))
sent_messages: list[Message] = []
@ -184,7 +184,8 @@ async def test_stateful_session_cleanup_on_exception(running_manager: tuple[Stre
return {"type": "http.request", "body": b"", "more_body": False}
# Trigger session creation
await manager.handle_request(scope, mock_receive, mock_send)
with patch("mcp.server.streamable_http_manager.serve_loop", mock_serve):
await manager.handle_request(scope, mock_receive, mock_send)
session_id = None
for msg in sent_messages: # pragma: no branch
@ -198,7 +199,7 @@ async def test_stateful_session_cleanup_on_exception(running_manager: tuple[Stre
assert session_id is not None, "Session ID not found in response headers"
mock_mcp_run.assert_called_once()
mock_serve.assert_called_once()
# Give other tasks a chance to run to ensure the finally block executes
await anyio.sleep(0.01)
@ -229,9 +230,6 @@ async def test_stateless_requests_memory_cleanup():
with patch.object(streamable_http_manager, "StreamableHTTPServerTransport", side_effect=track_transport):
async with manager.run():
# Mock app.run to complete immediately
app.run = AsyncMock(return_value=None)
# Send a simple request
sent_messages: list[Message] = []

View file

@ -2,61 +2,38 @@
The interaction suite under ``tests/interaction/transports/test_hosting_http_modern.py`` pins
the wire contract end to end; these tests cover the module's internal seams directly --
the closed back-channel on the dispatcher and dispatch context, the exception-to-error
mapping in ``handle()``, and the request-validation ladder in ``handle_modern_request``.
the closed back-channel on the dispatch context, and the request-validation ladder in
``handle_modern_request``.
"""
import logging
from collections.abc import Mapping
from typing import Any
import anyio
import httpx
import pytest
from starlette.requests import Request
from starlette.types import Receive, Scope, Send
import mcp.server._streamable_http_modern as modern
from mcp.server import Server, ServerRequestContext
from mcp.server._streamable_http_modern import (
SingleExchangeDispatcher,
_SingleExchangeDispatchContext,
handle_modern_request,
)
from mcp.server import Server, ServerRequestContext, runner
from mcp.server._streamable_http_modern import _SingleExchangeDispatchContext, handle_modern_request
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.dispatcher import DispatchContext
from mcp.shared.exceptions import NoBackChannelError
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.transport_context import TransportContext
from mcp.types import INVALID_PARAMS, PARSE_ERROR, JSONRPCError, JSONRPCRequest, ListToolsResult, PaginatedRequestParams
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
INVALID_REQUEST,
PARSE_ERROR,
PROTOCOL_VERSION_META_KEY,
ListToolsResult,
PaginatedRequestParams,
)
pytestmark = pytest.mark.anyio
def _request() -> Request:
return Request({"type": "http", "method": "POST", "headers": []})
async def test_single_exchange_dispatcher_has_no_back_channel_and_is_never_driven() -> None:
"""The dispatcher refuses server-initiated requests, drops notifications, and is not run-driven.
A 2026-07-28 POST has no channel for the server to push to the client, and ``ServerRunner``
never calls ``run()`` on this dispatcher -- ``handle()`` is invoked directly per request.
"""
dispatcher = SingleExchangeDispatcher(_request())
with pytest.raises(NoBackChannelError):
await dispatcher.send_raw_request("sampling/createMessage", None)
assert await dispatcher.notify("notifications/message", None) is None
async def on_request(ctx: DispatchContext[Any], method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
raise AssertionError("unreachable") # pragma: no cover
async def on_notify(ctx: DispatchContext[Any], method: str, params: Mapping[str, Any] | None) -> None:
raise AssertionError("unreachable") # pragma: no cover
with pytest.raises(RuntimeError, match="never driven"):
await dispatcher.run(on_request, on_notify)
async def test_single_exchange_dispatch_context_has_no_back_channel() -> None:
"""The per-request dispatch context refuses server-initiated requests and drops notify/progress."""
dctx = _SingleExchangeDispatchContext(
@ -71,41 +48,49 @@ async def test_single_exchange_dispatch_context_has_no_back_channel() -> None:
assert await dctx.progress(0.5, total=1.0, message="half") is None
async def test_handle_maps_validation_error_to_invalid_params() -> None:
"""A handler raising ``ValidationError`` is mapped to a ``-32602`` JSON-RPC error.
Mirrors ``JSONRPCDispatcher``'s exception-to-wire boundary: a Pydantic validation failure
inside the handler becomes ``INVALID_PARAMS`` rather than the generic internal error.
"""
async def on_request(ctx: DispatchContext[Any], method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
JSONRPCRequest.model_validate({}) # raises ValidationError
raise AssertionError("unreachable") # pragma: no cover
dispatcher = SingleExchangeDispatcher(_request())
msg = await dispatcher.handle(JSONRPCRequest(jsonrpc="2.0", id=7, method="tools/call", params={}), on_request)
assert isinstance(msg, JSONRPCError)
assert msg.id == 7
assert msg.error.code == INVALID_PARAMS
def _asgi_client(server: Server[Any], security_settings: TransportSecuritySettings | None = None) -> httpx.AsyncClient:
async def app(scope: Scope, receive: Receive, send: Send) -> None:
await handle_modern_request(server, security_settings, "2026-07-28", scope, receive, send)
async with server.lifespan(server) as lifespan_state:
await handle_modern_request(server, security_settings, lifespan_state, scope, receive, send)
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver")
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://testserver",
headers={MCP_PROTOCOL_VERSION_HEADER: MODERN_PROTOCOL_VERSIONS[0]},
)
async def test_handle_modern_request_rejects_non_post_with_405() -> None:
"""A GET on the 2026-07-28 entry is answered with 405 before any body is read."""
async def test_handle_modern_request_rejects_non_post_with_http_405_and_allow_header() -> None:
"""SDK-defined: a GET on the modern entry is an HTTP-verb mismatch — 405 Method Not
Allowed with ``Allow: POST`` per RFC 9110. This is HTTP-layer (before JSON-RPC parsing)
so there is no JSON-RPC body."""
async with _asgi_client(Server("test")) as http:
response = await http.get("/mcp")
assert response.status_code == 405
assert response.headers["allow"] == "POST"
assert response.content == b""
async def test_handle_modern_request_rejects_a_notification_body_with_invalid_request() -> None:
"""SDK-defined: well-formed JSON that isn't a single JSON-RPC request object (e.g. a
notification, which lacks ``id``) is ``INVALID_REQUEST`` — distinct from ``PARSE_ERROR``,
which is for malformed JSON."""
async with _asgi_client(Server("test")) as http:
response = await http.post(
"/mcp",
content=b'{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}',
headers={"content-type": "application/json"},
)
assert response.status_code == 400
assert response.json()["error"]["code"] == INVALID_REQUEST
async def test_handle_modern_request_rejects_malformed_body_with_parse_error() -> None:
"""A POST whose body is not a valid ``JSONRPCRequest`` returns 400 with ``-32700``."""
"""An unparseable POST body yields HTTP 400 with a ``PARSE_ERROR`` JSON-RPC error envelope.
SDK-defined: the 400 status comes from the SDK's error-code→HTTP-status table; spec-mandated: the
body is a full JSON-RPC error object with ``id: null`` and code ``-32700``.
"""
async with _asgi_client(Server("test")) as http:
response = await http.post("/mcp", content=b"not json", headers={"content-type": "application/json"})
assert response.status_code == 400
@ -113,7 +98,7 @@ async def test_handle_modern_request_rejects_malformed_body_with_parse_error() -
assert response.json() == {
"jsonrpc": "2.0",
"id": None,
"error": {"code": PARSE_ERROR, "message": "Parse error", "data": None},
"error": {"code": PARSE_ERROR, "message": "Parse error"},
}
@ -129,20 +114,45 @@ async def test_handle_modern_request_returns_transport_security_error_response()
def _list_tools_body() -> dict[str, Any]:
"""A minimal valid 2026-07-28 ``tools/list`` request body, including the required ``_meta`` envelope."""
meta = {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "raw", "version": "0.0.0"},
"io.modelcontextprotocol/clientCapabilities": {},
PROTOCOL_VERSION_META_KEY: MODERN_PROTOCOL_VERSIONS[0],
CLIENT_INFO_META_KEY: {"name": "raw", "version": "0.0.0"},
CLIENT_CAPABILITIES_META_KEY: {},
}
return {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": meta}}
async def test_handle_modern_request_routes_with_mis_shaped_envelope_client_info() -> None:
"""SDK-defined: a mis-shaped ``clientInfo`` envelope value is treated as not supplied —
the request still routes (200 + result) and the handler observes ``client_params is None``
rather than the request being rejected at the validation ladder. A non-spec method is
used so the kernel's per-method params validation does not re-reject the envelope."""
seen: list[object] = []
async def greet(ctx: ServerRequestContext, params: PaginatedRequestParams) -> dict[str, Any]:
seen.append(ctx.session.client_params)
return {"ok": True}
server: Server[Any] = Server("test")
server.add_request_handler("custom/greet", PaginatedRequestParams, greet)
body = _list_tools_body()
body["method"] = "custom/greet"
body["params"]["_meta"][CLIENT_INFO_META_KEY] = "not-an-object"
async with _asgi_client(server) as http:
response = await http.post("/mcp", json=body, headers={"content-type": "application/json"})
assert response.status_code == 200
assert response.json()["result"] == {"ok": True}
assert seen == [None]
async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_raises(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A raising ``connection.exit_stack`` callback is logged and swallowed; the computed result still ships.
The exit-stack guard mirrors ``ServerRunner.run``: cleanup runs in a ``finally`` after the
handler, and an exception there must not displace the JSON-RPC response that was already built.
The exit-stack guard is `aclose_shielded`: cleanup runs in `serve_one`'s ``finally`` after
the handler, and an exception there must not displace the JSON-RPC response that was already
built.
"""
async def boom() -> None:
@ -152,7 +162,7 @@ async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_rais
ctx.session._connection.exit_stack.push_async_callback(boom)
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
with caplog.at_level(logging.ERROR, logger=modern.__name__):
with caplog.at_level(logging.ERROR, logger=runner.__name__):
async with _asgi_client(Server("test", on_list_tools=list_tools)) as http:
response = await http.post("/mcp", json=_list_tools_body(), headers={"content-type": "application/json"})
@ -170,7 +180,7 @@ async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_hang
blocker at its first checkpoint, the abandonment warning is logged, and the JSON-RPC response
that was built before cleanup is sent unchanged.
"""
monkeypatch.setattr(modern, "_EXIT_STACK_CLOSE_TIMEOUT", 0)
monkeypatch.setattr(runner, "_EXIT_STACK_CLOSE_TIMEOUT", 0)
async def block() -> None:
await anyio.Event().wait()
@ -180,7 +190,7 @@ async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_hang
ctx.session._connection.exit_stack.push_async_callback(block)
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
with anyio.fail_after(5), caplog.at_level(logging.WARNING, logger=modern.__name__):
with anyio.fail_after(5), caplog.at_level(logging.WARNING, logger=runner.__name__):
async with _asgi_client(Server("test", on_list_tools=list_tools)) as http:
response = await http.post("/mcp", json=_list_tools_body(), headers={"content-type": "application/json"})
# coverage.py on Python 3.11 misreports the lines below as unhit (the test passes there);

View file

@ -0,0 +1,221 @@
"""Pure-function tests of :mod:`mcp.shared.inbound`.
Independent verifier of the classifier: every ladder rung is exercised
pass+fail with no ``mcp.server`` / transport imports and no inlined error-code
or protocol-version literals — all facts are imported from their one source.
"""
import dataclasses
from typing import Any
import pytest
from mcp.shared.inbound import (
ERROR_CODE_HTTP_STATUS,
MCP_PROTOCOL_VERSION_HEADER,
InboundLadderRejection,
InboundModernRoute,
classify_inbound_request,
)
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
LATEST_PROTOCOL_VERSION,
PROTOCOL_VERSION_META_KEY,
)
from mcp.types.jsonrpc import (
HEADER_MISMATCH,
INVALID_PARAMS,
INVALID_REQUEST,
METHOD_NOT_FOUND,
MISSING_REQUIRED_CLIENT_CAPABILITY,
PARSE_ERROR,
UNSUPPORTED_PROTOCOL_VERSION,
)
MODERN = MODERN_PROTOCOL_VERSIONS[0]
"""The modern protocol-version string, read from the registry — never inlined here."""
CLIENT_INFO = {"name": "t", "version": "0"}
CLIENT_CAPS: dict[str, Any] = {}
def envelope(
method: str = "tools/list",
*,
version: str = MODERN,
drop: frozenset[str] = frozenset(),
) -> dict[str, Any]:
"""Build a JSON-RPC body carrying a complete modern ``_meta`` envelope.
``drop`` removes named envelope keys so rung-1 failures are driven from one
table instead of repeating reserved-key constants per call site.
"""
meta: dict[str, Any] = {
PROTOCOL_VERSION_META_KEY: version,
CLIENT_INFO_META_KEY: CLIENT_INFO,
CLIENT_CAPABILITIES_META_KEY: CLIENT_CAPS,
}
for key in drop:
del meta[key]
return {"jsonrpc": "2.0", "id": 1, "method": method, "params": {"_meta": meta}}
def assert_rejected(result: object, code: int) -> InboundLadderRejection:
assert isinstance(result, InboundLadderRejection)
assert result.code == code
return result
# --- rung 1: envelope-three-keys -----------------------------------------------
@pytest.mark.parametrize(
"body",
[
pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, id="no-params"),
pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, id="no-meta"),
pytest.param(envelope(drop=frozenset({PROTOCOL_VERSION_META_KEY})), id="meta-missing-version"),
pytest.param(envelope(drop=frozenset({CLIENT_INFO_META_KEY})), id="meta-missing-client-info"),
pytest.param(envelope(drop=frozenset({CLIENT_CAPABILITIES_META_KEY})), id="meta-missing-client-caps"),
],
)
def test_envelope_rung_rejects_missing_keys(body: dict[str, Any]) -> None:
"""Spec-mandated: a modern request lacking any of the three reserved ``_meta`` keys is rejected INVALID_PARAMS."""
rejection = assert_rejected(classify_inbound_request(body), INVALID_PARAMS)
assert rejection.data is None
@pytest.mark.parametrize(
"body",
[
pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": None}, id="params-none"),
pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": None}}, id="meta-none"),
pytest.param(
{"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": 0}}, id="meta-non-mapping"
),
],
)
def test_envelope_rung_rejects_non_mapping_shapes(body: dict[str, Any]) -> None:
"""Spec-mandated: non-mapping ``params`` / ``_meta`` cannot carry the envelope and reject INVALID_PARAMS."""
assert_rejected(classify_inbound_request(body), INVALID_PARAMS)
# --- rung 2: protocol-version-supported ----------------------------------------
def test_version_rung_rejects_unsupported_with_data_shape() -> None:
"""Spec-mandated: an envelope version outside the modern set rejects with the ``supported``/``requested`` data."""
rejection = assert_rejected(
classify_inbound_request(envelope(version=LATEST_PROTOCOL_VERSION)),
UNSUPPORTED_PROTOCOL_VERSION,
)
assert rejection.data == {
"supported": list(MODERN_PROTOCOL_VERSIONS),
"requested": LATEST_PROTOCOL_VERSION,
}
def test_version_rung_data_reflects_supplied_supported_list() -> None:
"""SDK-defined: the caller-supplied ``supported_modern_versions`` is what rejection ``data.supported`` echoes."""
custom = (LATEST_PROTOCOL_VERSION,)
rejection = assert_rejected(
classify_inbound_request(envelope(), supported_modern_versions=custom),
UNSUPPORTED_PROTOCOL_VERSION,
)
assert rejection.data == {"supported": list(custom), "requested": MODERN}
# --- rung 3: header ↔ envelope agreement ---------------------------------------
def test_header_rung_does_not_reject_when_headers_arg_is_none() -> None:
"""SDK-defined: ``headers=None`` (non-HTTP transports) means rung 3 has nothing to check and the ladder proceeds."""
result = classify_inbound_request(envelope(), headers=None)
assert isinstance(result, InboundModernRoute)
def test_header_rung_passes_when_header_matches_envelope() -> None:
"""Spec-mandated: an HTTP version header equal to the envelope version passes rung 3."""
result = classify_inbound_request(envelope(), headers={MCP_PROTOCOL_VERSION_HEADER: MODERN})
assert isinstance(result, InboundModernRoute)
@pytest.mark.parametrize(
"headers",
[
pytest.param({MCP_PROTOCOL_VERSION_HEADER: LATEST_PROTOCOL_VERSION}, id="mismatch"),
pytest.param({}, id="header-absent"),
],
)
def test_header_rung_rejects_on_disagreement(headers: dict[str, str]) -> None:
"""Spec-mandated: an absent or mismatched HTTP version header rejects HEADER_MISMATCH."""
assert_rejected(classify_inbound_request(envelope(), headers=headers), HEADER_MISMATCH)
# --- all rungs pass ------------------------------------------------------------
def test_all_rungs_pass_yields_route() -> None:
"""Spec-mandated: a complete envelope at a supported version with agreeing header routes, surfacing the envelope."""
result = classify_inbound_request(envelope(), headers={MCP_PROTOCOL_VERSION_HEADER: MODERN})
assert isinstance(result, InboundModernRoute)
assert result.protocol_version == MODERN
assert result.client_info == CLIENT_INFO
assert result.client_capabilities == CLIENT_CAPS
@pytest.mark.parametrize("method", ["initialize", "myorg/custom", "does/not/exist"])
def test_classifier_passes_unknown_method_through_to_route(method: str) -> None:
"""SDK-defined: the classifier does not gate on method — kernel dispatch is the single owner of that decision."""
result = classify_inbound_request(envelope(method), headers={MCP_PROTOCOL_VERSION_HEADER: MODERN})
assert isinstance(result, InboundModernRoute)
def test_ladder_first_failure_wins() -> None:
"""Spec-mandated: rungs evaluate in order — header-mismatch and version-unsupported
would both fail; the header rung fires first so an inconsistent client is told it
disagrees with itself rather than that its body version is unsupported."""
body = envelope(version=LATEST_PROTOCOL_VERSION)
result = classify_inbound_request(body, headers={MCP_PROTOCOL_VERSION_HEADER: MODERN})
assert_rejected(result, HEADER_MISMATCH)
# --- ERROR_CODE_HTTP_STATUS ----------------------------------------------------
@pytest.mark.parametrize(
("code", "status"),
[
(PARSE_ERROR, 400),
(INVALID_REQUEST, 400),
(INVALID_PARAMS, 400),
(HEADER_MISMATCH, 400),
(MISSING_REQUIRED_CLIENT_CAPABILITY, 400),
(UNSUPPORTED_PROTOCOL_VERSION, 400),
(METHOD_NOT_FOUND, 404),
],
)
def test_error_code_http_status_table(code: int, status: int) -> None:
"""SDK-defined: pins the JSON-RPC error code → HTTP status mapping the streamable transport reads."""
assert ERROR_CODE_HTTP_STATUS[code] == status
def test_error_code_http_status_covers_every_ladder_code() -> None:
"""SDK-defined: every code the ladder can emit has an HTTP-status entry, so the transport never has to default."""
ladder_codes = {INVALID_PARAMS, UNSUPPORTED_PROTOCOL_VERSION, HEADER_MISMATCH}
assert ladder_codes <= ERROR_CODE_HTTP_STATUS.keys()
# --- shape invariants ----------------------------------------------------------
def test_verdict_dataclasses_are_frozen() -> None:
"""SDK-defined: both verdict dataclasses are frozen so a route/rejection cannot be mutated after classification."""
route = classify_inbound_request(envelope())
assert isinstance(route, InboundModernRoute)
rejection = InboundLadderRejection(code=METHOD_NOT_FOUND, message="m")
for verdict in (route, rejection):
with pytest.raises(dataclasses.FrozenInstanceError):
setattr(verdict, "message", "mutated")

View file

@ -50,6 +50,8 @@ from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, Ses
from mcp.shared.session import RequestResponder
from mcp.types import (
DEFAULT_NEGOTIATED_VERSION,
INVALID_PARAMS,
INVALID_REQUEST,
CallToolRequestParams,
CallToolResult,
InitializeResult,
@ -1045,8 +1047,10 @@ async def test_streamable_http_client_session_termination(basic_app: Starlette)
):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
# Attempt to make a request after termination
with pytest.raises(MCPError, match="Session terminated"): # pragma: no branch
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()
@pytest.mark.anyio
@ -1106,8 +1110,10 @@ async def test_streamable_http_client_session_termination_204(
):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
# Attempt to make a request after termination
with pytest.raises(MCPError, match="Session terminated"): # pragma: no branch
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()
@pytest.mark.anyio
@ -1388,7 +1394,7 @@ async def test_streamablehttp_stateless_ctx_protocol_version_tracks_the_header(
assert response.status_code == 200
echoed = json.loads(first_sse_data(response)["result"]["content"][0]["text"])
assert echoed["protocol_version"] == expected
assert echoed["session_protocol_version"] is None
assert echoed["session_protocol_version"] == expected
@pytest.mark.anyio
@ -1503,7 +1509,8 @@ async def test_server_validates_protocol_version_header(basic_app: Starlette) ->
session_id = init_response.headers.get(MCP_SESSION_ID_HEADER)
assert session_id is not None
# Test request with invalid protocol version (should fail)
# An unrecognised header value routes to the modern entry, where the
# validation ladder rejects an envelope-less body at rung 1.
response = await client.post(
"/mcp",
headers={
@ -1515,21 +1522,7 @@ async def test_server_validates_protocol_version_header(basic_app: Starlette) ->
json={"jsonrpc": "2.0", "method": "tools/list", "id": "test-2"},
)
assert response.status_code == 400
assert MCP_PROTOCOL_VERSION_HEADER in response.text or "protocol version" in response.text.lower()
# Test request with unsupported protocol version (should fail)
response = await client.post(
"/mcp",
headers={
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
MCP_SESSION_ID_HEADER: session_id,
MCP_PROTOCOL_VERSION_HEADER: "1999-01-01", # Very old unsupported version
},
json={"jsonrpc": "2.0", "method": "tools/list", "id": "test-3"},
)
assert response.status_code == 400
assert MCP_PROTOCOL_VERSION_HEADER in response.text or "protocol version" in response.text.lower()
assert response.json()["error"]["code"] == INVALID_PARAMS
# Test request with valid protocol version (should succeed)
negotiated_version = extract_protocol_version_from_sse(init_response)