Skip to content

Commit cff6319

Browse files
committed
Mirror x-mcp-header tool arguments into Mcp-Param-* request headers (SEP-2243)
On a modern (2026-07-28) Streamable HTTP connection, a tools/call now mirrors each argument annotated with x-mcp-header in the tool's input schema into an Mcp-Param-<name> header: string verbatim, integer as decimal, boolean as true/false, base64-sentinel-wrapped when not header-safe. Null or absent arguments are omitted and unannotated parameters are never mirrored; the argument stays in the request body. The tool schema comes from a prior list_tools (annotations are cached) or a per-call tool= override, so a client can emit headers without a prior list_tools. An uncached tool emits no Mcp-Param-* headers. Adds the http-custom-headers conformance client handler. The scenario stays an expected failure: its fixture annotates number-typed properties, which the spec forbids, so a conformant client drops those tools.
1 parent e9cd169 commit cff6319

11 files changed

Lines changed: 376 additions & 8 deletions

File tree

.github/actions/conformance/client.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,43 @@ async def run_http_invalid_tool_headers(server_url: str) -> None:
335335
logger.exception(f"call_tool({tool.name!r}) failed")
336336

337337

338+
def _stub_args_for_custom_headers(input_schema: dict[str, Any]) -> dict[str, Any]:
339+
"""Arguments exercising every `x-mcp-header`-annotated property in a tool schema.
340+
341+
Each annotated property gets a type-appropriate value so the SDK mirrors it into an
342+
`Mcp-Param-*` header; required properties without an annotation get a placeholder so
343+
the call is well-formed.
344+
"""
345+
by_type: dict[str, Any] = {"string": "us-west1", "integer": 42, "boolean": False, "number": 3.14}
346+
properties: dict[str, Any] = input_schema.get("properties", {})
347+
arguments: dict[str, Any] = {}
348+
for name, schema in properties.items():
349+
if "x-mcp-header" in schema:
350+
arguments[name] = by_type.get(schema.get("type"), "x")
351+
for name in input_schema.get("required", []):
352+
arguments.setdefault(name, by_type.get(properties.get(name, {}).get("type"), "x"))
353+
return arguments
354+
355+
356+
@register("http-custom-headers")
357+
async def run_http_custom_headers(server_url: str) -> None:
358+
"""List tools, then call each surfaced tool so its `x-mcp-header` args mirror into headers (SEP-2243).
359+
360+
A conforming client drops tools with invalid annotations during `list_tools` (e.g. the
361+
harness's `number`-typed properties, which the spec forbids), so the loop only calls tools
362+
whose annotations are valid; for those, the SDK emits the `Mcp-Param-*` headers the scenario
363+
checks. Per-call failures are logged and skipped rather than aborting the run.
364+
"""
365+
async with Client(server_url, mode=client_mode()) as client:
366+
listed = await client.list_tools()
367+
logger.debug(f"Surfaced tools: {[t.name for t in listed.tools]}")
368+
for tool in listed.tools:
369+
try:
370+
await client.call_tool(tool.name, _stub_args_for_custom_headers(tool.input_schema))
371+
except Exception:
372+
logger.exception(f"call_tool({tool.name!r}) failed")
373+
374+
338375
@register("elicitation-sep1034-client-defaults")
339376
async def run_elicitation_defaults(server_url: str) -> None:
340377
"""Connect with elicitation callback that applies schema defaults."""
@@ -526,8 +563,6 @@ def main() -> None:
526563
elif scenario.startswith("auth/"):
527564
asyncio.run(run_auth_code_client(server_url))
528565
else:
529-
# Unhandled scenarios:
530-
# - http-custom-headers (SEP-2243 / S8: Mcp-Param-* emission)
531566
print(f"Unknown scenario: {scenario}", file=sys.stderr)
532567
sys.exit(1)
533568
else:

.github/actions/conformance/expected-failures.2026-07-28.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@
2121
# milestone.
2222

