Conformance burn-down: server-side InputRequiredResult, Mcp-Method/Name validation, x-mcp-header filter (14 scenarios → green) (#2974)
Some checks failed
CI / checks (push) Has been cancelled
CI / all-green (push) Has been cancelled

This commit is contained in:
Max 2026-06-26 09:51:59 +02:00 • committed by GitHub
commit 587340279e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1163 additions and 150 deletions

View file

@ -472,6 +472,38 @@ async def test_client_auto_mode_falls_back_to_initialize_on_legacy_signal(code:
assert methods_seen == ["server/discover", "initialize", "notifications/initialized"]
@pytest.mark.anyio
async def test_modern_list_tools_drops_tools_with_invalid_x_mcp_header_but_legacy_does_not() -> None:
"""At 2026-07-28 the spec requires clients to exclude tools whose `x-mcp-header`
annotation is malformed; handshake-era sessions surface them unchanged. Two
tools are advertised — one valid, one with a non-RFC-9110-token header name —
and the modern client sees only the valid one."""
valid = types.Tool(
name="ok",
input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "Region"}}},
)
bad = types.Tool(
name="dropme",
input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}},
)
async def on_list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[valid, bad])
server = Server("test", on_list_tools=on_list_tools)
with anyio.fail_after(5):
async with Client(server) as client:
result = await client.list_tools()
assert [t.name for t in result.tools] == ["ok"]
async with Client(server, mode="legacy") as client:
result = await client.list_tools()
assert [t.name for t in result.tools] == ["ok", "dropme"]
def test_client_rejects_handshake_era_mode_at_construction() -> None:
"""A handshake-era protocol-version string passed as `mode=` is rejected by
`__post_init__` with a hint to use `mode='legacy'` — the version-pin path is

View file

@ -206,7 +206,7 @@ async def test_unsupported_protocol_version_rejection_body_contains_the_sniffed_
response = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {"_meta": meta}},
headers=base_headers() | {"mcp-protocol-version": bad},
headers=base_headers() | {"mcp-protocol-version": bad, "mcp-method": "tools/list"},
)
assert response.status_code == 400

View file

@ -10,7 +10,7 @@ from typing import Annotated, Any, Final, NamedTuple, TypedDict
import annotated_types
import pytest
from dirty_equals import IsPartialDict
from mcp_types import CallToolResult
from mcp_types import CallToolResult, InputRequiredResult
from pydantic import BaseModel, Field
from mcp.server.mcpserver.exceptions import InvalidSignature
@ -862,6 +862,29 @@ def test_tool_call_result_annotated_is_structured_and_converted():
assert isinstance(meta.convert_result(func_returning_annotated_tool_call_result()), CallToolResult)
def test_tool_call_result_annotated_unioned_with_input_required_result_is_equivalent_to_the_bare_annotated_form():
"""Stripping `InputRequiredResult` makes the residual behave exactly as if it were the
declared return annotation, including the `Annotated[CallToolResult, Model]` special case
— the schema derives from `Model` and `convert_result` validates `structured_content`
against it instead of wrapping the whole `CallToolResult`."""
class PersonClass(BaseModel):
name: str
def fn_bare() -> Annotated[CallToolResult, PersonClass]:
return CallToolResult(content=[], structured_content={"name": "Brandon"})
def fn_iir() -> Annotated[CallToolResult, PersonClass] | InputRequiredResult:
return CallToolResult(content=[], structured_content={"name": "Brandon"})
bare = func_metadata(fn_bare)
iir = func_metadata(fn_iir)
assert iir.output_schema == bare.output_schema
assert iir.wrap_output == bare.wrap_output
assert isinstance(bare.convert_result(fn_bare()), CallToolResult)
assert isinstance(iir.convert_result(fn_iir()), CallToolResult)
def test_tool_call_result_annotated_is_structured_and_invalid():
class PersonClass(BaseModel):
name: str
@ -1038,7 +1061,9 @@ def test_structured_output_aliases():
# Check that the actual output uses aliases too
result = ModelWithAliases(**{"first": "hello", "second": "world"})
structured_content = meta.convert_result(result).structured_content
converted = meta.convert_result(result)
assert isinstance(converted, CallToolResult)
structured_content = converted.structured_content
assert structured_content is not None
# The structured content should use aliases to match the schema
@ -1051,7 +1076,9 @@ def test_structured_output_aliases():
# Also test the case where we have a model with defaults to ensure aliases work in all cases
result_with_defaults = ModelWithAliases() # Uses default None values
structured_content_defaults = meta.convert_result(result_with_defaults).structured_content
converted_defaults = meta.convert_result(result_with_defaults)
assert isinstance(converted_defaults, CallToolResult)
structured_content_defaults = converted_defaults.structured_content
assert structured_content_defaults is not None
# Even with defaults, should use aliases in output
@ -1191,3 +1218,71 @@ def test_preserves_pydantic_metadata():
assert meta.output_schema is not None
assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "title": "Result", "type": "integer"}
def test_convert_result_passes_input_required_result_through_unchanged():
def fn() -> str | InputRequiredResult: ... # pragma: no branch
meta = func_metadata(fn)
irr = InputRequiredResult(request_state="opaque")
assert meta.convert_result(irr) is irr
def test_input_required_result_return_annotation_yields_no_output_schema():
def fn() -> InputRequiredResult: ... # pragma: no branch
meta = func_metadata(fn)
assert meta.output_schema is None
assert meta.output_model is None
def test_union_with_input_required_result_derives_schema_from_residual_arm():
def fn() -> str | InputRequiredResult: ... # pragma: no branch
meta = func_metadata(fn)
assert meta.output_schema is not None
assert meta.output_schema["properties"]["result"]["type"] == "string"
converted = meta.convert_result("hello")
assert isinstance(converted, CallToolResult)
assert converted.structured_content == {"result": "hello"}
irr = InputRequiredResult(request_state="opaque")
assert meta.convert_result(irr) is irr
def test_call_tool_result_unioned_with_input_required_result_is_accepted():
def fn() -> CallToolResult | InputRequiredResult: ... # pragma: no branch
meta = func_metadata(fn)
assert meta.output_schema is None
def test_basemodel_union_input_required_result_derives_model_schema():
class Payload(BaseModel):
x: int
def fn() -> Payload | InputRequiredResult: ... # pragma: no branch
meta = func_metadata(fn)
assert meta.output_model is Payload
assert meta.wrap_output is False
assert meta.output_schema == Payload.model_json_schema()
def test_call_tool_result_in_union_with_input_required_result_is_still_rejected():
def fn() -> CallToolResult | str | InputRequiredResult: ... # pragma: no branch
with pytest.raises(InvalidSignature, match="CallToolResult cannot be used in Union"):
func_metadata(fn)
def test_union_of_only_input_required_subclasses_yields_no_output_schema():
class StepA(InputRequiredResult):
pass
class StepB(InputRequiredResult):
pass
def fn() -> StepA | StepB: ... # pragma: no branch
meta = func_metadata(fn)
assert meta.output_schema is None

View file

@ -3,6 +3,7 @@ from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
import pytest
from inline_snapshot import snapshot
from mcp_types import (
@ -11,15 +12,21 @@ from mcp_types import (
AudioContent,
BlobResourceContents,
CallToolResult,
ClientCapabilities,
Completion,
CompletionArgument,
CompletionContext,
ContentBlock,
ElicitRequest,
ElicitRequestFormParams,
ElicitResult,
EmbeddedResource,
GetPromptResult,
Icon,
ImageContent,
InputRequiredResult,
ListPromptsResult,
ListRootsRequest,
Prompt,
PromptArgument,
PromptMessage,
@ -1570,3 +1577,98 @@ async def test_read_resource_template_not_found():
assert exc_info.value.error.code == INVALID_PARAMS
assert exc_info.value.error.data == {"uri": "resource://users/999"}
async def test_tool_returning_input_required_result_reaches_client_unchanged():
mcp = MCPServer()
@mcp.tool()
async def ask(ctx: Context) -> str | InputRequiredResult:
return InputRequiredResult(input_requests={"roots": ListRootsRequest()}, request_state="round-1")
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
result = await client.call_tool("ask", allow_input_required=True)
assert isinstance(result, InputRequiredResult)
assert result.request_state == "round-1"
assert result.input_requests is not None
assert result.input_requests["roots"].method == "roots/list"
async def test_tool_reads_input_responses_and_request_state_from_context_on_retry():
mcp = MCPServer()
@mcp.tool()
async def greet(ctx: Context) -> str | InputRequiredResult:
responses = ctx.input_responses
if responses and "who" in responses:
who = responses["who"]
assert isinstance(who, ElicitResult) and who.content is not None
return f"Hello, {who.content['name']}! (state={ctx.request_state})"
return InputRequiredResult(
input_requests={
"who": ElicitRequest(
params=ElicitRequestFormParams(
message="What is your name?",
requested_schema={
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
)
)
},
request_state="r1",
)
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
r1 = await client.call_tool("greet", allow_input_required=True)
assert isinstance(r1, InputRequiredResult)
assert r1.input_requests is not None and "who" in r1.input_requests
r2 = await client.call_tool(
"greet",
input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})},
request_state=r1.request_state,
allow_input_required=True,
)
assert isinstance(r2, CallToolResult)
block = r2.content[0]
assert isinstance(block, TextContent)
assert block.text == "Hello, Alice! (state=r1)"
async def test_context_exposes_client_capabilities_from_connection():
mcp = MCPServer()
seen: list[ClientCapabilities | None] = []
@mcp.tool()
async def probe(ctx: Context) -> str:
seen.append(ctx.client_capabilities)
return "ok"
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
await client.call_tool("probe")
assert len(seen) == 1
assert isinstance(seen[0], ClientCapabilities)
async def test_context_input_responses_and_request_state_are_none_on_initial_round():
mcp = MCPServer()
captured: dict[str, Any] = {}
@mcp.tool()
async def probe(ctx: Context) -> str:
captured["responses"] = ctx.input_responses
captured["state"] = ctx.request_state
return "ok"
with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
await client.call_tool("probe")
assert captured == {"responses": None, "state": None}

View file

@ -15,6 +15,7 @@ import pytest
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
HEADER_MISMATCH,
INTERNAL_ERROR,
INVALID_PARAMS,
INVALID_REQUEST,
@ -39,7 +40,7 @@ from mcp.server._streamable_http_modern import (
)
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.exceptions import MCPError, NoBackChannelError
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.transport_context import TransportContext
pytestmark = pytest.mark.anyio
@ -67,7 +68,10 @@ def _asgi_client(server: Server[Any], security_settings: TransportSecuritySettin
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://testserver",
headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION},
headers={
MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION,
"content-type": "application/json",
},
)
@ -150,7 +154,7 @@ async def test_handle_modern_request_routes_with_mis_shaped_envelope_client_info
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"})
response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "custom/greet"})
assert response.status_code == 200
assert response.json()["result"] == {"ok": True}
assert seen == [None]
@ -175,7 +179,7 @@ async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_rais
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"})
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"})
assert response.status_code == 200
assert response.json()["result"]["tools"] == []
@ -203,7 +207,7 @@ async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_hang
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"})
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"})
# coverage.py on Python 3.11 misreports the lines below as unhit (the test passes there);
# the shielded-cancel path inside the request task disrupts the tracer in this frame.
assert response.status_code == 200 # pragma: lax no cover
@ -270,3 +274,30 @@ async def test_to_jsonrpc_response_maps_unmapped_exception_to_internal_error_and
# Handler internals never reach the wire.
assert "boom" not in reply.error.message
assert "request handler raised" in caplog.text
# --- header cross-check at the wire --------------------------------------------
async def test_handle_modern_request_rejects_mismatched_method_header_with_400_and_header_mismatch() -> None:
"""Spec-mandated: an `Mcp-Method` header that disagrees with `body.method` is rejected at the
boundary as HTTP 400 with JSON-RPC error code HEADER_MISMATCH; the handler never runs."""
async with _asgi_client(Server("test")) as http:
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "prompts/list"})
assert response.status_code == 400
assert response.json()["error"]["code"] == HEADER_MISMATCH
async def test_handle_modern_request_rejects_mismatched_name_header_with_400_and_header_mismatch() -> None:
"""Spec-mandated: for a name-bearing method, an `Mcp-Name` header that disagrees with the body's
named param is rejected as HTTP 400 with JSON-RPC error code HEADER_MISMATCH."""
body = _list_tools_body()
body["method"] = "tools/call"
body["params"]["name"] = "real"
body["params"]["arguments"] = {}
async with _asgi_client(Server("test")) as http:
response = await http.post(
"/mcp", json=body, headers={MCP_METHOD_HEADER: "tools/call", MCP_NAME_HEADER: "wrong"}
)
assert response.status_code == 400
assert response.json()["error"]["code"] == HEADER_MISMATCH

View file

@ -1,7 +1,7 @@
"""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
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.
"""
@ -27,10 +27,16 @@ from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, M
from mcp.shared.inbound import (
ERROR_CODE_HTTP_STATUS,
MCP_METHOD_HEADER,
MCP_NAME_HEADER,
MCP_PROTOCOL_VERSION_HEADER,
NAME_BEARING_METHODS,
InboundLadderRejection,
InboundModernRoute,
classify_inbound_request,
decode_header_value,
encode_header_value,
find_invalid_x_mcp_header,
)
CLIENT_INFO = {"name": "t", "version": "0"}
@ -42,10 +48,11 @@ def envelope(
*,
version: str = LATEST_MODERN_VERSION,
drop: frozenset[str] = frozenset(),
extra_params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build a JSON-RPC body carrying a complete modern ``_meta`` envelope.
"""Build a JSON-RPC body carrying a complete modern `_meta` envelope.
``drop`` removes named envelope keys so rung-1 failures are driven from one
`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] = {
@ -55,7 +62,22 @@ def envelope(
}
for key in drop:
del meta[key]
return {"jsonrpc": "2.0", "id": 1, "method": method, "params": {"_meta": meta}}
params: dict[str, Any] = {"_meta": meta}
if extra_params:
params.update(extra_params)
return {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
def matching_headers(body: dict[str, Any]) -> dict[str, str]:
"""The minimal lowercase HTTP header set that agrees with `body` for rung 2."""
headers = {
MCP_PROTOCOL_VERSION_HEADER: body["params"]["_meta"][PROTOCOL_VERSION_META_KEY],
MCP_METHOD_HEADER: body["method"],
}
name_key = NAME_BEARING_METHODS.get(body["method"])
if name_key is not None and name_key in body["params"]:
headers[MCP_NAME_HEADER] = encode_header_value(body["params"][name_key])
return headers
def assert_rejected(result: object, code: int) -> InboundLadderRejection:
@ -78,7 +100,7 @@ def assert_rejected(result: object, code: int) -> InboundLadderRejection:
],
)
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."""
"""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
@ -94,7 +116,7 @@ def test_envelope_rung_rejects_missing_keys(body: dict[str, Any]) -> None:
],
)
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."""
"""Spec-mandated: non-mapping `params` / `_meta` cannot carry the envelope and reject INVALID_PARAMS."""
assert_rejected(classify_inbound_request(body), INVALID_PARAMS)
@ -102,7 +124,7 @@ def test_envelope_rung_rejects_non_mapping_shapes(body: dict[str, Any]) -> None:
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."""
"""Spec-mandated: an envelope version outside the modern set rejects with the `supported`/`requested` data."""
rejection = assert_rejected(
classify_inbound_request(envelope(version=LATEST_HANDSHAKE_VERSION)),
UNSUPPORTED_PROTOCOL_VERSION,
@ -114,7 +136,7 @@ def test_version_rung_rejects_unsupported_with_data_shape() -> None:
def test_version_rung_data_reflects_supplied_supported_list() -> None:
"""SDK-defined: the caller-supplied ``supported_modern_versions`` is what rejection ``data.supported`` echoes."""
"""SDK-defined: the caller-supplied `supported_modern_versions` is what rejection `data.supported` echoes."""
custom = (LATEST_HANDSHAKE_VERSION,)
rejection = assert_rejected(
classify_inbound_request(envelope(), supported_modern_versions=custom),
@ -127,14 +149,15 @@ def test_version_rung_data_reflects_supplied_supported_list() -> None:
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."""
"""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: LATEST_MODERN_VERSION})
body = envelope()
result = classify_inbound_request(body, headers=matching_headers(body))
assert isinstance(result, InboundModernRoute)
@ -150,12 +173,78 @@ def test_header_rung_rejects_on_disagreement(headers: dict[str, str]) -> None:
assert_rejected(classify_inbound_request(envelope(), headers=headers), HEADER_MISMATCH)
@pytest.mark.parametrize(
"override",
[
pytest.param({MCP_METHOD_HEADER: "prompts/list"}, id="method-mismatch"),
pytest.param({MCP_METHOD_HEADER: "TOOLS/LIST"}, id="method-case-mismatch"),
],
)
def test_header_rung_rejects_method_header_disagreement(override: dict[str, str]) -> None:
"""Spec-mandated: `Mcp-Method` must equal `body.method` exactly (case-sensitive) → else HEADER_MISMATCH."""
body = envelope()
rejection = assert_rejected(
classify_inbound_request(body, headers=matching_headers(body) | override), HEADER_MISMATCH
)
assert MCP_METHOD_HEADER in rejection.message
def test_header_rung_rejects_missing_method_header() -> None:
"""Spec-mandated: an HTTP request on the modern path without `Mcp-Method` is HEADER_MISMATCH."""
body = envelope()
headers = matching_headers(body)
del headers[MCP_METHOD_HEADER]
assert_rejected(classify_inbound_request(body, headers=headers), HEADER_MISMATCH)
@pytest.mark.parametrize(
("method", "name_key"),
[(m, k) for m, k in NAME_BEARING_METHODS.items()],
)
def test_header_rung_rejects_missing_or_mismatched_name_header_for_name_bearing_methods(
method: str, name_key: str
) -> None:
"""Spec-mandated: when the body carries the named param, `Mcp-Name` must be present and equal it."""
body = envelope(method, extra_params={name_key: "expected"})
headers = matching_headers(body)
# Mismatch
assert_rejected(classify_inbound_request(body, headers=headers | {MCP_NAME_HEADER: "wrong"}), HEADER_MISMATCH)
# Absent
del headers[MCP_NAME_HEADER]
assert_rejected(classify_inbound_request(body, headers=headers), HEADER_MISMATCH)
def test_header_rung_decodes_base64_sentinel_before_comparing_name() -> None:
"""Spec-mandated: servers MUST decode the `=?base64?...?=` sentinel before comparing `Mcp-Name`."""
body = envelope("tools/call", extra_params={"name": "résumé"})
headers = matching_headers(body)
assert headers[MCP_NAME_HEADER].startswith("=?base64?")
result = classify_inbound_request(body, headers=headers)
assert isinstance(result, InboundModernRoute)
def test_header_rung_does_not_require_name_header_for_non_name_bearing_method() -> None:
"""SDK-defined: a method outside `NAME_BEARING_METHODS` ignores `Mcp-Name` entirely."""
body = envelope("tools/list")
result = classify_inbound_request(body, headers=matching_headers(body) | {MCP_NAME_HEADER: "anything"})
assert isinstance(result, InboundModernRoute)
def test_header_rung_does_not_require_name_header_when_body_omits_the_named_param() -> None:
"""SDK-defined: a name-bearing method whose body lacks the named param skips the `Mcp-Name`
check — the param's absence is INVALID_PARAMS later, not HEADER_MISMATCH here."""
body = envelope("tools/call")
result = classify_inbound_request(body, headers=matching_headers(body))
assert isinstance(result, InboundModernRoute)
# --- 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: LATEST_MODERN_VERSION})
body = envelope()
result = classify_inbound_request(body, headers=matching_headers(body))
assert isinstance(result, InboundModernRoute)
assert result.protocol_version == LATEST_MODERN_VERSION
assert result.client_info == CLIENT_INFO
@ -165,7 +254,8 @@ def test_all_rungs_pass_yields_route() -> None:
@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: LATEST_MODERN_VERSION})
body = envelope(method)
result = classify_inbound_request(body, headers=matching_headers(body))
assert isinstance(result, InboundModernRoute)
@ -215,3 +305,173 @@ def test_verdict_dataclasses_are_frozen() -> None:
for verdict in (route, rejection):
with pytest.raises(dataclasses.FrozenInstanceError):
setattr(verdict, "message", "mutated")
# --- header-value codec --------------------------------------------------------
@pytest.mark.parametrize(
"raw",
["plain", "with internal space", "", " edge-ws ", "résumé", "a\r\nb", "=?base64?Zm9v?="],
)
def test_decode_header_value_round_trips_encode(raw: str) -> None:
"""SDK-defined: `decode_header_value` is the exact inverse of `encode_header_value` over the full input domain."""
assert decode_header_value(encode_header_value(raw)) == raw
def test_decode_header_value_passes_none_and_plain_through() -> None:
"""SDK-defined: `None` in → `None` out so callers can pass `headers.get(...)` directly; plain stays verbatim."""
assert decode_header_value(None) is None
assert decode_header_value("plain") == "plain"
@pytest.mark.parametrize("bad", ["=?base64?not base64!?=", "=?base64?gA==?="])
def test_decode_header_value_returns_none_for_malformed_sentinel(bad: str) -> None:
"""SDK-defined: a sentinel with bad base64 or bad UTF-8 decodes to `None`, so it can never match a body value."""
assert decode_header_value(bad) is None
# --- NAME_BEARING_METHODS ------------------------------------------------------
def test_name_bearing_methods_table_matches_spec() -> None:
"""Spec-mandated: pins the method → name-param table the client emit and server validate share."""
assert NAME_BEARING_METHODS == {"tools/call": "name", "prompts/get": "name", "resources/read": "uri"}
# --- find_invalid_x_mcp_header -------------------------------------------------
def _schema(**props: Any) -> dict[str, Any]:
return {"type": "object", "properties": props}
@pytest.mark.parametrize(
"input_schema",
[
pytest.param(None, id="none"),
pytest.param("not-a-mapping", id="non-mapping"),
pytest.param({"type": "object"}, id="no-properties"),
pytest.param({"type": "object", "properties": "not-a-mapping"}, id="properties-non-mapping"),
pytest.param(_schema(a={"type": "string"}), id="no-annotation"),
pytest.param(_schema(a={"type": "string", "x-mcp-header": "Region"}), id="valid-string"),
pytest.param(_schema(a={"type": "integer", "x-mcp-header": "Count"}), id="valid-integer"),
pytest.param(_schema(a={"type": "boolean", "x-mcp-header": "Flag"}), id="valid-boolean"),
pytest.param(
_schema(a={"type": "string", "x-mcp-header": "A"}, b={"type": "string", "x-mcp-header": "B"}),
id="two-distinct",
),
pytest.param(_schema(a="not-a-mapping", b={"type": "string", "x-mcp-header": "B"}), id="non-mapping-prop"),
pytest.param(
_schema(outer={"type": "object", "properties": {"r": {"type": "string", "x-mcp-header": "R"}}}),
id="nested-on-properties-chain",
),
pytest.param(
_schema(a={"type": "string", "default": {"x-mcp-header": "ignored"}}),
id="annotation-lookalike-in-default-is-data",
),
pytest.param(
_schema(a={"type": "string", "examples": [{"x-mcp-header": "ignored"}]}),
id="annotation-lookalike-in-examples-is-data",
),
pytest.param(
_schema(a={"type": "string", "const": {"x-mcp-header": "ignored"}}),
id="annotation-lookalike-in-const-is-data",
),
pytest.param(
{"properties": {"a": {"type": "string", "x-mcp-header": "R"}}, "$ref": "#/$defs/loop"},
id="ref-is-not-dereferenced",
),
pytest.param(
{"type": "object", "allOf": 0, "anyOf": [], "$defs": 0, "patternProperties": {}},
id="malformed-or-empty-applicators-ignored",
),
],
)
def test_find_invalid_x_mcp_header_accepts_valid_or_absent_annotations(input_schema: Any) -> None:
"""Spec-mandated: a schema without annotations, or with annotations that are RFC 9110 tokens on
integer/string/boolean properties reachable via a pure `properties` chain and case-insensitively
unique across the whole schema, is valid."""
assert find_invalid_x_mcp_header(input_schema) is None
@pytest.mark.parametrize(
"input_schema",
[
pytest.param(_schema(a={"type": "string", "x-mcp-header": ""}), id="empty"),
pytest.param(_schema(a={"type": "string", "x-mcp-header": "My Region"}), id="space"),
pytest.param(_schema(a={"type": "string", "x-mcp-header": "Region:Primary"}), id="colon"),
pytest.param(_schema(a={"type": "string", "x-mcp-header": "Région"}), id="non-ascii"),
pytest.param(_schema(a={"type": "string", "x-mcp-header": "Region\t1"}), id="control-char"),
pytest.param(_schema(a={"type": "string", "x-mcp-header": 42}), id="non-string"),
pytest.param(_schema(a={"type": "object", "x-mcp-header": "Data"}), id="on-object"),
pytest.param(_schema(a={"type": "array", "x-mcp-header": "Items"}), id="on-array"),
pytest.param(_schema(a={"type": "null", "x-mcp-header": "Nil"}), id="on-null"),
pytest.param(_schema(a={"type": "number", "x-mcp-header": "Ratio"}), id="on-number"),
pytest.param(_schema(a={"type": ["string", "null"], "x-mcp-header": "Maybe"}), id="array-type"),
pytest.param(_schema(a={"type": {"not": "valid"}, "x-mcp-header": "Bad"}), id="dict-type"),
pytest.param(_schema(a={"x-mcp-header": "NoType"}), id="missing-type"),
pytest.param(
_schema(a={"type": "string", "x-mcp-header": "Region"}, b={"type": "string", "x-mcp-header": "Region"}),
id="duplicate-same-case",
),
pytest.param(
_schema(a={"type": "string", "x-mcp-header": "MyField"}, b={"type": "string", "x-mcp-header": "myfield"}),
id="duplicate-diff-case",
),
pytest.param(
_schema(a={"type": "array", "items": {"type": "string", "x-mcp-header": "X"}}),
id="under-items",
),
pytest.param(
{"allOf": [{"properties": {"a": {"type": "string", "x-mcp-header": "X"}}}]},
id="under-allOf",
),
pytest.param(
{"oneOf": [{"type": "string", "x-mcp-header": "X"}]},
id="under-oneOf",
),
pytest.param(
_schema(a={"if": {"type": "string", "x-mcp-header": "X"}}),
id="under-if",
),
pytest.param(
{"$defs": {"T": {"type": "string", "x-mcp-header": "X"}}, "properties": {}},
id="under-defs",
),
pytest.param(
{"patternProperties": {"^a": {"type": "string", "x-mcp-header": "X"}}},
id="under-patternProperties",
),
pytest.param(
{"type": "string", "x-mcp-header": "X"},
id="on-root-schema",
),
pytest.param(
_schema(
a={"type": "string", "x-mcp-header": "Region"},
o={"type": "object", "properties": {"b": {"type": "string", "x-mcp-header": "region"}}},
),
id="duplicate-across-nesting-levels",
),
pytest.param(
_schema(outer={"type": "object", "properties": {"r": {"type": "string", "x-mcp-header": "bad name"}}}),
id="nested-bad-token",
),
pytest.param(
_schema(outer={"type": "object", "properties": {"r": {"type": "object", "x-mcp-header": "R"}}}),
id="nested-non-primitive",
),
],
)
def test_find_invalid_x_mcp_header_rejects_malformed_annotations(input_schema: dict[str, Any]) -> None:
"""Spec-mandated: empty / non-token / non-primitive / off-chain / duplicate `x-mcp-header`
annotations yield a reason string."""
assert isinstance(find_invalid_x_mcp_header(input_schema), str)
def test_find_invalid_x_mcp_header_reports_dotted_path_for_nested_property() -> None:
"""SDK-defined: the reason string names the nested property by its dotted `properties` path."""
schema = _schema(outer={"type": "object", "properties": {"r": {"type": "object", "x-mcp-header": "R"}}})
reason = find_invalid_x_mcp_header(schema)
assert reason is not None and "'outer.r'" in reason