Mirror x-mcp-header tool arguments into Mcp-Param-* request headers (SEP-2243) (#2990)
This commit is contained in:
parent
3a8da8c0c3
commit
5b2713d40c
3 changed files with 393 additions and 14 deletions
|
|
@ -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=(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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, {}) == {}
|
||||
|
|
|
|||
Loading…
Reference in a new issue