Skip to content

Commit 3d0c40d

Browse files
authored
Reject a tool with an invalid x-mcp-header annotation at registration (#3620)
1 parent ebf6e5a commit 3d0c40d

11 files changed

Lines changed: 368 additions & 1 deletion

File tree

‎docs/advanced/header-parameters.md‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Header parameters
2+
3+
Most servers never need this.
4+
5+
A gateway or load balancer in front of your server can only route on what it can read without parsing the body. Mark a tool argument with `x-mcp-header`, and clients on the `2026-07-28` **[protocol version](../protocol-versions.md)** send its value as an HTTP header as well.
6+
7+
## Mark an argument
8+
9+
The mark is one extra key in the argument's JSON Schema. On `MCPServer`, `Field` puts it there:
10+
11+
```python title="server.py" hl_lines="13"
12+
--8<-- "docs_src/header_parameters/tutorial001.py"
13+
```
14+
15+
* 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.
16+
* Every other connection ignores the annotation.
17+
18+
Your function doesn't change: `region` still arrives as an argument.
19+
20+
## What can be marked
21+
22+
`str`, `int` and `bool` arguments. Anything else is refused when the tool is registered, with `InvalidSignature`.
23+
24+
That includes `str | None`, which has no single type. An optional argument needs its schema spelled out, with pydantic's `WithJsonSchema`:
25+
26+
```python
27+
region: Annotated[str | None, WithJsonSchema({"type": "string", "x-mcp-header": "Region"})] = None
28+
```
29+
30+
## On the low-level `Server`
31+
32+
There you write `input_schema` by hand, so the key goes straight in:
33+
34+
```python title="server.py" hl_lines="18"
35+
--8<-- "docs_src/header_parameters/tutorial002.py"
36+
```
37+
38+
* Nothing checks the annotation for you: an invalid one is served, and `2026-07-28` clients leave the tool out of their listing.
39+
40+
## Recap
41+
42+
* `x-mcp-header` on a tool argument makes `2026-07-28` clients repeat it as an `Mcp-Param-*` HTTP header.
43+
* The server rejects a call whose header and body disagree.
44+
* Only `str`, `int` and `bool` arguments can be marked. `MCPServer` raises `InvalidSignature` for anything else.
45+
* The low-level `Server` checks nothing, and clients drop a tool whose annotation is invalid.
46+
47+
The rest of the hand-written `Server` API is **[The low-level Server](low-level-server.md)**.

‎docs/advanced/index.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ layer is in the way:
99
methods of your own.
1010
* **[Pagination](pagination.md)** and **[Middleware](middleware.md)**: two things you
1111
can *only* do on the low-level `Server`.
12+
* **[Header parameters](header-parameters.md)**: let a gateway route a tool call on one
13+
of its arguments.
1214
* **[Extensions](extensions.md)** and **[MCP Apps](apps.md)**: the protocol's
1315
extension surface. Compose extension packages into a server, or write your own.
1416

‎docs/troubleshooting.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@ Add the parentheses. `@mcp.resource(...)` and `@mcp.prompt()` say the same thing
123123
tools, has this shape: run `python server.py` yourself and read the traceback. A type checker
124124
also catches it: a function is not a valid `name=`.
125125

126+
## `InvalidSignature: Tool '<name>' has an invalid x-mcp-header annotation: <reason>`
127+
128+
A tool argument is marked with `x-mcp-header` in a way the spec doesn't allow, and `<reason>` says which rule it breaks. Clients on `2026-07-28` would leave such a tool out of their listing, so the SDK refuses to register it.
129+
130+
Only `str`, `int` and `bool` arguments can be marked, and `str | None` is none of them. **[Header parameters](advanced/header-parameters.md)** has the spelling for an optional argument.
131+
132+
Like the entry above, this raises when the module is **imported**, before any client connects.
133+
126134
## `Tool already exists: <name>`
127135

128136
Two registrations used the same tool name. The **first** one wins, the second is silently dropped, and this warning in the *server log* is the only signal:
@@ -420,6 +428,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key
420428
* `ExceptionGroup: unhandled errors in a TaskGroup` is never the error. Read the **last line**; catching `MCPError` *inside* the `async with Client(...)` block skips the wrapping entirely.
421429
* `call_tool` does not raise for a failing tool. `Error executing tool ...` and `Unknown tool: ...` are results: check `result.is_error`. No message after the tool name means it crashed, and the traceback is in the server log.
422430
* `Client must be used within an async context manager` -> use `async with`. `Use @tool() instead of @tool` -> add the parentheses.
431+
* `has an invalid x-mcp-header annotation` -> only `str`, `int` and `bool` arguments can be marked.
423432
* `Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one.
424433
* One 421, three spellings: `Server returned an error response` (the python `Client`), `421 Misdirected Request` / `Invalid Host header` (everything else), `Invalid Host header: <host>` (the server log). Fix: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`.
425434
* `Task group is not initialized` -> a mounted app whose host lifespan never entered `mcp.session_manager.run()`.

‎docs_src/header_parameters/__init__.py‎

Whitespace-only changes.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from typing import Annotated
2+
3+
from pydantic import Field
4+
5+
from mcp.server import MCPServer
6+
7+
mcp = MCPServer("Bookshop")
8+
9+
10+
@mcp.tool()
11+
def check_stock(
12+
title: str,
13+
region: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Region"})],
14+
) -> str:
15+
"""Count the copies of a book in one region's warehouses."""
16+
return f"{title}: 3 copies in {region}."
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from mcp.server import Server, ServerRequestContext
2+
from mcp.types import (
3+
CallToolRequestParams,
4+
CallToolResult,
5+
ListToolsResult,
6+
PaginatedRequestParams,
7+
TextContent,
8+
Tool,
9+
)
10+
11+
CHECK_STOCK = Tool(
12+
name="check_stock",
13+
description="Count the copies of a book in one region's warehouses.",
14+
input_schema={
15+
"type": "object",
16+
"properties": {
17+
"title": {"type": "string"},
18+
"region": {"type": "string", "x-mcp-header": "Region"},
19+
},
20+
"required": ["title", "region"],
21+
},
22+
)
23+
24+
25+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
26+
return ListToolsResult(tools=[CHECK_STOCK])
27+
28+
29+
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
30+
args = params.arguments or {}
31+
text = f"{args['title']}: 3 copies in {args['region']}."
32+
return CallToolResult(content=[TextContent(type="text", text=text)])
33+
34+
35+
server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool)
36+
app = server.streamable_http_app()

