Validate Mcp-Param-* headers server-side on the 2026-07-28 HTTP path (SEP-2243) (#3033)
This commit is contained in:
parent
4df609119f
commit
48ef569f7e
3 changed files with 971 additions and 37 deletions
|
|
@ -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")])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue