Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/advanced/header-parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The mark is one extra key in the argument's JSON Schema. On `MCPServer`, `Field`
```

* Over Streamable HTTP on `2026-07-28`, a client that has listed the tool sends `Mcp-Param-Region` alongside the body, and the server rejects a call where the two disagree.
* A client that hasn't listed the tool yet sends no header, and the call is rejected. This SDK's `Client` then lists the tools and resends the call once, so listing first only saves a round trip.
* Every other connection ignores the annotation.

Your function doesn't change: `region` still arrives as an argument.
Expand Down
8 changes: 0 additions & 8 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2865,14 +2865,6 @@ On a 2026-07-28 connection, `notifications/tools/list_changed`, `notifications/p

Migrate to publishing on the subscription bus, which stamps and filters per stream: `await ctx.notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`, and `notify_resource_updated(uri)` on `MCPServer`'s `Context`, or `await bus.publish(...)` on a low-level `Server`'s own `SubscriptionBus` — see [Subscriptions](handlers/subscriptions.md). A stream only ever receives the kinds and URIs the server acknowledged for it; to gate per caller which subscriptions may be opened, refuse `subscriptions/listen` in a middleware (`MCPServer(middleware=[...])`), covered on the same page.

### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))

On the 2026-07-28 Streamable HTTP path, a `tools/call` whose tool declares `x-mcp-header` annotations is validated before dispatch — each annotated argument and its mirroring `Mcp-Param-*` header must be present together and agree (after base64-sentinel decoding; integers compare numerically), or absent together. A violation is rejected with HTTP 400 and JSON-RPC error `-32020` (`HeaderMismatch`), as the spec requires. A client that sends an annotated argument *without* its header — for example one that never listed the tool — is therefore rejected instead of silently served; the spec's recovery is to re-list and retry. On the client side, `ClientSession.call_tool` emits these headers automatically for annotated arguments of any tool it has listed; list the tool first, and note that pre-2026 connections and non-HTTP transports never emit them.

There is nothing to configure. The server resolves the called tool's schema through its own registered `tools/list` handler (for `MCPServer`, the built-in one), so the validated catalog is exactly what that caller would be shown. Two consequences worth knowing: the listing runs internally on validated calls, so middleware and an expensive or paginated `tools/list` handler see extra invocations; and validation is skipped — never failing the call — when no `tools/list` handler is registered, the tool isn't in the listing, the handler raises (logged as an error), or the call has no arguments and no `Mcp-Param-*` headers. Headers with no matching annotation are ignored; a recognized header supplied more than once is rejected, as is a duplicated `MCP-Protocol-Version`, `Mcp-Method`, or `Mcp-Name` line. The codec and validator are public in `mcp.shared.inbound` (`decode_header_value`, `validate_mcp_param_headers`) for low-level servers hosting their own HTTP entry.

Base64-sentinel decoding is strict everywhere it applies, including the `Mcp-Name` header: a `=?base64?...?=` value whose payload is not canonical base64 (wrong padding, stray characters, non-zero trailing bits) or not valid UTF-8 is rejected as malformed rather than leniently decoded.

## Need Help?
Comment on lines 2867 to 2868

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): readers lose the only docs for the server-side Mcp-Param-* validation and the public mcp.shared.inbound validators, and no page describes the new re-list-and-retry. The deleted section at docs/migration.md:2868 was the sole place in docs/ naming validate_mcp_param_headers, decode_header_value, the skip-when-no-handler rule, duplicate-header rejection and strict base64-sentinel decoding. docs/advanced/header-parameters.md:15 still says only "a client that has listed the tool" sends the header. Fix: move the server-side facts onto docs/advanced/header-parameters.md and add the client recovery there (re-list, one resend, the 100-page cap), so the deletion is a relocation rather than a loss. [also at: docs/whats-new.md:202 - nit: after merging, readers of docs/ find no page describing the new re-list-and-retry behaviour nor the server-side Mcp-Param-* validation rules. docs/whats-new.md:202 drops the "has the rules" link and docs/migration.md deletes the only section that stated those rules, while no page gains the client recovery that this PR adds to Client.call_tool.; src/mcp/client/client.py:785 - nit: AGENTS.md requires the relevant docs/ page to be updated in the same PR when user-visible behaviour changes.]

Why this was flagged

The diff removes the whole "Servers validate Mcp-Param-* headers against the request body (SEP-2243)" section from docs/migration.md (old lines 2868-2874). validate_mcp_param_headers, decode_header_value, "supplied more than once" rejection and the =?base64?...?= strictness now appear only in src/ and tests/, in no page under docs/. docs/advanced/header-parameters.md is untouched and at line 15 still describes only the listed-tool case, so the new behaviour added at src/mcp/client/client.py:821-832 (an extra tools/list per page and a second tools/call after a -32020) is documented only in a docstring. AGENTS.md requires that a change affecting user-visible behaviour update the relevant docs page in the same PR and that docs/migration.md only be corrected or clarified, not have content removed. On the base branch a reader finds both the server-side rules and the "list the tool first" guidance; after merge they find neither, and nothing tells them a call now silently issues up to 100 tools/list requests before being resent.