2323
client:
24-
# SEP-2243 (HTTP standardization): no client Mcp-Param-* support yet — needs the
25-
# tool-schema-cache vs per-call tool_definition design (S8).
24+
# SEP-2243 (HTTP standardization): the client now mirrors x-mcp-header args into
25+
# Mcp-Param-* headers (S8), but the harness fixture annotates `number`-typed
26+
# properties, which the spec forbids ("Parameters with type number are not
27+
# permitted"). The SDK drops those tools per spec, so the scenario's
28+
# ClientSupportsCustomHeaders check (which requires the tool to be called)
29+
# cannot pass until the harness fixture is corrected.
2630
- http-custom-headers
2731
# auth/enterprise-managed-authorization (SEP-990) is in the 2025 baseline but
2832
# NOT here: the harness skips it as inapplicable at --spec-version 2026-07-28

.github/actions/conformance/expected-failures.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@
1212

1313
client:
1414
# --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) ---
15-
# SEP-2243 (HTTP standardization): no client Mcp-Param-* support yet — needs the
16-
# tool-schema-cache vs per-call tool_definition design (S8).
15+
# SEP-2243 (HTTP standardization): the client now mirrors x-mcp-header args into
16+
# Mcp-Param-* headers (S8), but the harness fixture annotates `number`-typed
17+
# properties, which the spec forbids ("Parameters with type number are not
18+
# permitted"). The SDK drops those tools per spec, so the scenario's
19+
# ClientSupportsCustomHeaders check (which requires the tool to be called)
20+
# cannot pass until the harness fixture is corrected.
1721
- http-custom-headers
1822

1923
# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---

docs/migration.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,10 @@ For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer`
398398

399399
For protocol 2026-07-28, a `tools/call` request may return an `InputRequiredResult` asking the client to supply additional input and retry. By default `call_tool` (on `ClientSession`, `Client`, and `ClientSessionGroup`) still returns `CallToolResult` and raises `RuntimeError` if the server requests input. Pass `allow_input_required=True` to receive the `InputRequiredResult` instead, then retry with `input_responses=` / `request_state=`.
400400

401+
### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers (SEP-2243)
402+
403+
For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-<name>` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations; if the client has not listed the tool, pass its definition via `call_tool(..., tool=...)` to enable mirroring without a prior `list_tools`. Other transports ignore the annotation.
404+
401405
### `McpError` renamed to `MCPError`
402406

403407
The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK.

src/mcp/client/client.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
RequestParamsMeta,
2929
ResourceTemplateReference,
3030
ServerCapabilities,
31+
Tool,
3132
)
3233
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
3334
from typing_extensions import deprecated
@@ -387,6 +388,7 @@ async def call_tool(
387388
input_responses: InputResponses | None = None,
388389
request_state: str | None = None,
389390
meta: RequestParamsMeta | None = None,
391+
tool: Tool | None = None,
390392
allow_input_required: Literal[False] = False,
391393
) -> CallToolResult: ...
392394

@@ -401,6 +403,7 @@ async def call_tool(
401403
input_responses: InputResponses | None = None,
402404
request_state: str | None = None,
403405
meta: RequestParamsMeta | None = None,
406+
tool: Tool | None = None,
404407
allow_input_required: bool,
405408
) -> CallToolResult | InputRequiredResult: ...
406409

