Validate Mcp-Param-* headers server-side on the 2026-07-28 HTTP path (SEP-2243) (#3033)

This commit is contained in:
Max 2026-06-30 21:39:32 +01:00 • committed by GitHub
commit 48ef569f7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 971 additions and 37 deletions

View file

@ -17,6 +17,7 @@ import pytest
from inline_snapshot import snapshot
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
HEADER_MISMATCH,
INTERNAL_ERROR,
INVALID_PARAMS,
METHOD_NOT_FOUND,
@ -475,12 +476,13 @@ async def test_modern_client_emits_no_param_headers_for_an_unlisted_tool() -> No
The spec lets a client that lacks the tool's `inputSchema` send the request without custom headers.
The call is made with no prior `list_tools`, so the first `tools/call` POST -- captured before the
implicit output-schema `list_tools` runs -- has no cached annotations and emits no `Mcp-Param-*` header.
The server validates `Mcp-Param-*` against its own catalog and rejects as the spec's scenario table
requires for an omitted header (the relist-and-retry recovery is a SHOULD the client does not implement yet).
"""
requests: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
if json.loads(request.content)["method"] == "tools/call":
requests.append(request)
requests.append(request)
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
@ -496,8 +498,12 @@ async def test_modern_client_emits_no_param_headers_for_an_unlisted_tool() -> No
prior_discover=discover,
) as client,
):
await client.call_tool("run", {"region": "us-west1"})
with pytest.raises(MCPError) as excinfo: # pragma: no branch
await client.call_tool("run", {"region": "us-west1"})
assert excinfo.value.error.code == HEADER_MISMATCH
assert len(requests) == 1
assert json.loads(requests[0].content)["method"] == "tools/call"
assert not any(k.startswith("mcp-param-") for k in requests[0].headers)
@ -513,11 +519,13 @@ async def test_modern_client_stops_mirroring_after_a_re_list_drops_the_tool() ->
bad_schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}}
valid = Tool(name="run", input_schema=schema)
invalid = Tool(name="run", input_schema=bad_schema)
# Three pages: the call after the drop re-lists once because the prune also cleared `run`'s schema entry.
listings = iter([valid, invalid, invalid])
# First listing valid, every later one invalid; the count is not pinned because the server also
# reads its own catalog on each tools/call.
listings: list[None] = []
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[next(listings)], ttl_ms=0, cache_scope="public")
listings.append(None)
return ListToolsResult(tools=[valid if len(listings) == 1 else invalid], ttl_ms=0, cache_scope="public")
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(content=[TextContent(text="ok")])

View file

@ -23,6 +23,8 @@ from mcp_types import (
METHOD_NOT_FOUND,
PARSE_ERROR,
PROTOCOL_VERSION_META_KEY,
CallToolRequestParams,
CallToolResult,
ErrorData,
JSONRPCError,
JSONRPCResponse,
@ -36,7 +38,7 @@ from mcp_types.version import LATEST_MODERN_VERSION
from starlette.types import Message, Receive, Scope, Send
from trio.testing import MockClock
from mcp.server import Server, ServerRequestContext, runner
from mcp.server import Server, ServerRequestContext, _streamable_http_modern, runner
from mcp.server._streamable_http_modern import (
_SingleExchangeDispatchContext,
_to_jsonrpc_response,
@ -647,3 +649,362 @@ async def test_disconnect_cancels_handler_and_runs_exit_stack() -> None:
await cleanup_ran.wait()
assert handler_started.is_set()
# --- Mcp-Param-* validation (SEP-2243 server half) -------------------------------
_REGION_TOOL = Tool(
name="search",
input_schema={
"type": "object",
"properties": {"region": {"type": "string", "x-mcp-header": "Region"}},
},
)
def _tool_call_body(arguments: dict[str, Any] | None = None, *, name: str | None = "search") -> dict[str, Any]:
"""A valid 2026-07-28 `tools/call` body; `name=None` omits the name entirely."""
body = _list_tools_body()
body["method"] = "tools/call"
if name is not None:
body["params"]["name"] = name
if arguments is not None:
body["params"]["arguments"] = arguments
return body
_TOOL_CALL_HEADERS = {MCP_METHOD_HEADER: "tools/call", MCP_NAME_HEADER: "search"}
async def _ok_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(content=[])
def _x_mcp_server(tools: list[Tool] | None = None) -> Server[Any]:
"""A lowlevel server whose `tools/list` handler advertises an `x-mcp-header` tool."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=tools if tools is not None else [_REGION_TOOL], ttl_ms=0, cache_scope="public")
return Server("test", on_list_tools=list_tools, on_call_tool=_ok_call_tool)
async def test_modern_tools_call_accepts_matching_mcp_param_header() -> None:
"""A `Mcp-Param-*` header that agrees with the body argument after sentinel decoding dispatches normally."""
async with _asgi_client(_x_mcp_server()) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "Tōkyō"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "=?base64?VMWNa3nFjQ==?="},
)
assert response.status_code == 200
assert response.json()["result"]["content"] == []
@pytest.mark.parametrize("json_response", [True, False])
async def test_modern_tools_call_rejects_mcp_param_mismatch_with_400_and_header_mismatch(
json_response: bool,
) -> None:
"""Spec MUST: a header/body mismatch is HTTP 400 + `HEADER_MISMATCH`, plain JSON even in SSE mode."""
async with _asgi_client(_x_mcp_server(), json_response=json_response) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 400
assert response.headers["content-type"].split(";", 1)[0] == "application/json"
error = response.json()["error"]
assert error["code"] == HEADER_MISMATCH
assert "Mcp-Param-Region" in error["message"]
async def test_modern_tools_call_rejects_missing_mcp_param_header_for_present_argument() -> None:
"""Spec table: a missing header for a present annotated argument MUST be rejected."""
async with _asgi_client(_x_mcp_server()) as http:
response = await http.post("/mcp", json=_tool_call_body({"region": "test-value"}), headers=_TOOL_CALL_HEADERS)
assert response.status_code == 400
assert response.json()["error"]["code"] == HEADER_MISMATCH
async def test_modern_tools_call_rejects_orphan_mcp_param_header() -> None:
"""SDK posture: a header for an absent argument is the routing-spoof case the gate exists to stop."""
async with _asgi_client(_x_mcp_server()) as http:
response = await http.post(
"/mcp", json=_tool_call_body({}), headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}
)
assert response.status_code == 400
assert response.json()["error"]["code"] == HEADER_MISMATCH
async def test_modern_tools_call_skips_validation_without_a_tools_list_handler() -> None:
"""Without a `tools/list` handler no annotations were ever advertised, so `Mcp-Param-*` headers are ignored."""
server: Server[Any] = Server("test")
server.add_request_handler("tools/call", CallToolRequestParams, _ok_call_tool)
async with _asgi_client(server) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 200
assert response.json()["result"]["content"] == []
async def test_modern_tools_call_skips_validation_when_tool_not_listed_to_this_caller() -> None:
"""A tool absent from this caller's listing was never advertised to it, so its headers go unvalidated."""
other = Tool(name="other", input_schema={"type": "object"})
async with _asgi_client(_x_mcp_server(tools=[other])) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 200
async def test_modern_tools_call_skips_validation_when_list_handler_raises(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A raising `tools/list` handler fails open — validation is skipped and the skip logged at error level."""
async def broken_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
raise RuntimeError("catalog backend down")
server: Server[Any] = Server("test", on_list_tools=broken_list, on_call_tool=_ok_call_tool)
with caplog.at_level(logging.ERROR, logger=_streamable_http_modern.__name__):
async with _asgi_client(server) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 200
assert "Mcp-Param header validation skipped: the tools/list listing failed" in caplog.text
async def test_modern_tools_call_walks_pagination_to_find_the_tool() -> None:
"""The schema lookup follows `nextCursor` pages; a tool on a later page is still validated."""
cursors_seen: list[str | None] = []
async def paged_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
cursor = params.cursor if params is not None else None
cursors_seen.append(cursor)
if cursor is None:
return ListToolsResult(tools=[], next_cursor="page-2", ttl_ms=0, cache_scope="public")
return ListToolsResult(tools=[_REGION_TOOL], ttl_ms=0, cache_scope="public")
server: Server[Any] = Server("test", on_list_tools=paged_list, on_call_tool=_ok_call_tool)
async with _asgi_client(server) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 400
assert response.json()["error"]["code"] == HEADER_MISMATCH
assert cursors_seen == [None, "page-2"]
async def test_modern_tools_call_skips_validation_on_a_cursor_cycle(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A repeated cursor stops the walk with a logged skip instead of hanging the request."""
async def cycling_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[], next_cursor="loop", ttl_ms=0, cache_scope="public")
server: Server[Any] = Server("test", on_list_tools=cycling_list, on_call_tool=_ok_call_tool)
with caplog.at_level(logging.WARNING, logger=_streamable_http_modern.__name__):
async with _asgi_client(server) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 200
assert "cursor cycle" in caplog.text
async def test_modern_tools_call_skips_validation_at_the_pagination_cap(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A non-terminating cursor chain stops at the page cap: logged skip, never a hang."""
monkeypatch.setattr(_streamable_http_modern, "_MCP_PARAM_LIST_PAGE_CAP", 3)
pages = iter(range(1_000_000))
async def endless_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[], next_cursor=f"page-{next(pages)}", ttl_ms=0, cache_scope="public")
server: Server[Any] = Server("test", on_list_tools=endless_list, on_call_tool=_ok_call_tool)
with caplog.at_level(logging.WARNING, logger=_streamable_http_modern.__name__):
async with _asgi_client(server) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 200
assert "did not terminate within 3 pages" in caplog.text
async def test_modern_tools_call_threads_the_callers_envelope_into_the_synthetic_listing() -> None:
"""The synthetic `tools/list` runs as this caller, so a visibility-scoped handler produces its view."""
seen: list[Any] = []
async def recording_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
seen.append(ctx.session.client_params)
return ListToolsResult(tools=[_REGION_TOOL], ttl_ms=0, cache_scope="public")
server: Server[Any] = Server("test", on_list_tools=recording_list, on_call_tool=_ok_call_tool)
async with _asgi_client(server) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "eu"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 200
assert len(seen) == 1
assert seen[0] is not None
assert seen[0].client_info.name == "raw"
async def test_modern_tools_call_leaves_mis_shaped_name_and_arguments_to_dispatch() -> None:
"""A missing `name` or non-mapping `arguments` is dispatch's INVALID_PARAMS, never a header mismatch."""
async with _asgi_client(_x_mcp_server()) as http:
nameless = await http.post("/mcp", json=_tool_call_body(name=None), headers={MCP_METHOD_HEADER: "tools/call"})
bad_arguments_body = _tool_call_body()
bad_arguments_body["params"]["arguments"] = ["not", "a", "mapping"]
bad_arguments = await http.post(
"/mcp",
json=bad_arguments_body,
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert nameless.json()["error"]["code"] == INVALID_PARAMS
assert bad_arguments.json()["error"]["code"] == INVALID_PARAMS
async def test_modern_tools_call_rejects_a_duplicated_mcp_param_header() -> None:
"""A duplicated recognized header is rejected even if one copy matches: readers may disagree on which wins."""
# An httpx header list with a repeated name reaches the ASGI scope as two raw header lines.
duplicated = httpx.Headers(
[*_TOOL_CALL_HEADERS.items(), ("mcp-param-region", "spoofed"), ("mcp-param-region", "eu")]
)
async with _asgi_client(_x_mcp_server()) as http:
response = await http.post("/mcp", json=_tool_call_body({"region": "eu"}), headers=duplicated)
assert response.status_code == 400
error = response.json()["error"]
assert error["code"] == HEADER_MISMATCH
assert "more than once" in error["message"]
async def test_modern_synthetic_listing_does_not_replay_caller_meta_extras() -> None:
"""The rebuilt synthetic-listing envelope does not replay caller `_meta` extras like a progress token."""
seen_metas: list[Any] = []
async def recording_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
assert params is not None
seen_metas.append(params.meta)
return ListToolsResult(tools=[_REGION_TOOL], ttl_ms=0, cache_scope="public")
server: Server[Any] = Server("test", on_list_tools=recording_list, on_call_tool=_ok_call_tool)
body = _tool_call_body({"region": "eu"})
body["params"]["_meta"]["progressToken"] = "tok-1"
async with _asgi_client(server) as http:
response = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"})
assert response.status_code == 200
assert len(seen_metas) == 1
assert seen_metas[0] is not None
assert "progressToken" not in seen_metas[0]
async def test_modern_post_rejects_a_duplicated_routing_header() -> None:
"""A duplicated routing header (`Mcp-Name`) is unverifiable and rejected before the validation ladder runs."""
duplicated = httpx.Headers(
[(MCP_METHOD_HEADER, "tools/call"), (MCP_NAME_HEADER, "search"), (MCP_NAME_HEADER, "admin-tool")]
)
async with _asgi_client(_x_mcp_server()) as http:
response = await http.post("/mcp", json=_tool_call_body({"region": "eu"}), headers=duplicated)
assert response.status_code == 400
error = response.json()["error"]
assert error["code"] == HEADER_MISMATCH
assert "more than once" in error["message"]
async def test_modern_tools_call_mis_shaped_envelope_skips_validation_without_an_error_log(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A mis-shaped envelope value is a debug-logged client fault; the wire reply is dispatch's INVALID_PARAMS."""
body = _tool_call_body({"region": "eu"})
body["params"]["_meta"][CLIENT_INFO_META_KEY] = "not-an-object"
with caplog.at_level(logging.DEBUG, logger=_streamable_http_modern.__name__):
async with _asgi_client(_x_mcp_server()) as http:
response = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"})
assert response.status_code == 400
assert response.json()["error"]["code"] == INVALID_PARAMS
module_records = [r for r in caplog.records if r.name == _streamable_http_modern.__name__]
assert all(r.levelno < logging.ERROR for r in module_records)
assert any("fails tools/list validation" in r.message for r in module_records)
async def test_modern_tools_call_survives_a_middleware_short_circuit_with_a_mis_shaped_listing(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A mis-shaped `tools/list` result from a middleware short-circuit fails open instead of crashing to a 500."""
async def short_circuit(ctx: Any, call_next: Any) -> Any:
if ctx.method == "tools/list":
return {"tools": None}
return await call_next(ctx)
server = _x_mcp_server()
server.middleware = [short_circuit]
with caplog.at_level(logging.ERROR, logger=_streamable_http_modern.__name__):
async with _asgi_client(server) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 200
assert "Mcp-Param header validation skipped: the tools/list listing failed" in caplog.text
async def test_modern_post_with_an_oversized_integer_literal_is_parse_error_not_a_crash() -> None:
"""An integer past CPython's digit limit makes json.loads raise a bare ValueError — still 400 + PARSE_ERROR."""
body = b'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"a":' + b"1" * 5000 + b"}}"
async with _asgi_client(_x_mcp_server()) as http:
response = await http.post("/mcp", content=body, headers={"content-type": "application/json"})
assert response.status_code == 400
assert response.json()["error"]["code"] == PARSE_ERROR
async def test_modern_tools_call_logs_a_handler_raised_validation_error_loudly(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A ValidationError from inside the tools/list handler is a server fault: the skip is
logged at error level, not mistaken for a client-fault envelope."""
async def broken_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool.model_validate({"bogus": 1})], ttl_ms=0, cache_scope="public")
server: Server[Any] = Server("test", on_list_tools=broken_list, on_call_tool=_ok_call_tool)
with caplog.at_level(logging.ERROR, logger=_streamable_http_modern.__name__):
async with _asgi_client(server) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 200
assert "Mcp-Param header validation skipped: the tools/list listing failed" in caplog.text
async def test_modern_post_with_deeply_nested_body_is_parse_error_not_a_crash() -> None:
"""Deep nesting makes json.loads raise RecursionError; still an unparseable body: 400 + PARSE_ERROR."""
body = b"[" * 100_000 + b"]" * 100_000
async with _asgi_client(_x_mcp_server()) as http:
response = await http.post("/mcp", content=body, headers={"content-type": "application/json"})
assert response.status_code == 400
assert response.json()["error"]["code"] == PARSE_ERROR

View file

@ -6,6 +6,7 @@ or protocol-version literals — all facts are imported from their one source.
"""
import dataclasses
from collections.abc import Iterator, Mapping
from typing import Any
import pytest
@ -39,8 +40,10 @@ from mcp.shared.inbound import (
classify_inbound_request,
decode_header_value,
encode_header_value,
find_duplicated_routing_header,
find_invalid_x_mcp_header,
mcp_param_headers,
validate_mcp_param_headers,
x_mcp_header_map,
)
@ -574,3 +577,273 @@ def test_mcp_param_headers_omits_when_nested_path_is_broken() -> None:
header_map = {("outer", "inner"): "Inner"}
assert mcp_param_headers(header_map, {"outer": "not-a-mapping"}) == {}
assert mcp_param_headers(header_map, {}) == {}
# --- validate_mcp_param_headers --------------------------------------------
REGION_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"region": {"type": "string", "x-mcp-header": "Region"}},
}
@pytest.mark.parametrize(
("argument", "header"),
[
pytest.param("Hello", "Hello", id="plain-literal"),
pytest.param("Hello", "=?base64?SGVsbG8=?=", id="valid-sentinel"),
pytest.param("", "=?base64??=", id="empty-sentinel"),
pytest.param("SGVsbG8=", "SGVsbG8=", id="missing-prefix-is-literal"),
pytest.param("=?base64?SGVsbG8=", "=?base64?SGVsbG8=", id="missing-suffix-is-literal"),
],
)
def test_validate_mcp_param_headers_accepts_agreeing_header_and_argument(argument: str, header: str) -> None:
"""Spec Value Encoding: a fully-wrapped sentinel decodes before comparison; anything else is a literal."""
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": argument}, {"Mcp-Param-Region": header}) is None
@pytest.mark.parametrize(
"header",
[
pytest.param("=?base64?SGVsbG8?=", id="missing-padding"),
pytest.param("=?base64?SGVs!!!bG8=?=", id="non-alphabet-chars"),
pytest.param("=?base64?SGVsbG9=?=", id="non-canonical-trailing-bits"),
pytest.param("=?base64?gA==?=", id="invalid-utf8"),
],
)
def test_validate_mcp_param_headers_rejects_malformed_sentinel(header: str) -> None:
"""Spec: servers MUST reject a recognized header whose sentinel cannot be strictly decoded — not a literal."""
rejection = assert_rejected(
validate_mcp_param_headers(REGION_SCHEMA, {"region": "Hello"}, {"Mcp-Param-Region": header}),
HEADER_MISMATCH,
)
assert "malformed base64" in rejection.message
def test_validate_mcp_param_headers_rejects_missing_header_for_present_argument() -> None:
"""Spec table: client omits the header but the value is in the body → server MUST reject."""
rejection = assert_rejected(
validate_mcp_param_headers(REGION_SCHEMA, {"region": "test-value"}, {}), HEADER_MISMATCH
)
assert "missing" in rejection.message
@pytest.mark.parametrize(
"arguments",
[pytest.param({}, id="absent"), pytest.param({"region": None}, id="null")],
)
def test_validate_mcp_param_headers_rejects_orphan_header_for_absent_or_null_argument(
arguments: dict[str, Any],
) -> None:
"""SDK-defined posture on a spec gap: an orphan header is the routing-spoof case; go rejects too, ts skips."""
rejection = assert_rejected(
validate_mcp_param_headers(REGION_SCHEMA, arguments, {"Mcp-Param-Region": "eu"}), HEADER_MISMATCH
)
assert "absent" in rejection.message
def test_validate_mcp_param_headers_rejects_value_mismatch() -> None:
rejection = assert_rejected(
validate_mcp_param_headers(REGION_SCHEMA, {"region": "us"}, {"Mcp-Param-Region": "eu"}), HEADER_MISMATCH
)
assert "does not match" in rejection.message
def test_validate_mcp_param_headers_accepts_absent_argument_with_no_header() -> None:
"""Spec table: parameter not in arguments / null → client MUST omit, server MUST NOT expect."""
assert validate_mcp_param_headers(REGION_SCHEMA, {}, {}) is None
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": None}, {}) is None
def test_validate_mcp_param_headers_matches_header_names_case_insensitively() -> None:
"""Spec Case Sensitivity: header-name comparison MUST be case-insensitive."""
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": "eu"}, {"MCP-PARAM-REGION": "eu"}) is None
rejection = validate_mcp_param_headers(REGION_SCHEMA, {"region": "us"}, {"MCP-PARAM-REGION": "eu"})
assert_rejected(rejection, HEADER_MISMATCH)
def test_validate_mcp_param_headers_ignores_undeclared_mcp_param_headers() -> None:
"""Spec: an undeclared `Mcp-Param-*` header is unrecognized — forwarded and ignored, never a failure."""
headers = {"Mcp-Param-Region": "eu", "Mcp-Param-Undeclared": "=?base64?not even base64?="}
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": "eu"}, headers) is None
def test_validate_mcp_param_headers_validates_nothing_for_an_invalid_annotation_schema() -> None:
"""Spec gives definition rejection to clients (drop the tool), so an invalid schema recognizes no headers."""
invalid = {
"type": "object",
"properties": {
"region": {"type": "string", "x-mcp-header": "Region"},
"dupe": {"type": "string", "x-mcp-header": "region"},
},
}
assert find_invalid_x_mcp_header(invalid) is not None
assert validate_mcp_param_headers(invalid, {"region": "us"}, {"Mcp-Param-Region": "eu"}) is None
def test_validate_mcp_param_headers_reads_nested_argument_paths() -> None:
"""A nested annotated property compares against the matching nested `arguments` path; a broken path is absent."""
schema = {
"type": "object",
"properties": {
"outer": {"type": "object", "properties": {"inner": {"type": "string", "x-mcp-header": "Inner"}}}
},
}
assert validate_mcp_param_headers(schema, {"outer": {"inner": "deep"}}, {"Mcp-Param-Inner": "deep"}) is None
rejection = validate_mcp_param_headers(schema, {"outer": {"inner": "deep"}}, {"Mcp-Param-Inner": "other"})
assert_rejected(rejection, HEADER_MISMATCH)
assert validate_mcp_param_headers(schema, {"outer": "not-a-mapping"}, {}) is None
def test_validate_mcp_param_headers_compares_booleans_against_true_false_rendering() -> None:
"""Booleans compare against the lowercase `true`/`false` rendering the client emits."""
schema = {"type": "object", "properties": {"flag": {"type": "boolean", "x-mcp-header": "Flag"}}}
assert validate_mcp_param_headers(schema, {"flag": True}, {"Mcp-Param-Flag": "true"}) is None
assert validate_mcp_param_headers(schema, {"flag": False}, {"Mcp-Param-Flag": "false"}) is None
rejection = validate_mcp_param_headers(schema, {"flag": True}, {"Mcp-Param-Flag": "True"})
assert_rejected(rejection, HEADER_MISMATCH)
INTEGER_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"n": {"type": "integer", "x-mcp-header": "N"}},
}
@pytest.mark.parametrize(
("body_value", "header", "matches"),
[
pytest.param(42, "42", True, id="exact"),
pytest.param(42, "42.0", True, id="trailing-zero-fraction"),
pytest.param(42, "42.000", True, id="long-zero-fraction"),
pytest.param(42, "42.5", False, id="real-fraction"),
pytest.param(42, "1e2", False, id="scientific-notation-never-numeric"),
pytest.param(42, "43", False, id="different-value"),
pytest.param(-7, "-7.0", True, id="negative"),
pytest.param(9007199254740993, "9007199254740993", True, id="beyond-ieee754-safe-range-exact"),
pytest.param(9007199254740993, "9007199254740992", False, id="beyond-ieee754-safe-range-off-by-one"),
],
)
def test_validate_mcp_param_headers_compares_integers_numerically(body_value: int, header: str, matches: bool) -> None:
"""Spec SHOULD: integers compare numerically (`42` == `42.0`) — gated to canonical decimals, compared exactly."""
result = validate_mcp_param_headers(INTEGER_SCHEMA, {"n": body_value}, {"Mcp-Param-N": header})
if matches:
assert result is None
else:
assert_rejected(result, HEADER_MISMATCH)
def test_validate_mcp_param_headers_non_primitive_body_value_rejects_only_when_a_header_claims_it() -> None:
"""A header claiming a non-primitive argument is a mismatch; without one, rejection is params validation's job."""
rejection = assert_rejected(
validate_mcp_param_headers(REGION_SCHEMA, {"region": {"k": "v"}}, {"Mcp-Param-Region": "x"}),
HEADER_MISMATCH,
)
assert "does not match" in rejection.message
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": {"k": "v"}}, {}) is None
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": [1, 2]}, {}) is None
class _RepeatedHeaders(Mapping[str, str]):
"""A header carrier whose `items()` yields duplicate names, like a raw HTTP header list."""
def __init__(self, pairs: list[tuple[str, str]]) -> None:
self._pairs = pairs
def __getitem__(self, key: str) -> str:
return next(value for name, value in self._pairs if name == key)
def __iter__(self) -> Iterator[str]:
return (name for name, _ in self._pairs)
def __len__(self) -> int:
return len(self._pairs)
def items(self) -> Any:
return list(self._pairs)
def test_validate_mcp_param_headers_rejects_a_recognized_header_supplied_more_than_once() -> None:
"""Duplicate recognized headers reject even when one matches: first-copy readers and last-wins checks diverge."""
headers = _RepeatedHeaders([("Mcp-Param-Region", "spoofed"), ("mcp-param-region", "eu")])
# The carrier behaves like a raw header list: first-wins lookup, every line iterated.
assert headers["Mcp-Param-Region"] == "spoofed"
assert len(headers) == len(list(headers)) == 2
rejection = assert_rejected(validate_mcp_param_headers(REGION_SCHEMA, {"region": "eu"}, headers), HEADER_MISMATCH)
assert "more than once" in rejection.message
noisy = _RepeatedHeaders([("Mcp-Param-Region", "eu"), ("Mcp-Param-Other", "a"), ("mcp-param-other", "b")])
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": "eu"}, noisy) is None
def test_validate_mcp_param_headers_rejects_a_header_exceeding_the_int_conversion_limit() -> None:
"""A canonical-decimal header beyond CPython's int-conversion digit limit is a clean mismatch, never an error."""
rejection = validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 1}, {"Mcp-Param-N": "1" * 5000})
assert_rejected(rejection, HEADER_MISMATCH)
def test_validate_mcp_param_headers_compares_integral_float_bodies_numerically() -> None:
"""JSON Schema admits `42.0` as an integer; the numeric SHOULD applies in both directions."""
assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42.0}, {"Mcp-Param-N": "42"}) is None
assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42.0}, {"Mcp-Param-N": "42.0"}) is None
assert_rejected(validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42.0}, {"Mcp-Param-N": "43"}), HEADER_MISMATCH)
# A genuinely fractional body value falls back to the exact string rendering.
assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42.5}, {"Mcp-Param-N": "42.5"}) is None
@pytest.mark.parametrize(
"value",
[
pytest.param("=?base64?SGVsbG9=?=", id="non-canonical-trailing-bits"),
pytest.param("=?base64?SGVsbG8?=", id="missing-padding"),
],
)
def test_decode_header_value_returns_none_for_non_canonical_base64(value: str) -> None:
"""Canonical base64 only: a payload that decodes but does not re-encode byte-identically is malformed."""
assert decode_header_value(value) is None
def test_validate_mcp_param_headers_union_typed_annotation_invalidates_the_whole_tool() -> None:
"""A union-typed annotation fails the integer/string/boolean-only rule, so the whole schema validates nothing."""
union_schema = {
"type": "object",
"properties": {"n": {"type": ["integer", "null"], "x-mcp-header": "N"}},
}
assert find_invalid_x_mcp_header(union_schema) is not None
assert validate_mcp_param_headers(union_schema, {"n": 42}, {"Mcp-Param-N": "999"}) is None
assert validate_mcp_param_headers(union_schema, {"n": 42}, {}) is None
def test_validate_mcp_param_headers_accepts_the_clients_own_rendering_of_large_integral_floats() -> None:
"""A non-canonical-decimal header falls back to rendered comparison, so the client's own mirroring round-trips."""
emitted = mcp_param_headers(x_mcp_header_map(INTEGER_SCHEMA), {"n": 1e16})
assert emitted == {"Mcp-Param-N": "1e+16"}
assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 1e16}, emitted) is None
assert_rejected(validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42}, {"Mcp-Param-N": "1e2"}), HEADER_MISMATCH)
def test_validate_mcp_param_headers_handles_unrenderable_huge_integer_bodies_without_raising() -> None:
"""An integer beyond CPython's int-to-str digit limit has no rendering: claimed → mismatch, unclaimed → fine."""
huge = 10**5000
rejection = validate_mcp_param_headers(REGION_SCHEMA, {"region": huge}, {"Mcp-Param-Region": "x"})
assert_rejected(rejection, HEADER_MISMATCH)
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": huge}, {}) is None
assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": huge}, {}) is None
def test_mcp_param_headers_omits_values_with_no_scalar_rendering() -> None:
"""Objects, arrays, and over-limit integers have no scalar rendering, so the client omits the header."""
header_map = {("v",): "Val"}
assert mcp_param_headers(header_map, {"v": {"k": 1}}) == {}
assert mcp_param_headers(header_map, {"v": [1, 2]}) == {}
assert mcp_param_headers(header_map, {"v": 10**5000}) == {}
def test_find_duplicated_routing_header_detects_repeats_of_routing_headers_only() -> None:
"""Repeated routing headers report case-insensitively; `Mcp-Param-*` or unrelated repeats are ignored."""
assert find_duplicated_routing_header([("Mcp-Name", "a"), ("mcp-name", "b")]) == MCP_NAME_HEADER
assert find_duplicated_routing_header([("MCP-Protocol-Version", "x"), ("mcp-protocol-version", "x")]) == (
MCP_PROTOCOL_VERSION_HEADER
)
assert find_duplicated_routing_header([("Mcp-Method", "a"), ("Mcp-Method", "a")]) == MCP_METHOD_HEADER
assert find_duplicated_routing_header([("Mcp-Name", "a"), ("Mcp-Param-X", "1"), ("Mcp-Param-X", "2")]) is None
assert find_duplicated_routing_header([("accept", "a"), ("accept", "b")]) is None