Verification: Triggering condition: any reader of docs/ looking for how Client.call_tool behaves on -32020 or for the server-side Mcp-Param-* validation rules. The only docs edits are the deletion of the migration.md section (old lines 2868-2874) and removal of the whats-new.md link to it. docs/advanced/header-parameters.md is untouched. Harm is to documentation only; nothing fails at runtime.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new bullet on docs/advanced/header-parameters.md covers the client's re-list-and-resend, which closes half of this. The server-side facts that the deleted docs/migration.md section carried are still absent from every page under docs/: grep -rn "validate_mcp_param_headers\|decode_header_value" docs/ returns nothing, and nothing names the skip-when-no-tools/list-handler rule, rejection of a recognized Mcp-Param-* header supplied more than once, or the strict =?base64?...?= decoding (also applied to Mcp-Name). Either restore the section at docs/migration.md:2868 or add a short "On the server" paragraph to docs/advanced/header-parameters.md stating those rules and pointing low-level Server authors at mcp.shared.inbound.validate_mcp_param_headers / decode_header_value, so the deletion becomes a relocation.


If you encounter issues during migration:
Expand Down
2 changes: 1 addition & 1 deletion docs/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are repla
### The rest, quickly

* **Identity is optional, per-message metadata.** The request-side `clientInfo` `_meta` key is optional (the required pair is `protocolVersion` + `clientCapabilities`), and `serverInfo` moved out of the `server/discover` result body: servers stamp it into every 2026-era result's `_meta` instead ([spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002)). The SDK always stamps; `client.server_info` is `None` when a server does not identify itself (for example, a middleware stripped the key). **[The low-level Server](advanced/low-level-server.md)** shows the stamp on the wire.
* **Requests are routable without parsing bodies.** Modern HTTP requests carry `Mcp-Method` (and, for the three tool-ish calls, `Mcp-Name`); a tool input-schema property annotated with `x-mcp-header` is mirrored into an `Mcp-Param-*` header and cross-checked by the server ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Gateways and rate limiters can route on headers alone; the **[Migration Guide](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)** has the rules.
* **Requests are routable without parsing bodies.** Modern HTTP requests carry `Mcp-Method` (and, for the three tool-ish calls, `Mcp-Name`); a tool input-schema property annotated with `x-mcp-header` is mirrored into an `Mcp-Param-*` header and cross-checked by the server ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Gateways and rate limiters can route on headers alone.
* **Results carry cache hints.** List and read results declare `ttlMs` and `cacheScope` ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); you set them per method with `cache_hints=`, and `Client` honors them with a built-in response cache. A server that sends no hints (every pre-2026 server) sees identical, uncached traffic. **[Caching hints](client/caching.md)**.
* **Extensions are first class.** Servers and clients declare optional capability bundles under reverse-DNS identifiers ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); the built-in `Apps` extension (MCP Apps) is the reference. **[Extensions](advanced/extensions.md)** and **[MCP Apps](advanced/apps.md)**.
* **Error codes got standardized.** A missing resource is `-32602` with the URI in `error.data`, and the new spec-reserved codes appear as `-32020` (header mismatch), `-32021` (missing required capability), and `-32022` (unsupported protocol version). **[Troubleshooting](troubleshooting.md)** is keyed by the exact messages.
Expand Down
42 changes: 39 additions & 3 deletions src/mcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@
from collections.abc import Awaitable, Callable, Mapping, Sequence
from contextlib import AbstractAsyncContextManager, AsyncExitStack
from dataclasses import KW_ONLY, dataclass, field
from typing import Any, Literal, TypeVar, cast
from typing import Any, Final, Literal, TypeVar, cast

import anyio
import anyio.lowlevel
import mcp_types as types
from mcp_types import (
HEADER_MISMATCH,
INVALID_PARAMS,
CacheableResult,
CallToolResult,
Expand All @@ -40,6 +41,7 @@
ServerCapabilities,
)
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
from pydantic import ValidationError
from typing_extensions import deprecated