@@ -414,6 +417,7 @@ async def call_tool(
414417
input_responses: InputResponses | None = None,
415418
request_state: str | None = None,
416419
meta: RequestParamsMeta | None = None,
420+
tool: Tool | None = None,
417421
allow_input_required: bool = False,
418422
) -> CallToolResult | InputRequiredResult:
419423
"""Call a tool on the server.
@@ -426,6 +430,10 @@ async def call_tool(
426430
input_responses: Responses to a prior `InputRequiredResult.input_requests`
427431
request_state: Opaque state echoed from a prior `InputRequiredResult`
428432
meta: Additional metadata for the request
433+
tool: The tool's definition, e.g. from an earlier `list_tools`. On a
434+
modern (2026-07-28) connection its `x-mcp-header` annotations are
435+
mirrored into `Mcp-Param-*` request headers; pass it when the
436+
client has not listed the tool itself.
429437
allow_input_required: When ``False`` (default), an `InputRequiredResult`
430438
from the server raises `RuntimeError`; when ``True``, it is returned
431439
so the caller can resolve the requests and retry.
@@ -448,6 +456,7 @@ async def call_tool(
448456
input_responses=input_responses,
449457
request_state=request_state,
450458
meta=meta,
459+
tool=tool,
451460
allow_input_required=allow_input_required,
452461
)
453462

src/mcp/client/session.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
NAME_BEARING_METHODS,
4242
encode_header_value,
4343
find_invalid_x_mcp_header,
44+
mcp_param_headers,
45+
x_mcp_header_map,
4446
)
4547
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
4648
from mcp.shared.message import ClientMessageMetadata, SessionMessage
@@ -68,7 +70,10 @@ def stamp(data: dict[str, Any], opts: CallOptions) -> None:
6870

6971

7072
def _make_modern_stamp(
71-
protocol_version: str, client_info: dict[str, Any], capabilities: dict[str, Any]
73+
protocol_version: str,
74+
client_info: dict[str, Any],
75+
capabilities: dict[str, Any],
76+
resolve_param_headers: Callable[[str, Mapping[str, Any]], dict[str, str]],
7277
) -> Callable[[dict[str, Any], CallOptions], None]:
7378
def stamp(data: dict[str, Any], opts: CallOptions) -> None:
7479
params = data.setdefault("params", {})
@@ -83,6 +88,8 @@ def stamp(data: dict[str, Any], opts: CallOptions) -> None:
8388
name_key = NAME_BEARING_METHODS.get(data["method"])
8489
if name_key is not None and isinstance(name := params.get(name_key), str):
8590
headers[MCP_NAME_HEADER] = encode_header_value(name)
91+
if data["method"] == "tools/call" and isinstance(name := params.get("name"), str):
92+
headers.update(resolve_param_headers(name, params.get("arguments") or {}))
8693

8794
return stamp
8895

@@ -215,6 +222,7 @@ def __init__(
215222
self._logging_callback = logging_callback or _default_logging_callback
216223
self._message_handler = message_handler or _default_message_handler
217224
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
225+
self._x_mcp_header_maps: dict[str, dict[tuple[str, ...], str]] = {}
218226
self._initialize_result: types.InitializeResult | None = None
219227
self._discover_result: types.DiscoverResult | None = None
220228
self._negotiated_version: str | None = None
@@ -393,7 +401,7 @@ def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None:
393401
)
394402
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
395403
capabilities = self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True)
396-
self._stamp = _make_modern_stamp(mutual[-1], client_info, capabilities)
404+
self._stamp = _make_modern_stamp(mutual[-1], client_info, capabilities, self._resolve_param_headers)
397405
self._discover_result = result
398406
self._initialize_result = None
399407
self._negotiated_version = mutual[-1]
@@ -615,6 +623,7 @@ async def call_tool(
615623
input_responses: types.InputResponses | None = None,
616624
request_state: str | None = None,
617625
meta: RequestParamsMeta | None = None,
626+
tool: types.Tool | None = None,
618627
allow_input_required: Literal[False] = False,
619628
) -> types.CallToolResult: ...
620629

@@ -629,6 +638,7 @@ async def call_tool(
629638
input_responses: types.InputResponses | None = None,
630639
request_state: str | None = None,
631640
meta: RequestParamsMeta | None = None,
641+
tool: types.Tool | None = None,
632642
allow_input_required: bool,
633643
) -> types.CallToolResult | types.InputRequiredResult: ...
634644

@@ -642,13 +652,20 @@ async def call_tool(
642652
input_responses: types.InputResponses | None = None,
643653
request_state: str | None = None,
644654
meta: RequestParamsMeta | None = None,
655+
tool: types.Tool | None = None,
645656
allow_input_required: bool = False,
646657
) -> types.CallToolResult | types.InputRequiredResult:
647658
"""Send a tools/call request with optional progress callback support.
648659
649660
Args:
650661
input_responses: Responses to a prior `InputRequiredResult.input_requests`.
651662
request_state: Opaque state echoed from a prior `InputRequiredResult`.
663+
tool: The tool's definition, e.g. from an earlier `list_tools`. On a
664+
modern (2026-07-28) connection its `x-mcp-header` annotations are
665+
mirrored into `Mcp-Param-*` request headers; pass it when the
666+
session has not listed the tool itself. Annotations seen by
667+
`list_tools` are cached, so this is only needed to supply or
668+
override them.
652669
allow_input_required: When ``False`` (default), an `InputRequiredResult`
653670
from the server raises `RuntimeError`; when ``True``, it is returned
654671
so the caller can resolve the requests and retry.
@@ -657,6 +674,11 @@ async def call_tool(
657674
RuntimeError: If the server returns an `InputRequiredResult` and
658675
``allow_input_required`` is ``False``.
659676
"""
677+
if tool is not None:
678+
if (reason := find_invalid_x_mcp_header(tool.input_schema)) is None:
679+
self._register_x_mcp_headers(tool)
680+
else:
681+
logger.warning("not mirroring headers for tool %r: invalid x-mcp-header (%s)", tool.name, reason)
660682

661683
result = await self.send_request(
662684
types.CallToolRequest(
@@ -683,6 +705,21 @@ async def call_tool(
683705
)
684706
return result
685707

708+
def _register_x_mcp_headers(self, tool: types.Tool) -> None:
709+
"""Cache `tool`'s argument→`x-mcp-header` map so `tools/call` can mirror them into headers.
710+
711+
A tool with no annotations records an empty map, which still pins the
712+
tool as known so a later call emits no `Mcp-Param-*` headers for it.
713+
"""
714+
self._x_mcp_header_maps[tool.name] = x_mcp_header_map(tool.input_schema)
715+
716+
def _resolve_param_headers(self, name: str, arguments: Mapping[str, Any]) -> dict[str, str]:
717+
"""`Mcp-Param-*` headers for a `tools/call`, or empty when the tool was never listed."""
718+
header_map = self._x_mcp_header_maps.get(name)
719+
if header_map is None:
720+
return {}
721+
return mcp_param_headers(header_map, arguments)
722+
686723
async def _validate_tool_result(self, name: str, result: types.CallToolResult) -> None:
687724
"""Validate the structured content of a tool result against its output schema."""
688725
if name not in self._tool_output_schemas:
@@ -768,6 +805,7 @@ async def list_tools(self, *, params: types.PaginatedRequestParams | None = None
768805
if (reason := find_invalid_x_mcp_header(tool.input_schema)) is not None:
769806
logger.warning("dropping tool %r: invalid x-mcp-header (%s)", tool.name, reason)
770807
continue
808+
self._register_x_mcp_headers(tool)
771809
kept.append(tool)
772810
result.tools = kept
773811

src/mcp/shared/inbound.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,16 @@
4242
"InboundModernRoute",
4343
"MCP_METHOD_HEADER",
4444
"MCP_NAME_HEADER",
45+
"MCP_PARAM_HEADER_PREFIX",
4546
"MCP_PROTOCOL_VERSION_HEADER",
4647
"NAME_BEARING_METHODS",
4748
"X_MCP_HEADER_KEY",
4849
"classify_inbound_request",
4950
"decode_header_value",
5051
"encode_header_value",
5152
"find_invalid_x_mcp_header",
53+
"mcp_param_headers",
54+
"x_mcp_header_map",
5255
]
5356

5457
MCP_PROTOCOL_VERSION_HEADER: Final = "mcp-protocol-version"
@@ -206,6 +209,51 @@ def find_invalid_x_mcp_header(input_schema: Any) -> str | None:
206209
return None
207210

208211

212+
MCP_PARAM_HEADER_PREFIX: Final = "Mcp-Param-"
213+
"""Prefix the `x-mcp-header` token is joined to, forming the per-parameter HTTP header name."""
214+
215+
216+
def x_mcp_header_map(input_schema: Any) -> dict[tuple[str, ...], str]:
217+
"""Map each property carrying a valid `x-mcp-header` to its annotation token, keyed by property path.
218+
219+
The key is the chain of `properties` keys from the schema root to the
220+
annotated property; a top-level property has a one-element path, a nested
221+
one a longer path. Call only on a schema that
222+
:func:`find_invalid_x_mcp_header` accepts; an invalid schema yields an
223+
undefined subset.
224+
"""
225+
mapping: dict[tuple[str, ...], str] = {}
226+
for path, schema in _walk_schema_positions(input_schema):
227+
if path and isinstance(header := schema.get(X_MCP_HEADER_KEY), str):
228+
mapping[path] = header
229+
return mapping
230+
231+
232+
def mcp_param_headers(header_map: Mapping[tuple[str, ...], str], arguments: Mapping[str, Any]) -> dict[str, str]:
233+
"""Build the `Mcp-Param-*` headers a `tools/call` mirrors from its arguments.
234+
235+
For each `(path, token)` in `header_map`, read the value at that property
236+
path in `arguments` and, when it is present and not `None`, emit
237+
`Mcp-Param-<token>` carrying it: `bool` as `true`/`false`, other scalars via
238+
`str`, each passed through :func:`encode_header_value` so a non-token value
239+
is base64-wrapped. A path that hits a missing key or a non-mapping node is
240+
skipped, matching the spec's "omit the header when no value is present".
241+
"""
242+
headers: dict[str, str] = {}
243+
for path, token in header_map.items():
244+
node: Any = arguments
245+
for key in path:
246+
if not isinstance(node, Mapping):
247+
node = None
248+
break
249+
node = cast("Mapping[str, Any]", node).get(key)
250+
if node is None:
251+
continue
252+
rendered = ("true" if node else "false") if isinstance(node, bool) else str(node)
253+
headers[f"{MCP_PARAM_HEADER_PREFIX}{token}"] = encode_header_value(rendered)
254+
return headers
255+
256+
209257
# INTERNAL_ERROR is deliberately unmapped (→ HTTP 200): the spec assigns no status to
210258
# -32603, and whether handler-origin errors get 5xx is an open S4 question — see TODO(L66).
211259
ERROR_CODE_HTTP_STATUS: Final[Mapping[int, int]] = MappingProxyType(

tests/client/test_client.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,41 @@ async def on_list_tools(
504504
assert [t.name for t in result.tools] == ["ok", "dropme"]
505505

506506

507+
async def test_call_tool_with_invalid_tool_override_logs_warning_and_mirrors_nothing(
508+
caplog: pytest.LogCaptureFixture,
509+
) -> None:
510+
"""A `tool=` override whose schema has a malformed `x-mcp-header` is not mirrored; the client warns instead.
511+
512+
The over-the-wire mirroring is only observable on streamable HTTP (see
513+
`tests/interaction/transports/test_hosting_http_modern.py`); here the in-memory transport proves the
514+
validation gate: an invalid override never registers a header map, and a warning names the tool and reason."""
515+
calls: list[str] = []
516+
bad_tool = types.Tool(
517+
name="run",
518+
input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}},
519+
)
520+
521+
async def on_list_tools(
522+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
523+
) -> types.ListToolsResult:
524+
return types.ListToolsResult(tools=[bad_tool])
525+
526+
async def on_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
527+
calls.append(params.name)
528+
return types.CallToolResult(content=[])
529+
530+
server = Server("test", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
531+
532+
with anyio.fail_after(5), caplog.at_level("WARNING", logger="client"):
533+
async with Client(server) as client:
534+
result = await client.call_tool("run", {"a": "x"}, tool=bad_tool)
535+
536+
assert result.content == []
537+
assert calls == ["run"]
538+
assert "not mirroring headers for tool 'run'" in caplog.text
539+
assert "bad name" in caplog.text
540+
541+
507542
def test_client_rejects_handshake_era_mode_at_construction() -> None:
508543
"""A handshake-era protocol-version string passed as `mode=` is rejected by
509544
`__post_init__` with a hint to use `mode='legacy'` — the version-pin path is

0 commit comments

Comments
 (0)