Repository navigation
MCPServer has no x-mcp-header declaration mechanism and never validates one, so an invalid annotation is served happily and dropped by every client #3484
Description
Summary
MCPServer offers no way to mark a tool parameter with x-mcp-header, and does not validate the annotation when one is smuggled in through pydantic. A server author who gets it wrong gets no signal at all: the tool is served happily, and every conforming client silently drops it.
SEP-2243's Reference Implementation section names this as a server-SDK requirement:
- Server SDKs: Provide a mechanism (attribute/decorator) for marking parameters with
x-mcp-header- Client SDKs: Implement the client behavior for extracting and encoding header values
- Validation: Both sides must validate header/body consistency
The client half is implemented. The server half is not: x-mcp-header appears in mcp/client/session.py, mcp/shared/inbound.py and mcp_types/_v2026_07_28/, and nowhere under mcp/server/.
1. No declaration mechanism
The only route is pydantic passthrough:
@server.tool()
async def fetch(
owner: Annotated[str, Field(json_schema_extra={"x-mcp-header": "owner"})],
) -> str:
...This works — the annotation reaches inputSchema, the client mirrors it, mcp/shared/inbound.py validates it — so this is an ergonomics and discoverability gap rather than a functional one. But it means the feature is invisible from the server API, and that a server author must know the extension keyword's exact spelling from the spec.
2. Nothing validates the declaration server-side
This is the part that fails silently. SEP-2243 puts type restrictions on x-mcp-header and assigns their enforcement to the server:
| Test Case | Property Type | x-mcp-header Present | Expected Behavior |
| Array type |"type": "array"| Yes | Server MUST reject tool definition |
| Object type |"type": "object"| Yes | Server MUST reject tool definition |
| Null type |"type": "null"| Yes | Server MUST reject tool definition |
MCPServer rejects none of them.
import anyio
from typing import Annotated
from pydantic import Field
from mcp.client import Client
from mcp.client._memory import InMemoryTransport
from mcp.server.mcpserver import MCPServer
server = MCPServer("repro")
@server.tool()
async def bad(
tags: Annotated[list[str], Field(json_schema_extra={"x-mcp-header": "Tags"})],
) -> str:
"""An array parameter annotated x-mcp-header -- the spec says reject."""
return "ok"
async def main() -> None:
async with Client(InMemoryTransport(server), mode="auto") as client:
print("negotiated:", client.protocol_version)
result = await client.list_tools()
print("tools the client kept:", [t.name for t in result.tools])
anyio.run(main)Output on mcp 2.1.1:
WARNING dropping tool 'bad': invalid x-mcp-header (property 'tags':
x-mcp-header is only permitted on integer/string/boolean
properties (got 'array'))
negotiated: 2026-07-28
tools the client kept: []
Registration succeeded, startup succeeded, tools/list served it. The client — correctly, per the client-side MUST — drops it. So the failure mode is a tool that exists on the server and is invisible to every client, with the only diagnostic emitted in the client's process, which in a real deployment belongs to someone else.
The validator that would catch this already exists and is already imported by the server package's transport: find_invalid_x_mcp_header in mcp/shared/inbound.py. It is simply never run against a tool the server itself is registering.
Suggested fixes
- Run
find_invalid_x_mcp_headerat tool-registration time and raise. This is the one that matters: it turns a silent cross-process failure into an error at the line that caused it, and it reuses code that is already there. - A first-class declaration API, so the extension keyword does not have to be spelled by hand — whatever shape fits the SDK's conventions, e.g.
Annotated[str, McpHeader("Region")].
Happy to open a PR for (1) if the direction is agreeable.
Environment
mcp2.1.1,mcp-types2.1.1, Python 3.12.9- Both reproductions negotiate
2026-07-28
Activity
atsuki-shirasawa commentedon Sep 9, 2026
Re-checked against the v2.2.0 tag — unchanged, so this is not stale.
(Correcting my earlier comment here, which pasted the checklist from the client-side issue #3483. Only its last line was about this one. The server-side facts:)
src/mcp/server/still contains nox-mcp-headeranything — no declaration mechanism, andFastMCP/MCPServertool registration does not look for one.find_invalid_x_mcp_headeris still insrc/mcp/shared/inbound.pyand is still imported by the modern streamable-HTTP server transport, where it validates the annotations on an incoming request's schema. It is still never run against a tool the server itself registers, which is the gap: an invalid annotation is accepted at registration and only rejected later, by clients.x-mcp-headeron anarray-typed parameter registers, starts and serves without complaint, and every conforming client drops the tool.#3442 in that release does touch SEP-2243, but it adds conformance fixtures for the resource/prompt
Mcp-Nameheaders rather than anything on this path.Kangwenqiao commentedon Sep 9, 2026
I traced the current main implementation and confirmed the smallest fix boundary:
I think this should stay scoped to registration-time validation; a first-class annotation API is a separate design question. The regression should cover an array-typed parameter with x-mcp-header and confirm registration fails, while valid string/integer/boolean annotations remain accepted.
AI assistance was used for repository investigation and this implementation analysis; I reviewed the source and the proposed scope.
atsuki-shirasawa commentedon Sep 9, 2026
Thanks — I checked all four points against the
v2.2.0tag andmain, and they hold. Three things to add, plus one argument for the fix that I think strengthens the case.Confirmations.
ToolManager.add_tool(tool_manager.py:39) builds viaTool.from_functionand stores at:66with nothing in between;Tool.parameters(base.py:41) is the JSON Schema; andserver.py:515serves it verbatim asinput_schema=info.parameters— so validatingtool.parametersat registration validates exactly the object a client receives, with no transformation in between.1.
add_toolis not the only door.ToolManager.__init__(tools=...)(tool_manager.py:23-27) insertsToolobjects straight intoself._tools, bypassingadd_toolentirely. A check placed only inadd_toolleaves that path open. A single private helper both call sites use covers it.Separately, the lowlevel
Server(on_list_tools=...)path never goes throughToolManagerat all — the author returnsToolobjects themselves. I do not think that needs to be in scope, but it is worth saying out loud that the guarantee isMCPServer-scoped rather than server-wide, so nobody reads the fix as stronger than it is.2. The predicate to reuse is the client's drop condition, not the SEP's table.
find_invalid_x_mcp_headeris strictly stronger than the three MUST rows — it also enforces the RFC 9110 token shape, case-insensitive uniqueness, and reachability via a purepropertieschain. That extra strictness is the point rather than over-reach:client/session.py:1301calls the same function, so rejecting exactly what it rejects gives the invariant "the server never serves a tool a conforming client would drop." Worth stating in the PR so the extra conditions do not later get trimmed back to the table.3. A mis-annotated tool is not only invisible — it is also unvalidated.
validate_mcp_param_headersopens withif find_invalid_x_mcp_header(input_schema) is not None: return None(inbound.py:543), and the docstring says so deliberately: "A schemafind_invalid_x_mcp_headerrejects validates nothing: conforming clients drop the tool and emit no headers." That reasoning is sound for conforming clients, but it means that today a tool with a bad annotation gets zero header/body consistency checking when a non-conforming client does call it. Registration-time rejection closes that too, which I think makes this more than an ergonomics fix.On the regression test, I would add the
ToolManager(tools=[...])constructor path alongside the array-typed case, and a duplicate-token case — the latter is the one where reusing the shared validator visibly buys something the SEP table would not.Happy for you to take this — you have clearly done the tracing. I will stay out of the way unless you would rather I open it.
maxisbey commentedon Oct 2, 2026
The silent failure here is fixed in #3620, thanks for the thorough report.
Registering a tool on
MCPServernow runs the samex-mcp-headercheck the client uses, and raisesInvalidSignaturenaming the tool and the problem. There's also a new Header parameters page in the docs.The first-class marker (
McpHeader(...)) isn't included, to keep this a small fix with no new public API. A hand-builtTool(parameters=...)or a low-levelServeris still unchecked.If either of those gaps matters for you, a new issue is welcome.
AI Disclaimer