Skip to content

Reject a tool with an invalid x-mcp-header annotation at registration - #3620

Merged
maxisbey merged 2 commits into
mainfrom
3484-reject-invalid-x-mcp-header
Oct 2, 2026
Merged

maxisbey merged 2 commits into
mainfrom
3484-reject-invalid-x-mcp-header

Conversation

@maxisbey

@maxisbey maxisbey commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #3484.

A tool whose input schema carries an invalid x-mcp-header annotation registered on MCPServer without complaint. Clients on 2026-07-28 then leave the tool out of their tools/list result, as the spec requires of them, and the only diagnostic was a warning in the client's process. Registration now fails with an error that names the tool and the problem.

What changes

  • Tool.from_function runs the existing find_invalid_x_mcp_header over the schema it just generated and raises InvalidSignature when it finds a problem:

    InvalidSignature: Tool 'fetch' has an invalid x-mcp-header annotation: property 'tags': x-mcp-header is only permitted on integer/string/boolean properties (got 'array')
    
  • That covers @server.tool(), MCPServer.add_tool, Tool.from_function, and so MCPServer(tools=[Tool.from_function(...)]).

  • It is the same check the client applies before dropping a tool, so registration refuses exactly the schemas this SDK's client would drop.

  • InvalidSignature is what registration already raises for a function that cannot be a tool as declared.

  • No new names, parameters or defaults. Nothing changes on the wire for a tool that registers.

What users will notice

  • A server that registers a tool with an invalid annotation now fails at registration, which for a decorated tool is import time.

    • Until now such a server started, served the tool to clients on earlier protocol versions, and lost it for clients on 2026-07-28.
  • The cases that are refused:

    • an annotation on anything other than a string, integer or boolean parameter, such as list[...] or float
    • an annotation on a parameter whose generated schema has no plain type: str | None (pydantic renders an anyOf), an Enum or a field inside a nested model (pydantic renders a $ref)
    • a header name that is not an RFC 9110 token
    • two parameters whose header names differ only by case
  • An optional header parameter can still be declared by giving the schema directly:

    region: Annotated[str | None, WithJsonSchema({"type": "string", "x-mcp-header": "Region"})] = None
  • On the 2026-07-28 Streamable HTTP path, a tools/call for a tool with an invalid annotation skipped the Mcp-Param-* header check entirely. Such a tool can no longer be registered this way, so that case is gone for tools built by Tool.from_function.

Not included

  • A first-class marker such as McpHeader("Region"). The annotation is still written with Field(json_schema_extra={"x-mcp-header": ...}).
  • A Tool built by hand with Tool(parameters=...) and tools returned from a low-level Server list handler are not checked. Both bypass Tool.from_function.

How it was checked

  • New tests in tests/server/mcpserver/tools/test_base.py:
    • an array, a float, a str | None and a non-token header name each raise InvalidSignature and nothing is registered
    • a tool annotating a str, an int and a bool registers
  • The four invalid-annotation cases fail on main and pass with the change.
  • No existing test, example or docs snippet needed changing.
  • ./scripts/test passes with 100% coverage; ruff and pyright are clean.

AI Disclaimer

`Tool.from_function` now checks the generated input schema with
`find_invalid_x_mcp_header` and raises `InvalidSignature` naming the tool
and the reason. Clients on 2026-07-28 exclude such a tool from their
tools/list result, so until now it registered without complaint and then
went missing for those clients, with the only diagnostic logged in the
client's process.

Fixes #3484

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No issues found across 2 files

Re-trigger cubic

@claude claude Bot left a comment

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

One optional note from this repository's REVIEW.md or CLAUDE.md checks was not posted as a comment, over this review's limit for such notes; it is on this commit's check card.

Comment thread src/mcp/server/mcpserver/tools/base.py
Comment thread tests/server/mcpserver/tools/test_base.py Outdated
Add a short Advanced page on marking a tool argument with `x-mcp-header`:
what it does, which argument types can be marked, how to spell an optional
argument, and what the low-level `Server` leaves unchecked. Link it from the
nav and the Advanced index, and give the new `InvalidSignature` message a
Troubleshooting entry. Every claim is covered in `tests/docs_src`.

The registration tests now capture the SDK-authored messages with inline
snapshots instead of hand-written literals.
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3620.mcp-python-docs.pages.dev
Deployment https://80d1113c.mcp-python-docs.pages.dev
Commit c89d692
Triggered by @maxisbey
Updated 2026-10-02 12:02:23 UTC

@maxisbey
maxisbey merged commit 3d0c40d into main Oct 2, 2026
37 checks passed
@maxisbey
maxisbey deleted the 3484-reject-invalid-x-mcp-header branch October 2, 2026 12:03

@claude claude Bot left a comment

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

The second push addresses both points from my earlier pass: the new docs/advanced/header-parameters.md page plus the troubleshooting entry cover the registration-time rejection and the WithJsonSchema spelling for an optional header, and the tests/server/mcpserver/tools/test_base.py messages are now inline snapshots. Beyond the one inline nit, I also checked that the check in Tool.from_function raises before the Tool is constructed, so a refused function leaves nothing registered (the list_tools() == [] assertions exercise this), and that the hand-built Tool(parameters=...) and low-level Server paths remain unchecked exactly as the description states.

Extended reasoning...

The library change is three lines in src/mcp/server/mcpserver/tools/base.py that reuse the client's existing find_invalid_x_mcp_header check at registration and raise InvalidSignature; the rest is a new docs page, two docs_src examples, and tests. No security-sensitive surface is touched. It is a deliberate behavioural change (a previously accepted schema now fails at import), which per the repository's own conventions is a maintainer design decision, so a human confirmation of that intent is still worthwhile even though the implementation itself is straightforward.

Comment on lines +86 to +91
copies: Annotated[int, Field(json_schema_extra={"x-mcp-header": "Copies"})],
gift: Annotated[bool, Field(json_schema_extra={"x-mcp-header": "Gift"})],
) -> None:
"""Never called: registering and listing it is the claim."""

async with Client(mcp) as client:

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): maintainers get a registered-but-never-invoked tool whose body silently returns None, against the repo's test bar. In test_header_parameters.py:86-91 reserve is registered on the server and listed, but its body is only a docstring, so a future call would succeed with no result instead of failing loudly. Fix: make the body raise NotImplementedError, as fetch does in tests/server/mcpserver/tools/test_base.py:146. The docstring-only bodies at lines 108 and 118 are fine, since the decorator raises before anything is registered.

Why this was flagged

tests/docs_src/test_header_parameters.py:86-91 defines reserve under @ mcp.tool() with a docstring-only body and -> None; the test then lists tools over an in-memory Client(mcp) at line 93-94 and never calls it. AGENTS.md's Testing section delegates test conventions to .claude/skills/test-quality/SKILL.md, whose Hygiene section says registered-but-never-invoked handler bodies are raise NotImplementedError so they cannot silently become load-bearing. Here a later edit that calls reserve would get a successful empty result rather than an error, hiding that the test's claim is only about registration and listing. The sibling test in tests/server/mcpserver/tools/test_base.py:139-146 follows the rule with raise NotImplementedError. Nothing fails at runtime; this is a convention slip only.

Verification: nit. The new file tests/docs_src/test_header_parameters.py:84-89 registers reserve with @ mcp.tool() and its body is only the docstring (returns None); lines 91-92 then list tools over Client(mcp) and never call it. Nothing fails at runtime today; the consequence is only that a later call to reserve would succeed silently instead of failing loudly, so severity is nit.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCPServer has no x-mcp-header declaration mechanism and never validates one, so an invalid annotation is served happily and dropped by every client

1 participant