Server-side 2026-07-28 stateless support: classifier, driver split, server/discover (#2928)
This commit is contained in:
parent
44724284b3
commit
2397319a68
4 changed files with 2256 additions and 1088 deletions
|
|
@ -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
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue