Reject a tool with an invalid x-mcp-header annotation at registration - #3620
Conversation
`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
There was a problem hiding this comment.
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.
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.
📚 Documentation preview
|
There was a problem hiding this comment.
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.
| 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: |
There was a problem hiding this comment.
🟡 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.
Fixes #3484.
A tool whose input schema carries an invalid
x-mcp-headerannotation registered onMCPServerwithout complaint. Clients on 2026-07-28 then leave the tool out of theirtools/listresult, 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_functionruns the existingfind_invalid_x_mcp_headerover the schema it just generated and raisesInvalidSignaturewhen it finds a problem:That covers
@server.tool(),MCPServer.add_tool,Tool.from_function, and soMCPServer(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.
InvalidSignatureis 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.
The cases that are refused:
list[...]orfloattype:str | None(pydantic renders ananyOf), anEnumor a field inside a nested model (pydantic renders a$ref)An optional header parameter can still be declared by giving the schema directly:
On the 2026-07-28 Streamable HTTP path, a
tools/callfor a tool with an invalid annotation skipped theMcp-Param-*header check entirely. Such a tool can no longer be registered this way, so that case is gone for tools built byTool.from_function.Not included
McpHeader("Region"). The annotation is still written withField(json_schema_extra={"x-mcp-header": ...}).Toolbuilt by hand withTool(parameters=...)and tools returned from a low-levelServerlist handler are not checked. Both bypassTool.from_function.How it was checked
tests/server/mcpserver/tools/test_base.py:float, astr | Noneand a non-token header name each raiseInvalidSignatureand nothing is registeredstr, anintand aboolregistersmainand pass with the change../scripts/testpasses with 100% coverage; ruff and pyright are clean.AI Disclaimer