Mirror x-mcp-header tool arguments into Mcp-Param-* request headers (SEP-2243) (#2990)

This commit is contained in:
Marcelo Trylesinski 2026-06-26 14:36:56 +02:00 • committed by GitHub
commit 5b2713d40c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 393 additions and 14 deletions

View file

@ -3372,6 +3372,19 @@ REQUIREMENTS: dict[str, Requirement] = {
transports=("streamable-http",),
note="Only observable over streamable HTTP: headers are derived from the body envelope at the transport seam.",
),
"client-transport:http:custom-param-headers": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#custom-headers-from-tool-parameters",
behavior=(
"On a tools/call, a client mirrors each argument annotated with x-mcp-header in the tool's "
"inputSchema into an Mcp-Param-<name> header -- string as-is, integer as decimal, boolean as "
"true/false, base64-sentinel-wrapped when not header-safe -- omitting null or absent arguments and "
"never mirroring unannotated parameters. The schema is taken from the tool's last list_tools entry; "
"a tool the client never listed emits no Mcp-Param-* headers."
),
added_in="2026-07-28",
transports=("streamable-http",),
note="Only observable over streamable HTTP: headers are derived from the cached tool schema at the seam.",
),
"client-transport:http:stateless-ignores-session-id": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers",
behavior=(

View file

@ -38,6 +38,7 @@ from mcp_types import (
from mcp_types.version import LATEST_MODERN_VERSION
from mcp import MCPError
from mcp.client.client import Client
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server, ServerRequestContext
@ -388,3 +389,164 @@ async def test_pinned_client_stateless_tools_call_round_trips_against_the_modern
assert len(responses) == len(requests)
assert all("mcp-session-id" not in r.headers for r in requests)
assert all("mcp-session-id" not in r.headers for r in responses)
_CUSTOM_HEADER_TOOL = Tool(
name="run",
input_schema={
"type": "object",
"properties": {
"region": {"type": "string", "x-mcp-header": "Region"},
"priority": {"type": "integer", "x-mcp-header": "Priority"},
"verbose": {"type": "boolean", "x-mcp-header": "Verbose"},
"note": {"type": "string", "x-mcp-header": "Note"},
"query": {"type": "string"},
},
"required": ["region"],
},
)
def _custom_header_server() -> Server:
"""A server with one tool whose schema annotates four args with `x-mcp-header` and leaves `query` plain."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[_CUSTOM_HEADER_TOOL], ttl_ms=0, cache_scope="public")
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(content=[TextContent(text="ok")])
return Server("custom-headers", on_list_tools=list_tools, on_call_tool=call_tool)
@requirement("client-transport:http:custom-param-headers")
async def test_modern_client_mirrors_x_mcp_header_args_into_mcp_param_headers() -> None:
"""A tools/call mirrors the tool's `x-mcp-header` arguments into `Mcp-Param-*` headers.
After `list_tools` caches the tool's annotations, the client renders each annotated argument into
its header per the spec's Value Encoding rules: `region` verbatim, `priority` as a decimal, `verbose`
as `false`, and the non-ASCII `note` base64-sentinel-wrapped. The unannotated `query` and the omitted
`verbose`-sibling stay out of the headers, and every mirrored value remains in the request body. Asserted
at the wire because the client never surfaces the outgoing headers.
"""
requests: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
requests.append(request)
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with (
mounted_app(_custom_header_server(), on_request=on_request) as (http, _),
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=discover,
) as client,
):
await client.list_tools()
await client.call_tool("run", {"region": "us-west1", "priority": 42, "verbose": False, "note": "héllo"})
call = next(r for r in requests if json.loads(r.content)["method"] == "tools/call")
assert {k: v for k, v in call.headers.items() if k.startswith("mcp-param-")} == snapshot(
{
"mcp-param-region": "us-west1",
"mcp-param-priority": "42",
"mcp-param-verbose": "false",
"mcp-param-note": "=?base64?aMOpbGxv?=",
}
)
# Mirroring is additive: the arguments are unchanged in the body.
assert json.loads(call.content)["params"]["arguments"] == snapshot(
{"region": "us-west1", "priority": 42, "verbose": False, "note": "héllo"}
)
@requirement("client-transport:http:custom-param-headers")
async def test_modern_client_emits_no_param_headers_for_an_unlisted_tool() -> None:
"""A `tools/call` for a tool the client never listed carries no `Mcp-Param-*` headers.
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.
"""
requests: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
if json.loads(request.content)["method"] == "tools/call":
requests.append(request)
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with (
mounted_app(_custom_header_server(), on_request=on_request) as (http, _),
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=discover,
) as client,
):
await client.call_tool("run", {"region": "us-west1"})
assert not any(k.startswith("mcp-param-") for k in requests[0].headers)
@requirement("client-transport:http:custom-param-headers")
async def test_modern_client_stops_mirroring_after_a_re_list_drops_the_tool() -> None:
"""A re-list that drops a previously valid tool stops mirroring its `x-mcp-header` args.
The tool is first listed with a valid annotation (so a call mirrors `Mcp-Param-Region`), then re-listed
with an invalid annotation -- the modern client drops it and evicts the cached map, so a later `tools/call`
by name carries no `Mcp-Param-*` header. Asserted at the wire, where the eviction is observable.
"""
schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "Region"}}}
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)
listings = iter([valid, invalid])
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[next(listings)], ttl_ms=0, cache_scope="public")
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(content=[TextContent(text="ok")])
server = Server("evict", on_list_tools=list_tools, on_call_tool=call_tool)
tool_calls: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
if json.loads(request.content)["method"] == "tools/call":
tool_calls.append(request)
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with (
mounted_app(server, on_request=on_request) as (http, _),
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=discover,
) as client,
):
assert [t.name for t in (await client.list_tools()).tools] == ["run"]
await client.call_tool("run", {"a": "x"})
assert [t.name for t in (await client.list_tools()).tools] == []
await client.call_tool("run", {"a": "x"})
before, after = tool_calls
assert before.headers.get("mcp-param-region") == "x"
assert not any(k.startswith("mcp-param-") for k in after.headers)

View file

@ -40,6 +40,8 @@ from mcp.shared.inbound import (
decode_header_value,
encode_header_value,
find_invalid_x_mcp_header,
mcp_param_headers,
x_mcp_header_map,
)
CLIENT_INFO = {"name": "t", "version": "0"}
@ -505,3 +507,70 @@ def test_find_invalid_x_mcp_header_reports_dotted_path_for_nested_property() ->
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
# --- x_mcp_header_map ----------------------------------------------------------
def test_x_mcp_header_map_keys_top_level_and_nested_properties_by_path() -> None:
"""Each annotated property maps to its token under its full `properties` path; unannotated props are absent."""
schema = _schema(
region={"type": "string", "x-mcp-header": "Region"},
query={"type": "string"},
outer={"type": "object", "properties": {"inner": {"type": "string", "x-mcp-header": "Inner"}}},
)
assert x_mcp_header_map(schema) == {("region",): "Region", ("outer", "inner"): "Inner"}
@pytest.mark.parametrize("input_schema", [None, "not-a-mapping", {"type": "object"}])
def test_x_mcp_header_map_empty_for_schemas_without_annotations(input_schema: Any) -> None:
assert x_mcp_header_map(input_schema) == {}
# --- mcp_param_headers ---------------------------------------------------------
def test_mcp_param_headers_renders_primitive_types_per_spec() -> None:
"""String verbatim, integer as decimal, boolean as lowercase `true`/`false`, header named `Mcp-Param-<token>`."""
header_map = {("region",): "Region", ("priority",): "Priority", ("verbose",): "Verbose", ("debug",): "Debug"}
arguments = {"region": "us-west1", "priority": 42, "verbose": False, "debug": True}
assert mcp_param_headers(header_map, arguments) == {
"Mcp-Param-Region": "us-west1",
"Mcp-Param-Priority": "42",
"Mcp-Param-Verbose": "false",
"Mcp-Param-Debug": "true",
}
@pytest.mark.parametrize(
("value", "encoded"),
[
pytest.param("us-west1", "us-west1", id="plain-ascii"),
pytest.param("Hello, 世界", "=?base64?SGVsbG8sIOS4lueVjA==?=", id="non-ascii"),
pytest.param(" padded ", "=?base64?IHBhZGRlZCA=?=", id="edge-whitespace"),
pytest.param("line1\nline2", "=?base64?bGluZTEKbGluZTI=?=", id="control-char"),
pytest.param("=?base64?literal?=", "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=", id="sentinel-lookalike"),
],
)
def test_mcp_param_headers_base64_wraps_header_unsafe_strings(value: str, encoded: str) -> None:
"""Matches the spec's Value Encoding table: a non-header-safe string is base64-sentinel wrapped."""
assert mcp_param_headers({("v",): "Val"}, {"v": value}) == {"Mcp-Param-Val": encoded}
def test_mcp_param_headers_omits_absent_or_null_arguments() -> None:
"""A path that hits a missing key or a `None` value emits no header (spec: omit when no value is present)."""
header_map = {("present",): "Present", ("missing",): "Missing", ("nulled",): "Nulled"}
assert mcp_param_headers(header_map, {"present": "x", "nulled": None}) == {"Mcp-Param-Present": "x"}
def test_mcp_param_headers_reads_nested_argument_path() -> None:
"""A nested annotated property reads its value at the matching nested `arguments` path."""
headers = mcp_param_headers({("outer", "inner"): "Inner"}, {"outer": {"inner": "deep"}})
assert headers == {"Mcp-Param-Inner": "deep"}
def test_mcp_param_headers_omits_when_nested_path_is_broken() -> None:
"""A nested path through a non-mapping or missing intermediate node yields no header."""
header_map = {("outer", "inner"): "Inner"}
assert mcp_param_headers(header_map, {"outer": "not-a-mapping"}) == {}
assert mcp_param_headers(header_map, {}) == {}