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
4 changed files with 2256 additions and 1088 deletions

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