From cff6319a6de18cb5a3f87c76112beced8ce7330c Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 11:40:39 +0200 Subject: [PATCH 1/6] 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- 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. --- .github/actions/conformance/client.py | 39 ++++++- .../expected-failures.2026-07-28.yml | 8 +- .../actions/conformance/expected-failures.yml | 8 +- docs/migration.md | 4 + src/mcp/client/client.py | 9 ++ src/mcp/client/session.py | 42 ++++++- src/mcp/shared/inbound.py | 48 ++++++++ tests/client/test_client.py | 35 ++++++ tests/interaction/_requirements.py | 13 +++ .../transports/test_hosting_http_modern.py | 109 ++++++++++++++++++ tests/shared/test_inbound.py | 69 +++++++++++ 11 files changed, 376 insertions(+), 8 deletions(-) diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py index 4a57d5aee..c14a1d13c 100644 --- a/.github/actions/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -335,6 +335,43 @@ async def run_http_invalid_tool_headers(server_url: str) -> None: logger.exception(f"call_tool({tool.name!r}) failed") +def _stub_args_for_custom_headers(input_schema: dict[str, Any]) -> dict[str, Any]: + """Arguments exercising every `x-mcp-header`-annotated property in a tool schema. + + Each annotated property gets a type-appropriate value so the SDK mirrors it into an + `Mcp-Param-*` header; required properties without an annotation get a placeholder so + the call is well-formed. + """ + by_type: dict[str, Any] = {"string": "us-west1", "integer": 42, "boolean": False, "number": 3.14} + properties: dict[str, Any] = input_schema.get("properties", {}) + arguments: dict[str, Any] = {} + for name, schema in properties.items(): + if "x-mcp-header" in schema: + arguments[name] = by_type.get(schema.get("type"), "x") + for name in input_schema.get("required", []): + arguments.setdefault(name, by_type.get(properties.get(name, {}).get("type"), "x")) + return arguments + + +@register("http-custom-headers") +async def run_http_custom_headers(server_url: str) -> None: + """List tools, then call each surfaced tool so its `x-mcp-header` args mirror into headers (SEP-2243). + + A conforming client drops tools with invalid annotations during `list_tools` (e.g. the + harness's `number`-typed properties, which the spec forbids), so the loop only calls tools + whose annotations are valid; for those, the SDK emits the `Mcp-Param-*` headers the scenario + checks. Per-call failures are logged and skipped rather than aborting the run. + """ + async with Client(server_url, mode=client_mode()) as client: + listed = await client.list_tools() + logger.debug(f"Surfaced tools: {[t.name for t in listed.tools]}") + for tool in listed.tools: + try: + await client.call_tool(tool.name, _stub_args_for_custom_headers(tool.input_schema)) + except Exception: + logger.exception(f"call_tool({tool.name!r}) failed") + + @register("elicitation-sep1034-client-defaults") async def run_elicitation_defaults(server_url: str) -> None: """Connect with elicitation callback that applies schema defaults.""" @@ -526,8 +563,6 @@ def main() -> None: elif scenario.startswith("auth/"): asyncio.run(run_auth_code_client(server_url)) else: - # Unhandled scenarios: - # - http-custom-headers (SEP-2243 / S8: Mcp-Param-* emission) print(f"Unknown scenario: {scenario}", file=sys.stderr) sys.exit(1) else: diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index a4b4f4480..261cf56ef 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -21,8 +21,12 @@ # milestone. client: - # SEP-2243 (HTTP standardization): no client Mcp-Param-* support yet — needs the - # tool-schema-cache vs per-call tool_definition design (S8). + # SEP-2243 (HTTP standardization): the client now mirrors x-mcp-header args into + # Mcp-Param-* headers (S8), but the harness fixture annotates `number`-typed + # properties, which the spec forbids ("Parameters with type number are not + # permitted"). The SDK drops those tools per spec, so the scenario's + # ClientSupportsCustomHeaders check (which requires the tool to be called) + # cannot pass until the harness fixture is corrected. - http-custom-headers # auth/enterprise-managed-authorization (SEP-990) is in the 2025 baseline but # NOT here: the harness skips it as inapplicable at --spec-version 2026-07-28 diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index cb59dba02..1696050c9 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -12,8 +12,12 @@ client: # --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) --- - # SEP-2243 (HTTP standardization): no client Mcp-Param-* support yet — needs the - # tool-schema-cache vs per-call tool_definition design (S8). + # SEP-2243 (HTTP standardization): the client now mirrors x-mcp-header args into + # Mcp-Param-* headers (S8), but the harness fixture annotates `number`-typed + # properties, which the spec forbids ("Parameters with type number are not + # permitted"). The SDK drops those tools per spec, so the scenario's + # ClientSupportsCustomHeaders check (which requires the tool to be called) + # cannot pass until the harness fixture is corrected. - http-custom-headers # --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 --- diff --git a/docs/migration.md b/docs/migration.md index 7598b5202..299a96aab 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -398,6 +398,10 @@ For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer` 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=`. +### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers (SEP-2243) + +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-` 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. + ### `McpError` renamed to `MCPError` The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK. diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index 308f28d1c..e6d1c091e 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -28,6 +28,7 @@ RequestParamsMeta, ResourceTemplateReference, ServerCapabilities, + Tool, ) from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS from typing_extensions import deprecated @@ -387,6 +388,7 @@ async def call_tool( input_responses: InputResponses | None = None, request_state: str | None = None, meta: RequestParamsMeta | None = None, + tool: Tool | None = None, allow_input_required: Literal[False] = False, ) -> CallToolResult: ... @@ -401,6 +403,7 @@ async def call_tool( input_responses: InputResponses | None = None, request_state: str | None = None, meta: RequestParamsMeta | None = None, + tool: Tool | None = None, allow_input_required: bool, ) -> CallToolResult | InputRequiredResult: ... @@ -414,6 +417,7 @@ async def call_tool( input_responses: InputResponses | None = None, request_state: str | None = None, meta: RequestParamsMeta | None = None, + tool: Tool | None = None, allow_input_required: bool = False, ) -> CallToolResult | InputRequiredResult: """Call a tool on the server. @@ -426,6 +430,10 @@ async def call_tool( input_responses: Responses to a prior `InputRequiredResult.input_requests` request_state: Opaque state echoed from a prior `InputRequiredResult` meta: Additional metadata for the request + tool: The tool's definition, e.g. from an earlier `list_tools`. On a + modern (2026-07-28) connection its `x-mcp-header` annotations are + mirrored into `Mcp-Param-*` request headers; pass it when the + client has not listed the tool itself. allow_input_required: When ``False`` (default), an `InputRequiredResult` from the server raises `RuntimeError`; when ``True``, it is returned so the caller can resolve the requests and retry. @@ -448,6 +456,7 @@ async def call_tool( input_responses=input_responses, request_state=request_state, meta=meta, + tool=tool, allow_input_required=allow_input_required, ) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 0c6e0270c..523529a65 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -41,6 +41,8 @@ NAME_BEARING_METHODS, encode_header_value, find_invalid_x_mcp_header, + mcp_param_headers, + x_mcp_header_map, ) from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.message import ClientMessageMetadata, SessionMessage @@ -68,7 +70,10 @@ def stamp(data: dict[str, Any], opts: CallOptions) -> None: def _make_modern_stamp( - protocol_version: str, client_info: dict[str, Any], capabilities: dict[str, Any] + protocol_version: str, + client_info: dict[str, Any], + capabilities: dict[str, Any], + resolve_param_headers: Callable[[str, Mapping[str, Any]], dict[str, str]], ) -> Callable[[dict[str, Any], CallOptions], None]: def stamp(data: dict[str, Any], opts: CallOptions) -> None: params = data.setdefault("params", {}) @@ -83,6 +88,8 @@ def stamp(data: dict[str, Any], opts: CallOptions) -> None: name_key = NAME_BEARING_METHODS.get(data["method"]) if name_key is not None and isinstance(name := params.get(name_key), str): headers[MCP_NAME_HEADER] = encode_header_value(name) + if data["method"] == "tools/call" and isinstance(name := params.get("name"), str): + headers.update(resolve_param_headers(name, params.get("arguments") or {})) return stamp @@ -215,6 +222,7 @@ def __init__( self._logging_callback = logging_callback or _default_logging_callback self._message_handler = message_handler or _default_message_handler self._tool_output_schemas: dict[str, dict[str, Any] | None] = {} + self._x_mcp_header_maps: dict[str, dict[tuple[str, ...], str]] = {} self._initialize_result: types.InitializeResult | None = None self._discover_result: types.DiscoverResult | None = None self._negotiated_version: str | None = None @@ -393,7 +401,7 @@ def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None: ) client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True) capabilities = self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True) - self._stamp = _make_modern_stamp(mutual[-1], client_info, capabilities) + self._stamp = _make_modern_stamp(mutual[-1], client_info, capabilities, self._resolve_param_headers) self._discover_result = result self._initialize_result = None self._negotiated_version = mutual[-1] @@ -615,6 +623,7 @@ async def call_tool( input_responses: types.InputResponses | None = None, request_state: str | None = None, meta: RequestParamsMeta | None = None, + tool: types.Tool | None = None, allow_input_required: Literal[False] = False, ) -> types.CallToolResult: ... @@ -629,6 +638,7 @@ async def call_tool( input_responses: types.InputResponses | None = None, request_state: str | None = None, meta: RequestParamsMeta | None = None, + tool: types.Tool | None = None, allow_input_required: bool, ) -> types.CallToolResult | types.InputRequiredResult: ... @@ -642,6 +652,7 @@ async def call_tool( input_responses: types.InputResponses | None = None, request_state: str | None = None, meta: RequestParamsMeta | None = None, + tool: types.Tool | None = None, allow_input_required: bool = False, ) -> types.CallToolResult | types.InputRequiredResult: """Send a tools/call request with optional progress callback support. @@ -649,6 +660,12 @@ async def call_tool( Args: input_responses: Responses to a prior `InputRequiredResult.input_requests`. request_state: Opaque state echoed from a prior `InputRequiredResult`. + tool: The tool's definition, e.g. from an earlier `list_tools`. On a + modern (2026-07-28) connection its `x-mcp-header` annotations are + mirrored into `Mcp-Param-*` request headers; pass it when the + session has not listed the tool itself. Annotations seen by + `list_tools` are cached, so this is only needed to supply or + override them. allow_input_required: When ``False`` (default), an `InputRequiredResult` from the server raises `RuntimeError`; when ``True``, it is returned so the caller can resolve the requests and retry. @@ -657,6 +674,11 @@ async def call_tool( RuntimeError: If the server returns an `InputRequiredResult` and ``allow_input_required`` is ``False``. """ + if tool is not None: + if (reason := find_invalid_x_mcp_header(tool.input_schema)) is None: + self._register_x_mcp_headers(tool) + else: + logger.warning("not mirroring headers for tool %r: invalid x-mcp-header (%s)", tool.name, reason) result = await self.send_request( types.CallToolRequest( @@ -683,6 +705,21 @@ async def call_tool( ) return result + def _register_x_mcp_headers(self, tool: types.Tool) -> None: + """Cache `tool`'s argument→`x-mcp-header` map so `tools/call` can mirror them into headers. + + A tool with no annotations records an empty map, which still pins the + tool as known so a later call emits no `Mcp-Param-*` headers for it. + """ + self._x_mcp_header_maps[tool.name] = x_mcp_header_map(tool.input_schema) + + def _resolve_param_headers(self, name: str, arguments: Mapping[str, Any]) -> dict[str, str]: + """`Mcp-Param-*` headers for a `tools/call`, or empty when the tool was never listed.""" + header_map = self._x_mcp_header_maps.get(name) + if header_map is None: + return {} + return mcp_param_headers(header_map, arguments) + async def _validate_tool_result(self, name: str, result: types.CallToolResult) -> None: """Validate the structured content of a tool result against its output schema.""" if name not in self._tool_output_schemas: @@ -768,6 +805,7 @@ async def list_tools(self, *, params: types.PaginatedRequestParams | None = None if (reason := find_invalid_x_mcp_header(tool.input_schema)) is not None: logger.warning("dropping tool %r: invalid x-mcp-header (%s)", tool.name, reason) continue + self._register_x_mcp_headers(tool) kept.append(tool) result.tools = kept diff --git a/src/mcp/shared/inbound.py b/src/mcp/shared/inbound.py index 1c70e3d92..6c8d23678 100644 --- a/src/mcp/shared/inbound.py +++ b/src/mcp/shared/inbound.py @@ -42,6 +42,7 @@ "InboundModernRoute", "MCP_METHOD_HEADER", "MCP_NAME_HEADER", + "MCP_PARAM_HEADER_PREFIX", "MCP_PROTOCOL_VERSION_HEADER", "NAME_BEARING_METHODS", "X_MCP_HEADER_KEY", @@ -49,6 +50,8 @@ "decode_header_value", "encode_header_value", "find_invalid_x_mcp_header", + "mcp_param_headers", + "x_mcp_header_map", ] MCP_PROTOCOL_VERSION_HEADER: Final = "mcp-protocol-version" @@ -206,6 +209,51 @@ def find_invalid_x_mcp_header(input_schema: Any) -> str | None: return None +MCP_PARAM_HEADER_PREFIX: Final = "Mcp-Param-" +"""Prefix the `x-mcp-header` token is joined to, forming the per-parameter HTTP header name.""" + + +def x_mcp_header_map(input_schema: Any) -> dict[tuple[str, ...], str]: + """Map each property carrying a valid `x-mcp-header` to its annotation token, keyed by property path. + + The key is the chain of `properties` keys from the schema root to the + annotated property; a top-level property has a one-element path, a nested + one a longer path. Call only on a schema that + :func:`find_invalid_x_mcp_header` accepts; an invalid schema yields an + undefined subset. + """ + mapping: dict[tuple[str, ...], str] = {} + for path, schema in _walk_schema_positions(input_schema): + if path and isinstance(header := schema.get(X_MCP_HEADER_KEY), str): + mapping[path] = header + return mapping + + +def mcp_param_headers(header_map: Mapping[tuple[str, ...], str], arguments: Mapping[str, Any]) -> dict[str, str]: + """Build the `Mcp-Param-*` headers a `tools/call` mirrors from its arguments. + + For each `(path, token)` in `header_map`, read the value at that property + path in `arguments` and, when it is present and not `None`, emit + `Mcp-Param-` carrying it: `bool` as `true`/`false`, other scalars via + `str`, each passed through :func:`encode_header_value` so a non-token value + is base64-wrapped. A path that hits a missing key or a non-mapping node is + skipped, matching the spec's "omit the header when no value is present". + """ + headers: dict[str, str] = {} + for path, token in header_map.items(): + node: Any = arguments + for key in path: + if not isinstance(node, Mapping): + node = None + break + node = cast("Mapping[str, Any]", node).get(key) + if node is None: + continue + rendered = ("true" if node else "false") if isinstance(node, bool) else str(node) + headers[f"{MCP_PARAM_HEADER_PREFIX}{token}"] = encode_header_value(rendered) + return headers + + # INTERNAL_ERROR is deliberately unmapped (→ HTTP 200): the spec assigns no status to # -32603, and whether handler-origin errors get 5xx is an open S4 question — see TODO(L66). ERROR_CODE_HTTP_STATUS: Final[Mapping[int, int]] = MappingProxyType( diff --git a/tests/client/test_client.py b/tests/client/test_client.py index f869d1f1b..d27a0ec83 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -504,6 +504,41 @@ async def on_list_tools( assert [t.name for t in result.tools] == ["ok", "dropme"] +async def test_call_tool_with_invalid_tool_override_logs_warning_and_mirrors_nothing( + caplog: pytest.LogCaptureFixture, +) -> None: + """A `tool=` override whose schema has a malformed `x-mcp-header` is not mirrored; the client warns instead. + + The over-the-wire mirroring is only observable on streamable HTTP (see + `tests/interaction/transports/test_hosting_http_modern.py`); here the in-memory transport proves the + validation gate: an invalid override never registers a header map, and a warning names the tool and reason.""" + calls: list[str] = [] + bad_tool = types.Tool( + name="run", + input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}}, + ) + + async def on_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[bad_tool]) + + async def on_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + calls.append(params.name) + return types.CallToolResult(content=[]) + + server = Server("test", on_list_tools=on_list_tools, on_call_tool=on_call_tool) + + with anyio.fail_after(5), caplog.at_level("WARNING", logger="client"): + async with Client(server) as client: + result = await client.call_tool("run", {"a": "x"}, tool=bad_tool) + + assert result.content == [] + assert calls == ["run"] + assert "not mirroring headers for tool 'run'" in caplog.text + assert "bad name" in caplog.text + + def test_client_rejects_handshake_era_mode_at_construction() -> None: """A handshake-era protocol-version string passed as `mode=` is rejected by `__post_init__` with a hint to use `mode='legacy'` — the version-pin path is diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 50449c459..380558ddc 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -3372,6 +3372,19 @@ def __post_init__(self) -> None: 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- 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 a prior list_tools or a " + "tool= override; an uncached tool 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=( diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index 77ac85018..f86f9a2be 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -388,3 +388,112 @@ async def on_response(response: httpx.Response) -> None: 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) + + with anyio.fail_after(5): + async with ( + mounted_app(_custom_header_server(), on_request=on_request) as (http, _), + streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write), + ClientSession(read, write) as session, + ): + session.adopt( + DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="srv", version="0"), + ) + ) + await session.list_tools() + await session.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_uncached_tool_then_mirrors_with_tool_override() -> None: + """Without a cached schema the client sends no `Mcp-Param-*`; a `tool=` override supplies it for the next call. + + The spec lets a client that lacks the tool's `inputSchema` send the request without custom headers, and + pre-load definitions by other means. The first `call_tool` (no prior `list_tools`, no `tool=`) carries no + `Mcp-Param-*` header; the second passes the tool definition via `tool=` and mirrors `region`. + """ + requests: list[httpx.Request] = [] + + async def on_request(request: httpx.Request) -> None: + if json.loads(request.content)["method"] == "tools/call": + requests.append(request) + + with anyio.fail_after(5): + async with ( + mounted_app(_custom_header_server(), on_request=on_request) as (http, _), + streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write), + ClientSession(read, write) as session, + ): + session.adopt( + DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="srv", version="0"), + ) + ) + await session.call_tool("run", {"region": "us-west1"}) + await session.call_tool("run", {"region": "us-west1"}, tool=_CUSTOM_HEADER_TOOL) + + uncached, overridden = requests + assert not any(k.startswith("mcp-param-") for k in uncached.headers) + assert overridden.headers.get("mcp-param-region") == "us-west1" diff --git a/tests/shared/test_inbound.py b/tests/shared/test_inbound.py index 93ab6ecc2..4297265e1 100644 --- a/tests/shared/test_inbound.py +++ b/tests/shared/test_inbound.py @@ -37,6 +37,8 @@ decode_header_value, encode_header_value, find_invalid_x_mcp_header, + mcp_param_headers, + x_mcp_header_map, ) CLIENT_INFO = {"name": "t", "version": "0"} @@ -475,3 +477,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-`.""" + 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, {}) == {} From c89582362a018c5b73050274cda404447af7a58c Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 11:46:52 +0200 Subject: [PATCH 2/6] Drop the call_tool tool= override; rely on the list_tools cache The cache populated by list_tools already covers the normal flow (a client discovers a tool via list_tools before calling it), and the spec frames pre-loading definitions as a MAY. Removing tool= cuts the extra overloads, facade plumbing, and per-call validation branch for a path nothing needs yet. --- docs/migration.md | 2 +- src/mcp/client/client.py | 9 ----- src/mcp/client/session.py | 31 ++++------------ tests/client/test_client.py | 35 ------------------- tests/interaction/_requirements.py | 4 +-- .../transports/test_hosting_http_modern.py | 15 ++++---- 6 files changed, 16 insertions(+), 80 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 299a96aab..1be971163 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -400,7 +400,7 @@ For protocol 2026-07-28, a `tools/call` request may return an `InputRequiredResu ### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers (SEP-2243) -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-` 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. +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-` 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, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation. ### `McpError` renamed to `MCPError` diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index e6d1c091e..308f28d1c 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -28,7 +28,6 @@ RequestParamsMeta, ResourceTemplateReference, ServerCapabilities, - Tool, ) from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS from typing_extensions import deprecated @@ -388,7 +387,6 @@ async def call_tool( input_responses: InputResponses | None = None, request_state: str | None = None, meta: RequestParamsMeta | None = None, - tool: Tool | None = None, allow_input_required: Literal[False] = False, ) -> CallToolResult: ... @@ -403,7 +401,6 @@ async def call_tool( input_responses: InputResponses | None = None, request_state: str | None = None, meta: RequestParamsMeta | None = None, - tool: Tool | None = None, allow_input_required: bool, ) -> CallToolResult | InputRequiredResult: ... @@ -417,7 +414,6 @@ async def call_tool( input_responses: InputResponses | None = None, request_state: str | None = None, meta: RequestParamsMeta | None = None, - tool: Tool | None = None, allow_input_required: bool = False, ) -> CallToolResult | InputRequiredResult: """Call a tool on the server. @@ -430,10 +426,6 @@ async def call_tool( input_responses: Responses to a prior `InputRequiredResult.input_requests` request_state: Opaque state echoed from a prior `InputRequiredResult` meta: Additional metadata for the request - tool: The tool's definition, e.g. from an earlier `list_tools`. On a - modern (2026-07-28) connection its `x-mcp-header` annotations are - mirrored into `Mcp-Param-*` request headers; pass it when the - client has not listed the tool itself. allow_input_required: When ``False`` (default), an `InputRequiredResult` from the server raises `RuntimeError`; when ``True``, it is returned so the caller can resolve the requests and retry. @@ -456,7 +448,6 @@ async def call_tool( input_responses=input_responses, request_state=request_state, meta=meta, - tool=tool, allow_input_required=allow_input_required, ) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 523529a65..93ba60ce2 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -623,7 +623,6 @@ async def call_tool( input_responses: types.InputResponses | None = None, request_state: str | None = None, meta: RequestParamsMeta | None = None, - tool: types.Tool | None = None, allow_input_required: Literal[False] = False, ) -> types.CallToolResult: ... @@ -638,7 +637,6 @@ async def call_tool( input_responses: types.InputResponses | None = None, request_state: str | None = None, meta: RequestParamsMeta | None = None, - tool: types.Tool | None = None, allow_input_required: bool, ) -> types.CallToolResult | types.InputRequiredResult: ... @@ -652,20 +650,18 @@ async def call_tool( input_responses: types.InputResponses | None = None, request_state: str | None = None, meta: RequestParamsMeta | None = None, - tool: types.Tool | None = None, allow_input_required: bool = False, ) -> types.CallToolResult | types.InputRequiredResult: """Send a tools/call request with optional progress callback support. + On a modern (2026-07-28) connection, arguments annotated with `x-mcp-header` + in the tool's input schema are mirrored into `Mcp-Param-*` request headers. + The annotations are read from the tool's last `list_tools` entry, so list + the tool before calling it to enable header emission. + Args: input_responses: Responses to a prior `InputRequiredResult.input_requests`. request_state: Opaque state echoed from a prior `InputRequiredResult`. - tool: The tool's definition, e.g. from an earlier `list_tools`. On a - modern (2026-07-28) connection its `x-mcp-header` annotations are - mirrored into `Mcp-Param-*` request headers; pass it when the - session has not listed the tool itself. Annotations seen by - `list_tools` are cached, so this is only needed to supply or - override them. allow_input_required: When ``False`` (default), an `InputRequiredResult` from the server raises `RuntimeError`; when ``True``, it is returned so the caller can resolve the requests and retry. @@ -674,12 +670,6 @@ async def call_tool( RuntimeError: If the server returns an `InputRequiredResult` and ``allow_input_required`` is ``False``. """ - if tool is not None: - if (reason := find_invalid_x_mcp_header(tool.input_schema)) is None: - self._register_x_mcp_headers(tool) - else: - logger.warning("not mirroring headers for tool %r: invalid x-mcp-header (%s)", tool.name, reason) - result = await self.send_request( types.CallToolRequest( params=types.CallToolRequestParams( @@ -705,14 +695,6 @@ async def call_tool( ) return result - def _register_x_mcp_headers(self, tool: types.Tool) -> None: - """Cache `tool`'s argument→`x-mcp-header` map so `tools/call` can mirror them into headers. - - A tool with no annotations records an empty map, which still pins the - tool as known so a later call emits no `Mcp-Param-*` headers for it. - """ - self._x_mcp_header_maps[tool.name] = x_mcp_header_map(tool.input_schema) - def _resolve_param_headers(self, name: str, arguments: Mapping[str, Any]) -> dict[str, str]: """`Mcp-Param-*` headers for a `tools/call`, or empty when the tool was never listed.""" header_map = self._x_mcp_header_maps.get(name) @@ -805,7 +787,8 @@ async def list_tools(self, *, params: types.PaginatedRequestParams | None = None if (reason := find_invalid_x_mcp_header(tool.input_schema)) is not None: logger.warning("dropping tool %r: invalid x-mcp-header (%s)", tool.name, reason) continue - self._register_x_mcp_headers(tool) + # Cache the arg→header map so a later tools/call mirrors it into Mcp-Param-* headers. + self._x_mcp_header_maps[tool.name] = x_mcp_header_map(tool.input_schema) kept.append(tool) result.tools = kept diff --git a/tests/client/test_client.py b/tests/client/test_client.py index d27a0ec83..f869d1f1b 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -504,41 +504,6 @@ async def on_list_tools( assert [t.name for t in result.tools] == ["ok", "dropme"] -async def test_call_tool_with_invalid_tool_override_logs_warning_and_mirrors_nothing( - caplog: pytest.LogCaptureFixture, -) -> None: - """A `tool=` override whose schema has a malformed `x-mcp-header` is not mirrored; the client warns instead. - - The over-the-wire mirroring is only observable on streamable HTTP (see - `tests/interaction/transports/test_hosting_http_modern.py`); here the in-memory transport proves the - validation gate: an invalid override never registers a header map, and a warning names the tool and reason.""" - calls: list[str] = [] - bad_tool = types.Tool( - name="run", - input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}}, - ) - - async def on_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None - ) -> types.ListToolsResult: - return types.ListToolsResult(tools=[bad_tool]) - - async def on_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - calls.append(params.name) - return types.CallToolResult(content=[]) - - server = Server("test", on_list_tools=on_list_tools, on_call_tool=on_call_tool) - - with anyio.fail_after(5), caplog.at_level("WARNING", logger="client"): - async with Client(server) as client: - result = await client.call_tool("run", {"a": "x"}, tool=bad_tool) - - assert result.content == [] - assert calls == ["run"] - assert "not mirroring headers for tool 'run'" in caplog.text - assert "bad name" in caplog.text - - def test_client_rejects_handshake_era_mode_at_construction() -> None: """A handshake-era protocol-version string passed as `mode=` is rejected by `__post_init__` with a hint to use `mode='legacy'` — the version-pin path is diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 380558ddc..0150513e2 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -3378,8 +3378,8 @@ def __post_init__(self) -> None: "On a tools/call, a client mirrors each argument annotated with x-mcp-header in the tool's " "inputSchema into an Mcp-Param- 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 a prior list_tools or a " - "tool= override; an uncached tool emits no Mcp-Param-* headers." + "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",), diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index f86f9a2be..3334f542a 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -465,12 +465,12 @@ async def on_request(request: httpx.Request) -> None: @requirement("client-transport:http:custom-param-headers") -async def test_modern_client_emits_no_param_headers_for_uncached_tool_then_mirrors_with_tool_override() -> None: - """Without a cached schema the client sends no `Mcp-Param-*`; a `tool=` override supplies it for the next call. +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, and - pre-load definitions by other means. The first `call_tool` (no prior `list_tools`, no `tool=`) carries no - `Mcp-Param-*` header; the second passes the tool definition via `tool=` and mirrors `region`. + 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] = [] @@ -492,8 +492,5 @@ async def on_request(request: httpx.Request) -> None: ) ) await session.call_tool("run", {"region": "us-west1"}) - await session.call_tool("run", {"region": "us-west1"}, tool=_CUSTOM_HEADER_TOOL) - uncached, overridden = requests - assert not any(k.startswith("mcp-param-") for k in uncached.headers) - assert overridden.headers.get("mcp-param-region") == "us-west1" + assert not any(k.startswith("mcp-param-") for k in requests[0].headers) From b8a88afd6c14628da98b62f9cf4b07883555433f Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 13:01:50 +0200 Subject: [PATCH 3/6] Pass http-custom-headers conformance via the #371 fixture-fix preview The conformance handler now replays the harness-supplied toolCalls verbatim (including the null and Base64-edge-case values) instead of synthesizing arguments from the schema, so every per-parameter check is exercised: the null-omission and Base64-unsafe checks now fire correctly. Pins CONFORMANCE_PKG to the pkg.pr.new preview of conformance#371, which fixes the fixture's spec-forbidden number-typed x-mcp-header annotations, and removes http-custom-headers from both expected-failures baselines (it now passes 18/18 on both the default and 2026-07-28 legs). Repin to the published release once #371 ships. --- .github/actions/conformance/client.py | 45 ++++++++----------- .../expected-failures.2026-07-28.yml | 8 +--- .../actions/conformance/expected-failures.yml | 9 ---- .github/workflows/conformance.yml | 7 ++- 4 files changed, 25 insertions(+), 44 deletions(-) diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py index c14a1d13c..ec4ff2245 100644 --- a/.github/actions/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -335,41 +335,32 @@ async def run_http_invalid_tool_headers(server_url: str) -> None: logger.exception(f"call_tool({tool.name!r}) failed") -def _stub_args_for_custom_headers(input_schema: dict[str, Any]) -> dict[str, Any]: - """Arguments exercising every `x-mcp-header`-annotated property in a tool schema. - - Each annotated property gets a type-appropriate value so the SDK mirrors it into an - `Mcp-Param-*` header; required properties without an annotation get a placeholder so - the call is well-formed. - """ - by_type: dict[str, Any] = {"string": "us-west1", "integer": 42, "boolean": False, "number": 3.14} - properties: dict[str, Any] = input_schema.get("properties", {}) - arguments: dict[str, Any] = {} - for name, schema in properties.items(): - if "x-mcp-header" in schema: - arguments[name] = by_type.get(schema.get("type"), "x") - for name in input_schema.get("required", []): - arguments.setdefault(name, by_type.get(properties.get(name, {}).get("type"), "x")) - return arguments - - @register("http-custom-headers") async def run_http_custom_headers(server_url: str) -> None: - """List tools, then call each surfaced tool so its `x-mcp-header` args mirror into headers (SEP-2243). + """List tools, then replay the harness's `toolCalls` so x-mcp-header args mirror into headers (SEP-2243). - A conforming client drops tools with invalid annotations during `list_tools` (e.g. the - harness's `number`-typed properties, which the spec forbids), so the loop only calls tools - whose annotations are valid; for those, the SDK emits the `Mcp-Param-*` headers the scenario - checks. Per-call failures are logged and skipped rather than aborting the run. + The scenario supplies the exact arguments to send (including the null/edge-case values that + exercise omission and Base64 encoding) via the context `toolCalls`; using them verbatim is + what drives every per-parameter check. `list_tools` first so the SDK caches each tool's + annotations; a tool the SDK dropped (invalid annotations) is skipped. Per-call failures are + logged and skipped rather than aborting the run. """ + tool_calls: list[dict[str, Any]] = [] + if os.environ.get("MCP_CONFORMANCE_CONTEXT"): + tool_calls = get_conformance_context().get("toolCalls", []) async with Client(server_url, mode=client_mode()) as client: listed = await client.list_tools() - logger.debug(f"Surfaced tools: {[t.name for t in listed.tools]}") - for tool in listed.tools: + surfaced = {tool.name for tool in listed.tools} + logger.debug(f"Surfaced tools: {sorted(surfaced)}") + for call in tool_calls: + name = call["name"] + if name not in surfaced: + logger.debug(f"skipping {name!r}: not surfaced by list_tools") + continue try: - await client.call_tool(tool.name, _stub_args_for_custom_headers(tool.input_schema)) + await client.call_tool(name, call.get("arguments") or {}) except Exception: - logger.exception(f"call_tool({tool.name!r}) failed") + logger.exception(f"call_tool({name!r}) failed") @register("elicitation-sep1034-client-defaults") diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index 261cf56ef..39fcdde48 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -21,13 +21,7 @@ # milestone. client: - # SEP-2243 (HTTP standardization): the client now mirrors x-mcp-header args into - # Mcp-Param-* headers (S8), but the harness fixture annotates `number`-typed - # properties, which the spec forbids ("Parameters with type number are not - # permitted"). The SDK drops those tools per spec, so the scenario's - # ClientSupportsCustomHeaders check (which requires the tool to be called) - # cannot pass until the harness fixture is corrected. - - http-custom-headers + [] # auth/enterprise-managed-authorization (SEP-990) is in the 2025 baseline but # NOT here: the harness skips it as inapplicable at --spec-version 2026-07-28 # (it is an extension scenario not carried into the 2026 wire), so it is diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index 1696050c9..b8994f76b 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -11,15 +11,6 @@ # on stale entries), so the baseline burns down per milestone. client: - # --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) --- - # SEP-2243 (HTTP standardization): the client now mirrors x-mcp-header args into - # Mcp-Param-* headers (S8), but the harness fixture annotates `number`-typed - # properties, which the spec forbids ("Parameters with type number are not - # permitted"). The SDK drops those tools per spec, so the scenario's - # ClientSupportsCustomHeaders check (which requires the tool to be called) - # cannot pass until the harness fixture is corrected. - - http-custom-headers - # --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 --- # SEP-990 (enterprise-managed authorization extension): no fixture handler / # client support for the token-exchange + JWT bearer flow. diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 9f5ce489f..a2799d762 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -18,7 +18,12 @@ env: # Use a published version, e.g. @modelcontextprotocol/conformance@0.2.0-alpha.7. # Bump deliberately and reconcile both # .github/actions/conformance/expected-failures*.yml files in the same change. - CONFORMANCE_PKG: "@modelcontextprotocol/conformance@0.2.0-alpha.7" + # + # Temporarily pinned to the pkg.pr.new preview build of conformance#371, which + # fixes the http-custom-headers fixture's spec-forbidden `number`-typed + # x-mcp-header annotations. Repin to the published release that includes #371 + # once it ships (the preview URL is ephemeral). + CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@371" jobs: server-conformance: From 9a84462f02793b2c5010b18c5a1e93280a6a97db Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 13:24:16 +0200 Subject: [PATCH 4/6] Evict cached x-mcp-header map when a re-list drops the tool list_tools only overwrote the cache on the kept path, so if a tool was first listed with valid x-mcp-header annotations and a later list returned the same name with invalid ones, the tool was dropped but its stale arg->header map survived -- a subsequent tools/call would still mirror the old Mcp-Param-* headers. Evict the entry on the drop path so the cache reflects the last list_tools entry. Found by Codex review. --- src/mcp/client/session.py | 3 +++ tests/client/test_client.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 93ba60ce2..b9e686056 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -786,6 +786,9 @@ async def list_tools(self, *, params: types.PaginatedRequestParams | None = None for tool in result.tools: if (reason := find_invalid_x_mcp_header(tool.input_schema)) is not None: logger.warning("dropping tool %r: invalid x-mcp-header (%s)", tool.name, reason) + # Evict any map cached from a prior valid listing so a stale entry can't + # mirror headers for a tool this listing dropped. + self._x_mcp_header_maps.pop(tool.name, None) continue # Cache the arg→header map so a later tools/call mirrors it into Mcp-Param-* headers. self._x_mcp_header_maps[tool.name] = x_mcp_header_map(tool.input_schema) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index f869d1f1b..ef9f9dab8 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -504,6 +504,38 @@ async def on_list_tools( assert [t.name for t in result.tools] == ["ok", "dropme"] +@pytest.mark.anyio +async def test_modern_list_tools_evicts_header_map_when_a_tool_turns_invalid() -> None: + """A re-list that drops a previously valid tool must also evict its cached `x-mcp-header` map, + so a later `tools/call` can't mirror stale `Mcp-Param-*` headers for a now-dropped tool.""" + valid = types.Tool( + name="run", + input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "Region"}}}, + ) + invalid = types.Tool( + name="run", + input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}}, + ) + listings = iter([valid, invalid]) + + async def on_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[next(listings)]) + + server = Server("test", on_list_tools=on_list_tools) + + with anyio.fail_after(5): + async with Client(server) as client: + session = client.session + await client.list_tools() + assert session._resolve_param_headers("run", {"a": "x"}) == {"Mcp-Param-Region": "x"} + + result = await client.list_tools() + assert [t.name for t in result.tools] == [] + assert session._resolve_param_headers("run", {"a": "x"}) == {} + + def test_client_rejects_handshake_era_mode_at_construction() -> None: """A handshake-era protocol-version string passed as `mode=` is rejected by `__post_init__` with a hint to use `mode='legacy'` — the version-pin path is From 33c0d767932715f72f0e1107c36049c71de44a59 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 13:35:54 +0200 Subject: [PATCH 5/6] Verify the conformance preview tarball; tidy mcp_param_headers typing Address PR review: - conformance.yml pinned CONFORMANCE_PKG to a mutable pkg.pr.new URL run via npx without verification. Restore the repo's pre-#2974 supply-chain pattern: a CONFORMANCE_PKG_SHA256 digest plus a fetch-and-verify step that downloads, checks the sha256, and repoints CONFORMANCE_PKG at the verified local tarball (no-op for registry specs). Verified the scenario still passes 18/18 via the file: tarball. - mcp_param_headers carried an inline node: Any and a cast. Move the arbitrary-JSON path walk into a small _value_at_path helper so the main body is clean; the single remaining cast (strict-pyright narrowing of a bare Mapping) matches _walk_schema_positions in the same file. --- .github/workflows/conformance.yml | 34 +++++++++++++++++++++++++++++-- src/mcp/shared/inbound.py | 21 +++++++++++-------- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index a2799d762..e68991e47 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -21,9 +21,13 @@ env: # # Temporarily pinned to the pkg.pr.new preview build of conformance#371, which # fixes the http-custom-headers fixture's spec-forbidden `number`-typed - # x-mcp-header annotations. Repin to the published release that includes #371 - # once it ships (the preview URL is ephemeral). + # x-mcp-header annotations. Because this is a mutable URL (not a registry + # spec), CONFORMANCE_PKG_SHA256 pins the tarball and the fetch-and-verify step + # below downloads, checks the digest, and repoints CONFORMANCE_PKG at the + # verified local copy. Repin to the published release that includes #371 once + # it ships, then drop CONFORMANCE_PKG_SHA256 and the fetch-and-verify steps. CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@371" + CONFORMANCE_PKG_SHA256: "9d8b25874d55e304b006cbaa066571773582f5828143c53a2b8a6830f203ca1d" jobs: server-conformance: @@ -39,6 +43,19 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 + - name: Fetch and verify conformance harness + # Only when CONFORMANCE_PKG is a URL: download, check the recorded + # sha256, and re-point CONFORMANCE_PKG at the verified local tarball. + # When CONFORMANCE_PKG is a registry spec, this step is a no-op (npm's + # own integrity check applies). + run: | + case "$CONFORMANCE_PKG" in + https://*) + curl -fsSL "$CONFORMANCE_PKG" -o /tmp/conformance.tgz + echo "$CONFORMANCE_PKG_SHA256 /tmp/conformance.tgz" | sha256sum -c - + echo "CONFORMANCE_PKG=file:/tmp/conformance.tgz" >> "$GITHUB_ENV" + ;; + esac - run: uv sync --frozen --all-extras --package mcp-everything-server - name: Run server conformance (active suite) run: >- @@ -70,6 +87,19 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 + - name: Fetch and verify conformance harness + # Only when CONFORMANCE_PKG is a URL: download, check the recorded + # sha256, and re-point CONFORMANCE_PKG at the verified local tarball. + # When CONFORMANCE_PKG is a registry spec, this step is a no-op (npm's + # own integrity check applies). + run: | + case "$CONFORMANCE_PKG" in + https://*) + curl -fsSL "$CONFORMANCE_PKG" -o /tmp/conformance.tgz + echo "$CONFORMANCE_PKG_SHA256 /tmp/conformance.tgz" | sha256sum -c - + echo "CONFORMANCE_PKG=file:/tmp/conformance.tgz" >> "$GITHUB_ENV" + ;; + esac - run: uv sync --frozen --all-extras --package mcp - name: Run client conformance (all suite) # The harness runs all scenarios via unbounded Promise.all; with 40 diff --git a/src/mcp/shared/inbound.py b/src/mcp/shared/inbound.py index 6c8d23678..7ca04223a 100644 --- a/src/mcp/shared/inbound.py +++ b/src/mcp/shared/inbound.py @@ -241,19 +241,24 @@ def mcp_param_headers(header_map: Mapping[tuple[str, ...], str], arguments: Mapp """ headers: dict[str, str] = {} for path, token in header_map.items(): - node: Any = arguments - for key in path: - if not isinstance(node, Mapping): - node = None - break - node = cast("Mapping[str, Any]", node).get(key) - if node is None: + value = _value_at_path(arguments, path) + if value is None: continue - rendered = ("true" if node else "false") if isinstance(node, bool) else str(node) + rendered = ("true" if value else "false") if isinstance(value, bool) else str(value) headers[f"{MCP_PARAM_HEADER_PREFIX}{token}"] = encode_header_value(rendered) return headers +def _value_at_path(arguments: Mapping[str, Any], path: tuple[str, ...]) -> Any: + """Read the value at a `properties`-key path in `arguments`, or `None` if any step is missing or non-mapping.""" + node: Any = arguments + for key in path: + if not isinstance(node, Mapping): + return None + node = cast("Mapping[str, Any]", node).get(key) + return node + + # INTERNAL_ERROR is deliberately unmapped (→ HTTP 200): the spec assigns no status to # -32603, and whether handler-origin errors get 5xx is an open S4 question — see TODO(L66). ERROR_CODE_HTTP_STATUS: Final[Mapping[int, int]] = MappingProxyType( From 0c1c904412b485fc6946ed95c4a7eb3028813661 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 14:25:30 +0200 Subject: [PATCH 6/6] Drive the Mcp-Param-* tests through the high-level Client The three x-mcp-header emission tests used a raw ClientSession with a manual adopt(); switch them to the high-level Client over the same mounted HTTP transport (mode=2026-07-28 + prior_discover reproduces the adopt). The cache eviction test moves from tests/client (where it reached into session._resolve_param_headers) to a wire-level assertion: list a tool valid, call it (Mcp-Param-Region present), re-list it invalid, call again (no header). --- tests/client/test_client.py | 32 ------ .../transports/test_hosting_http_modern.py | 98 +++++++++++++++---- 2 files changed, 77 insertions(+), 53 deletions(-) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index ef9f9dab8..f869d1f1b 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -504,38 +504,6 @@ async def on_list_tools( assert [t.name for t in result.tools] == ["ok", "dropme"] -@pytest.mark.anyio -async def test_modern_list_tools_evicts_header_map_when_a_tool_turns_invalid() -> None: - """A re-list that drops a previously valid tool must also evict its cached `x-mcp-header` map, - so a later `tools/call` can't mirror stale `Mcp-Param-*` headers for a now-dropped tool.""" - valid = types.Tool( - name="run", - input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "Region"}}}, - ) - invalid = types.Tool( - name="run", - input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}}, - ) - listings = iter([valid, invalid]) - - async def on_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None - ) -> types.ListToolsResult: - return types.ListToolsResult(tools=[next(listings)]) - - server = Server("test", on_list_tools=on_list_tools) - - with anyio.fail_after(5): - async with Client(server) as client: - session = client.session - await client.list_tools() - assert session._resolve_param_headers("run", {"a": "x"}) == {"Mcp-Param-Region": "x"} - - result = await client.list_tools() - assert [t.name for t in result.tools] == [] - assert session._resolve_param_headers("run", {"a": "x"}) == {} - - def test_client_rejects_handshake_era_mode_at_construction() -> None: """A handshake-era protocol-version string passed as `mode=` is rejected by `__post_init__` with a hint to use `mode='legacy'` — the version-pin path is diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index 3334f542a..a8f1f53c7 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -38,6 +38,7 @@ 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 @@ -433,21 +434,22 @@ async def test_modern_client_mirrors_x_mcp_header_args_into_mcp_param_headers() 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, _), - streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write), - ClientSession(read, write) as session, + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, ): - session.adopt( - DiscoverResult( - supported_versions=[LATEST_MODERN_VERSION], - capabilities=ServerCapabilities(), - server_info=Implementation(name="srv", version="0"), - ) - ) - await session.list_tools() - await session.call_tool("run", {"region": "us-west1", "priority": 42, "verbose": False, "note": "héllo"}) + 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( @@ -478,19 +480,73 @@ 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, _), - streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write), - ClientSession(read, write) as session, + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, ): - session.adopt( - DiscoverResult( - supported_versions=[LATEST_MODERN_VERSION], - capabilities=ServerCapabilities(), - server_info=Implementation(name="srv", version="0"), - ) - ) - await session.call_tool("run", {"region": "us-west1"}) + 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)