Skip to content

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

@atsuki-shirasawa

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

  1. Run find_invalid_x_mcp_header at 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.
  2. 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

  • mcp 2.1.1, mcp-types 2.1.1, Python 3.12.9
  • Both reproductions negotiate 2026-07-28

Activity

  1. atsuki-shirasawa commented on Sep 9, 2026

    @atsuki-shirasawa
    Author

    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 no x-mcp-header anything — no declaration mechanism, and FastMCP/MCPServer tool registration does not look for one.
    • find_invalid_x_mcp_header is still in src/mcp/shared/inbound.py and 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.
    • So the reproduction in the issue body stands on 2.2.0: an x-mcp-header on an array-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-Name headers rather than anything on this path.

  2. Kangwenqiao commented on Sep 9, 2026

    @Kangwenqiao

    I traced the current main implementation and confirmed the smallest fix boundary:

    • ToolManager.add_tool() builds the Tool and stores it without validating tool.parameters.
    • find_invalid_x_mcp_header() already walks the relevant JSON Schema positions and is already used by the client-side listing filter.
    • The server-side registration path can reuse that validator before storing the tool, turning an invalid x-mcp-header into a local registration error instead of serving a tool every conforming client drops.

    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.

  3. atsuki-shirasawa commented on Sep 9, 2026

    @atsuki-shirasawa
    Author

    Thanks — I checked all four points against the v2.2.0 tag and main, 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 via Tool.from_function and stores at :66 with nothing in between; Tool.parameters (base.py:41) is the JSON Schema; and server.py:515 serves it verbatim as input_schema=info.parameters — so validating tool.parameters at registration validates exactly the object a client receives, with no transformation in between.

    1. add_tool is not the only door. ToolManager.__init__(tools=...) (tool_manager.py:23-27) inserts Tool objects straight into self._tools, bypassing add_tool entirely. A check placed only in add_tool leaves that path open. A single private helper both call sites use covers it.

    Separately, the lowlevel Server(on_list_tools=...) path never goes through ToolManager at all — the author returns Tool objects themselves. I do not think that needs to be in scope, but it is worth saying out loud that the guarantee is MCPServer-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_header is strictly stronger than the three MUST rows — it also enforces the RFC 9110 token shape, case-insensitive uniqueness, and reachability via a pure properties chain. That extra strictness is the point rather than over-reach: client/session.py:1301 calls 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_headers opens with if find_invalid_x_mcp_header(input_schema) is not None: return None (inbound.py:543), and the docstring says so deliberately: "A schema find_invalid_x_mcp_header rejects 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.

  4. added
    v2Affects the v2 line (2.x on main)
    spec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revision
    on Sep 10, 2026
  5. maxisbey commented on Oct 2, 2026

    @maxisbey
    Contributor

    The silent failure here is fixed in #3620, thanks for the thorough report.

    Registering a tool on MCPServer now runs the same x-mcp-header check the client uses, and raises InvalidSignature naming 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-built Tool(parameters=...) or a low-level Server is still unchecked.

    If either of those gaps matters for you, a new issue is welcome.

    AI Disclaimer

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    spec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revisionv2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions