Skip to content

Commit 6b00946

Browse files
committed
Reject a tool with an invalid x-mcp-header annotation at registration
`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
1 parent c8fcfc8 commit 6b00946

2 files changed

Lines changed: 82 additions & 0 deletions

File tree

‎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.

‎tests/server/mcpserver/tools/test_base.py‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
from collections.abc import Callable
2+
from typing import Annotated
3+
14
import mcp_types as types
25
import pytest
6+
from pydantic import Field
37

48
from mcp import Client
59
from mcp.server.mcpserver import Context, MCPServer
10+
from mcp.server.mcpserver.exceptions import InvalidSignature
611
from mcp.server.mcpserver.tools.base import Tool
712
from mcp.shared.exceptions import MCPError
813

@@ -55,3 +60,77 @@ async def boom() -> str:
5560

5661
assert isinstance(result, types.CallToolResult)
5762
assert result.is_error is True
63+
64+
65+
def array_header(tags: Annotated[list[str], Field(json_schema_extra={"x-mcp-header": "Tags"})]) -> str:
66+
raise NotImplementedError
67+
68+
69+
def number_header(ratio: Annotated[float, Field(json_schema_extra={"x-mcp-header": "Ratio"})]) -> str:
70+
raise NotImplementedError
71+
72+
73+
def optional_header(region: Annotated[str | None, Field(json_schema_extra={"x-mcp-header": "Region"})] = None) -> str:
74+
raise NotImplementedError
75+
76+
77+
def non_token_header(region: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Region Name"})]) -> str:
78+
raise NotImplementedError
79+
80+
81+
@pytest.mark.anyio
82+
@pytest.mark.parametrize(
83+
("fn", "reason"),
84+
[
85+
pytest.param(
86+
array_header,
87+
"property 'tags': x-mcp-header is only permitted on integer/string/boolean properties (got 'array')",
88+
id="array",
89+
),
90+
pytest.param(
91+
number_header,
92+
"property 'ratio': x-mcp-header is only permitted on integer/string/boolean properties (got 'number')",
93+
id="number",
94+
),
95+
pytest.param(
96+
optional_header,
97+
"property 'region': x-mcp-header is only permitted on integer/string/boolean properties "
98+
"(the type keyword is NoneType, not a string)",
99+
id="optional",
100+
),
101+
pytest.param(
102+
non_token_header,
103+
"property 'region': x-mcp-header 'Region Name' is not an RFC 9110 token",
104+
id="non-token-name",
105+
),
106+
],
107+
)
108+
async def test_tool_with_an_invalid_x_mcp_header_annotation_is_rejected_at_registration(
109+
fn: Callable[..., str], reason: str
110+
):
111+
"""SDK-defined: the spec has 2026-07-28 clients exclude such a tool, so registration
112+
refuses it with an error naming the tool and the reason, and nothing is registered."""
113+
mcp = MCPServer(name="srv")
114+
115+
with pytest.raises(InvalidSignature) as exc_info:
116+
mcp.add_tool(fn)
117+
118+
assert str(exc_info.value) == f"Tool {fn.__name__!r} has an invalid x-mcp-header annotation: {reason}"
119+
assert await mcp.list_tools() == []
120+
121+
122+
@pytest.mark.anyio
123+
async def test_tool_with_valid_x_mcp_header_annotations_is_registered():
124+
"""SDK-defined: string, integer and boolean parameters may carry `x-mcp-header`,
125+
so a tool that annotates one of each registers."""
126+
mcp = MCPServer(name="srv")
127+
128+
@mcp.tool()
129+
def fetch(
130+
region: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Region"})],
131+
shard: Annotated[int, Field(json_schema_extra={"x-mcp-header": "Shard"})],
132+
dry_run: Annotated[bool, Field(json_schema_extra={"x-mcp-header": "Dry-Run"})],
133+
) -> str:
134+
raise NotImplementedError
135+
136+
assert [tool.name for tool in await mcp.list_tools()] == ["fetch"]

0 commit comments

Comments
 (0)