from mcp.client._input_required import DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, run_input_required_driver
Expand Down Expand Up @@ -79,6 +81,9 @@
initialize), or a modern protocol-version string (adopt directly). The ``str`` arm is for
forward-compat; ``Client.__post_init__`` rejects anything outside that set at construction."""

_RELIST_PAGE_CAP: Final = 100
"""Page cap for the tools/list walk that follows a `HEADER_MISMATCH`: a paginator that never ends cannot hang a call."""

_T = TypeVar("_T")
_ResultT = TypeVar("_ResultT")
_CacheableT = TypeVar("_CacheableT", bound=CacheableResult)
Expand Down Expand Up @@ -775,10 +780,17 @@ async def call_tool(
exceptions propagate as-is. To receive the claimed shape yourself, use
`client.session.call_tool(..., allow_claimed=True)`.

On a 2026-07-28 connection, a call the server rejects with `HEADER_MISMATCH`
(this client has not listed the tool, or its input schema changed since) is
resent once after refetching the tool listing. A second rejection is raised,
and so is the first when the listing cannot be refetched.

Args:
name: The name of the tool to call.
arguments: Arguments to pass to the tool.
read_timeout_seconds: Timeout for each underlying `tools/call` round.
read_timeout_seconds: Timeout for each underlying `tools/call` round, and
for the whole re-list after a `HEADER_MISMATCH`. Defaults to this
client's `read_timeout_seconds`.
progress_callback: Callback for progress updates.
input_responses: Responses to seed the first call with (e.g. when
resuming from a persisted `InputRequiredResult`).
Expand All @@ -795,7 +807,7 @@ async def call_tool(
conform to the negotiated protocol version.
"""