‎mkdocs.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ nav:
6565
- The low-level Server: advanced/low-level-server.md
6666
- Pagination: advanced/pagination.md
6767
- Middleware: advanced/middleware.md
68+
- Header parameters: advanced/header-parameters.md
6869
- Extensions: advanced/extensions.md
6970
- MCP Apps: advanced/apps.md
7071
- Troubleshooting: troubleshooting.md

‎src/mcp/server/mcpserver/tools/base.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
2525
from mcp.shared._callable_inspection import is_async_callable
2626
from mcp.shared.exceptions import MCPError
27+
from mcp.shared.inbound import find_invalid_x_mcp_header
2728
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
2829

2930
if TYPE_CHECKING:
@@ -104,6 +105,8 @@ def from_function(
104105
structured_output=structured_output,
105106
)
106107
parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True)
108+
if (reason := find_invalid_x_mcp_header(parameters)) is not None:
109+
raise InvalidSignature(f"Tool {func_name!r} has an invalid x-mcp-header annotation: {reason}")
107110

108111
# Match `model_dump_one_level`'s kwarg keys (alias when present, else field name)
109112
# so a by-name resolver param resolves to a key that exists at call time.
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""`docs/advanced/header-parameters.md`: every claim the page makes, proved against the real SDK."""
2+
3+
from collections.abc import AsyncIterator
4+
from contextlib import asynccontextmanager
5+
from typing import Annotated, Literal
6+
7+
import httpx2
8+
import pytest
9+
from mcp_types import HEADER_MISMATCH, ListToolsResult, PaginatedRequestParams
10+
from pydantic import Field, WithJsonSchema
11+
from starlette.applications import Starlette
12+
13+
from docs_src.header_parameters import tutorial001, tutorial002
14+
from mcp import Client
15+
from mcp.client.streamable_http import streamable_http_client
16+
from mcp.server import MCPServer, Server, ServerRequestContext
17+
from mcp.server.mcpserver.exceptions import InvalidSignature
18+
19+
# See test_index.py for why this is a per-module mark and not a conftest hook.
20+
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
21+
22+
URL = "http://localhost:8000/mcp"
23+
ARGUMENTS = {"title": "Dune", "region": "eu"}
24+
25+
26+
@asynccontextmanager
27+
async def check_stock_over_http(
28+
app: Starlette, mode: Literal["auto", "legacy"] = "auto"
29+
) -> AsyncIterator[tuple[httpx2.AsyncClient, httpx2.Request]]:
30+
"""List the tools and call `check_stock` over in-process HTTP; yield the HTTP client and the call's request."""
31+
requests: list[httpx2.Request] = []
32+
33+
async def record(request: httpx2.Request) -> None:
34+
requests.append(request)
35+
36+
async with (
37+
app.router.lifespan_context(app),
38+
httpx2.ASGITransport(app) as transport,
39+
httpx2.AsyncClient(transport=transport, event_hooks={"request": [record]}) as http,
40+
Client(streamable_http_client(URL, http_client=http), mode=mode) as client,
41+
):
42+
await client.list_tools()
43+
result = await client.call_tool("check_stock", ARGUMENTS)
44+
assert not result.is_error
45+
yield http, next(request for request in requests if b'"tools/call"' in request.content)
46+
47+
48+
@pytest.mark.parametrize(
49+
"app", [tutorial001.mcp.streamable_http_app(), tutorial002.app], ids=["tutorial001", "tutorial002"]
50+
)
51+
async def test_a_2026_http_client_sends_the_marked_argument_as_a_header_as_well(app: Starlette) -> None:
52+
"""Both tutorials: `region` travels as `Mcp-Param-Region` and stays in the body; `title` is body only."""
53+
async with check_stock_over_http(app) as (_, call):
54+
assert {k: v for k, v in call.headers.items() if k.startswith("mcp-param-")} == {"mcp-param-region": "eu"}
55+
assert b'"region":"eu"' in call.content
56+
57+
58+
async def test_a_call_whose_header_and_body_disagree_is_rejected() -> None:
59+
"""tutorial001: the client's own request, replayed with a different `Mcp-Param-Region`, is a 400."""
60+
async with check_stock_over_http(tutorial001.mcp.streamable_http_app()) as (http, call):
61+
tampered = await http.post(URL, content=call.content, headers={**call.headers, "mcp-param-region": "us"})
62+
assert tampered.status_code == 400
63+
assert tampered.json()["error"]["code"] == HEADER_MISMATCH
64+
65+
66+
async def test_a_legacy_http_connection_ignores_the_annotation() -> None:
67+
"""tutorial001: before 2026-07-28 the same call succeeds and carries no `Mcp-Param-*` header."""
68+
async with check_stock_over_http(tutorial001.mcp.streamable_http_app(), mode="legacy") as (_, call):
69+
assert not [name for name in call.headers if name.startswith("mcp-param-")]
70+
71+
72+
async def test_a_connection_that_is_not_http_ignores_the_annotation() -> None:
73+
"""tutorial001: in memory there are no headers to send, and the call succeeds all the same."""
74+
async with Client(tutorial001.mcp) as client:
75+
assert client.protocol_version == "2026-07-28"
76+
result = await client.call_tool("check_stock", ARGUMENTS)
77+
assert result.structured_content == {"result": "Dune: 3 copies in eu."}
78+
79+
80+
async def test_int_and_bool_arguments_can_be_marked() -> None:
81+
"""`str` is tutorial001; `int` and `bool` register too, and a 2026-07-28 client keeps the tool."""
82+
mcp = MCPServer("Bookshop")
83+
84+
@mcp.tool()
85+
def reserve(
86+
copies: Annotated[int, Field(json_schema_extra={"x-mcp-header": "Copies"})],
87+
gift: Annotated[bool, Field(json_schema_extra={"x-mcp-header": "Gift"})],
88+
) -> None:
89+
"""Never called: registering and listing it is the claim."""
90+
91+
async with Client(mcp) as client:
92+
assert [tool.name for tool in (await client.list_tools()).tools] == ["reserve"]
93+
94+
95+
async def test_any_other_type_is_refused_when_the_tool_is_registered() -> None:
96+
"""A marked `list[str]` raises `InvalidSignature` from the decorator, before any client connects."""
97+
mcp = MCPServer("Bookshop")
98+
with pytest.raises(InvalidSignature):
99+
100+
@mcp.tool()
101+
def check_stock(regions: Annotated[list[str], Field(json_schema_extra={"x-mcp-header": "Regions"})]) -> None:
102+
"""Never called: the decoration itself is what raises."""
103+
104+
assert await mcp.list_tools() == []
105+
106+
107+
async def test_a_plain_optional_argument_is_refused_and_the_spelled_out_schema_registers() -> None:
108+
"""`str | None` has no single `type`, so it is refused; the page's `WithJsonSchema` spelling is kept."""
109+
mcp = MCPServer("Bookshop")
110+
with pytest.raises(InvalidSignature):
111+
112+
@mcp.tool()
113+
def refused(region: Annotated[str | None, Field(json_schema_extra={"x-mcp-header": "Region"})] = None) -> None:
114+
"""Never called: the decoration itself is what raises."""
115+
116+
@mcp.tool()
117+
def check_stock(
118+
region: Annotated[str | None, WithJsonSchema({"type": "string", "x-mcp-header": "Region"})] = None,
119+
) -> str:
120+
"""Count the copies of a book in one region's warehouses."""
121+
return f"3 copies in {region}."
122+
123+
async with Client(mcp) as client:
124+
assert [tool.name for tool in (await client.list_tools()).tools] == ["check_stock"]
125+
result = await client.call_tool("check_stock", {})
126+
assert result.structured_content == {"result": "3 copies in None."}
127+
128+
129+
async def test_the_low_level_server_serves_an_invalid_annotation_and_a_2026_client_leaves_the_tool_out() -> None:
130+
"""tutorial002's tool with `region` turned into an array: a legacy client is shown it, a 2026-07-28 one is not."""
131+
properties = {"region": {"type": "array", "x-mcp-header": "Region"}}
132+
invalid = tutorial002.CHECK_STOCK.model_copy(update={"input_schema": {"type": "object", "properties": properties}})
133+
134+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
135+
return ListToolsResult(tools=[invalid])
136+
137+
server = Server("Bookshop", on_list_tools=list_tools)
138+
async with Client(server, mode="legacy") as legacy:
139+
assert [tool.name for tool in (await legacy.list_tools()).tools] == ["check_stock"]
140+
async with Client(server) as modern:
141+
assert modern.protocol_version == "2026-07-28"
142+
assert (await modern.list_tools()).tools == []

‎tests/docs_src/test_troubleshooting.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""`docs/troubleshooting.md`: every error string the page names, reproduced against the real SDK."""
22

33
import logging
4-
from typing import Any
4+
from typing import Annotated, Any
55

66
import httpx2
77
import pytest
8+
from inline_snapshot import snapshot
89
from mcp_types import (
910
INVALID_PARAMS,
1011
INVALID_REQUEST,
@@ -14,6 +15,7 @@
1415
ErrorData,
1516
TextContent,
1617
)
18+
from pydantic import Field
1719

1820
from docs_src.troubleshooting import (
1921
tutorial001,
@@ -30,6 +32,7 @@
3032
from mcp.client.streamable_http import streamable_http_client
3133
from mcp.server import MCPServer
3234
from mcp.server.mcpserver import RequestStateSecurity
35+
from mcp.server.mcpserver.exceptions import InvalidSignature
3336

3437
# See test_index.py for why this is a per-module mark and not a conftest hook.
3538
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@@ -124,6 +127,22 @@ def forecast(city: str) -> None:
124127
"""Today's forecast for one city. Never called: the decoration itself is what raises."""
125128

126129

130+
async def test_an_invalid_x_mcp_header_annotation_raises_at_import_time() -> None:
131+
"""A marked `str | None` is refused by the decorator itself, with the heading's text and the reason after it."""
132+
mcp = MCPServer("Weather")
133+
with pytest.raises(InvalidSignature) as excinfo:
134+
135+
@mcp.tool()
136+
def forecast(city: Annotated[str | None, Field(json_schema_extra={"x-mcp-header": "City"})] = None) -> None:
137+
"""Today's forecast for one city. Never called: the decoration itself is what raises."""
138+
139+
assert str(excinfo.value) == snapshot(
140+
"Tool 'forecast' has an invalid x-mcp-header annotation: "
141+
"property 'city': x-mcp-header is only permitted on integer/string/boolean properties "
142+
"(the type keyword is NoneType, not a string)"
143+
)
144+
145+
127146
async def test_a_duplicate_tool_name_keeps_the_first_and_drops_the_second() -> None:
128147
"""tutorial002: `tools/list` reports one `forecast`, and it is the first registration that won."""
129148
async with Client(tutorial002.mcp) as client:

0 commit comments

Comments
 (0)