async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult | Result:
async def send(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult | Result:
return await self.session.call_tool(
name,
arguments,
Expand All @@ -809,6 +821,21 @@ async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | Inp
allow_claimed=True,
)

async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult | Result:
try:
return await send(r, s)
except MCPError as mismatch:
if mismatch.code != HEADER_MISMATCH or self.protocol_version not in MODERN_PROTOCOL_VERSIONS:
raise
# The spec's recovery: the tool's listed schema is missing or stale, so re-list and resend once.
timeout = read_timeout_seconds if read_timeout_seconds is not None else self.read_timeout_seconds
try:
with anyio.fail_after(timeout):
await self._relist_tool(name)
except (MCPError, TimeoutError, ValidationError) as relist_error:
raise mismatch from relist_error
return await send(r, s)
Comment on lines +828 to +837

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Tool handlers that surface a -32020 error are now executed twice per call, where the base ran them once and raised. The gate at src/mcp/client/client.py:825 checks only the negotiated version, not whether the transport is HTTP, so on stdio or in-memory 2026-07-28 connections (where no Mcp-Param-* validation exists) any -32020 must come from the handler itself, yet the client still re-lists and resends at src/mcp/client/client.py:832. Fix: only recover when the rejection can be a pre-dispatch header rejection, e.g. gate on the connection being Streamable HTTP (Client already knows a URL server at client.py:396-398) or on the error being a transport-level rejection, so handler-originated -32020 propagates unchanged.

Why this was flagged

A Server on a 2026-07-28 stdio or in-memory connection whose tool handler raises MCPError with code -32020 (for example a proxy/aggregator tool that forwards an upstream HTTP server's HeaderMismatch verbatim). The client's call_tool enters retry at src/mcp/client/client.py:821-832; the guard at client.py:825 passes because self.protocol_version is modern, so _relist_tool issues tools/list and send runs the handler a second time at client.py:832. On the base branch the first -32020 was raised to the caller and the handler ran once. The SDK server emits HEADER_MISMATCH only from the HTTP ladder (src/mcp/shared/inbound.py:448-471); classify_inbound_request skips the header rung when headers is None, so on non-HTTP transports every -32020 is handler-originated and the double run is unconditional. The PR text calls this accepted, but nothing in Client distinguishes the transport even though the constructor knows a URL server from a stdio/in-memory one (client.py:396-402).

Verification: The gate at src/mcp/client/client.py:825 inspects only the negotiated version, never the transport; on passing it calls self._relist_tool(name) (line 829) and then return await send(r, s) (line 832). src/mcp/shared/inbound.py:452 only emits HEADER_MISMATCH if headers is not None, so on stdio/in-process modern connections every -32020 originates in the handler. Base behavior: the handler ran once.

Comment on lines +824 to +837

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): AGENTS.md says any change to an existing public API's observable behaviour is an explicit maintainer design decision and should generally be avoided. The new retry wrapper changes Client.call_tool's observable behaviour on 2026-07-28 connections: a -32020 that used to be raised immediately now silently issues up to 100 tools/list requests and resends the tools/call, and this also wraps every input-required resumption. Fix: have a maintainer explicitly sign off on the behaviour change on the linked issue (#3483) before merge, or gate the recovery behind an opt-in so existing callers' observable behaviour is unchanged.

Why this was flagged

The instruction guards the 2.x compatibility contract. Concretely: callers that caught -32020 themselves (the migration guide told them 'the spec's recovery is to re-list and retry') now see extra wire traffic and a second tools/call before any error; the PR notes a tool handler that itself raises -32020 is run twice on a 2026-07-28 connection. Mitigating context the maintainer may weigh: the spec's Client Behavior section says a client SHOULD do this, the TypeScript SDK already does, the PR fixes an assigned issue, signatures are unchanged, and legacy connections are untouched.

Verification: AGENTS.md at base (Branching Model): "v2 is released; its public API is a compatibility contract for the 2.x line. Removals, renames, or any change to an existing API's signature or observable behaviour ... is a design decision a maintainer makes explicitly, and should generally be avoided."


result = await self._drive_input_required(await retry(input_responses, request_state), retry)
if isinstance(result, CallToolResult):
return result
Expand Down Expand Up @@ -943,6 +970,15 @@ async def list_tools(
),
)

async def _relist_tool(self, name: str) -> None:
"""Refetch the tool listing from the server, page by page, until a page lists `name`."""
cursor: str | None = None
for _ in range(_RELIST_PAGE_CAP):
page = await self.list_tools(cursor=cursor, cache_mode="refresh")
Comment thread
claude[bot] marked this conversation as resolved.
cursor = page.next_cursor
if cursor is None or any(tool.name == name for tool in page.tools):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A paginated refresh that exhausts without name leaves the old per-tool state alive. The retry can therefore emit stale Mcp-Param-* headers and validate output against a stale schema; clear the named tool’s derived state when the complete walk does not find it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/mcp/client/client.py, line 974:

<comment>A paginated refresh that exhausts without `name` leaves the old per-tool state alive. The retry can therefore emit stale `Mcp-Param-*` headers and validate output against a stale schema; clear the named tool’s derived state when the complete walk does not find it.</comment>

<file context>
@@ -943,6 +965,15 @@ async def list_tools(
+        for _ in range(_RELIST_PAGE_CAP):
+            page = await self.list_tools(cursor=cursor, cache_mode="refresh")
+            cursor = page.next_cursor
+            if cursor is None or any(tool.name == name for tool in page.tools):
+                return
+
</file context>

return

@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
async def send_roots_list_changed(self) -> None:
"""Send a notification that the roots list has changed."""
Expand Down
25 changes: 25 additions & 0 deletions tests/docs_src/test_header_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,31 @@ async def test_a_call_whose_header_and_body_disagree_is_rejected() -> None:
assert tampered.json()["error"]["code"] == HEADER_MISMATCH


async def test_a_client_that_has_not_listed_the_tool_is_rejected_then_lists_and_resends_once() -> None:
"""tutorial001: the first call has no header and is a 400; after one `tools/list` it is resent with the header."""
app = tutorial001.mcp.streamable_http_app()
exchanges: list[tuple[str, str | None, int]] = []

async def record(response: httpx2.Response) -> None:
sent = response.request.headers
exchanges.append((sent["mcp-method"], sent.get("mcp-param-region"), response.status_code))

async with (
app.router.lifespan_context(app),
httpx2.ASGITransport(app) as transport,
httpx2.AsyncClient(transport=transport, event_hooks={"response": [record]}) as http,
Client(streamable_http_client(URL, http_client=http)) as client,
):
result = await client.call_tool("check_stock", ARGUMENTS)
assert result.structured_content == {"result": "Dune: 3 copies in eu."}
assert exchanges == [
("server/discover", None, 200),
("tools/call", None, 400),
("tools/list", None, 200),
("tools/call", "eu", 200),
]


async def test_a_legacy_http_connection_ignores_the_annotation() -> None:
"""tutorial001: before 2026-07-28 the same call succeeds and carries no `Mcp-Param-*` header."""
async with check_stock_over_http(tutorial001.mcp.streamable_http_app(), mode="legacy") as (_, call):
Expand Down
11 changes: 11 additions & 0 deletions tests/interaction/_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -3624,6 +3624,17 @@ def __post_init__(self) -> None:
transports=("streamable-http",),
note="Only observable over streamable HTTP: headers are derived from the cached tool schema at the seam.",
),
"client-transport:http:header-mismatch-recovery": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#client-behavior",
behavior=(
"When the server rejects a tools/call with HeaderMismatch, the client calls tools/list for the "
"tool's current inputSchema and retries the call once with the Mcp-Param-* headers that schema "
"asks for. A second rejection is raised to the caller."
),
added_in="2026-07-28",
transports=("streamable-http",),
note="Client.call_tool only: ClientSession.call_tool sends once and leaves the recovery to its caller.",
),
"client-transport:http:vendor-name-param-header": Requirement(
source="sdk",
behavior=(
Expand Down
Loading
Loading