Skip to content

Commit 5b4a5e6

Browse files
fix: forbid extra arguments in FastMCP tool arg models (#3067)
1 parent 53117cb commit 5b4a5e6

10 files changed

Lines changed: 31 additions & 6 deletions

File tree

src/mcp/server/mcpserver/utilities/func_metadata.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def model_dump_one_level(self) -> dict[str, Any]:
6060
kwargs[output_name] = value
6161
return kwargs
6262

63-
model_config = ConfigDict(arbitrary_types_allowed=True)
63+
model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
6464

6565

6666
class FuncMetadata(BaseModel):

tests/client/test_client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ async def test_client_list_tools(app: MCPServer):
156156
description="Greet someone by name.",
157157
input_schema={
158158
"properties": {"name": {"title": "Name", "type": "string"}},
159+
"additionalProperties": False,
159160
"required": ["name"],
160161
"title": "greetArguments",
161162
"type": "object",

tests/docs_src/test_client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ async def test_list_tools_returns_the_full_definition() -> None:
5353
assert tool.input_schema == snapshot(
5454
{
5555
"type": "object",
56+
"additionalProperties": False,
5657
"properties": {
5758
"query": {"title": "Query", "type": "string"},
5859
"limit": {"default": 10, "title": "Limit", "type": "integer"},

tests/docs_src/test_context.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ async def test_the_context_parameter_is_not_in_the_input_schema() -> None:
2020
assert tool.input_schema == snapshot(
2121
{
2222
"type": "object",
23+
"additionalProperties": False,
2324
"properties": {"query": {"title": "Query", "type": "string"}},
2425
"required": ["query"],
2526
"title": "search_booksArguments",
@@ -56,7 +57,7 @@ async def test_a_context_only_tool_takes_no_arguments() -> None:
5657
async with Client(tutorial002.mcp) as client:
5758
tools = {tool.name: tool for tool in (await client.list_tools()).tools}
5859
assert tools["describe_catalog"].input_schema == snapshot(
59-
{"type": "object", "properties": {}, "title": "describe_catalogArguments"}
60+
{"type": "object", "additionalProperties": False, "properties": {}, "title": "describe_catalogArguments"}
6061
)
6162

6263

tests/docs_src/test_dependencies.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,23 @@ async def test_the_resolved_parameter_is_invisible_to_the_model() -> None:
3131
assert tool.input_schema == snapshot(
3232
{
3333
"type": "object",
34+
"additionalProperties": False,
3435
"properties": {"title": {"title": "Title", "type": "string"}},
3536
"required": ["title"],
3637
"title": "reserve_bookArguments",
3738
}
3839
)
3940

4041

41-
async def test_a_client_supplied_value_for_a_resolved_parameter_is_ignored() -> None:
42-
"""tutorial001: the resolver's value is the only one the tool can receive."""
42+
async def test_a_client_supplied_value_for_a_resolved_parameter_is_rejected() -> None:
43+
"""tutorial001: resolved parameters are not in the schema, so extra arguments fail validation."""
4344
async with Client(tutorial001.mcp) as client:
4445
result = await client.call_tool("reserve_book", {"title": "Dune", "stock": {"title": "Dune", "copies": 999}})
4546

46-
assert result.content == [TextContent(type="text", text="Reserved 'Dune' (6 copies left).")]
47+
assert result.is_error
48+
assert isinstance(result.content[0], TextContent)
49+
# pydantic's "Extra inputs are not permitted" wording changes across versions.
50+
assert result.content[0].text.startswith("Error executing tool reserve_book:")
4751

4852

4953
async def test_a_resolver_can_depend_on_another_resolver() -> None:

tests/docs_src/test_first_steps.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ async def test_each_decorator_registers_one_primitive() -> None:
2626
assert tool.input_schema == snapshot(
2727
{
2828
"type": "object",
29+
"additionalProperties": False,
2930
"properties": {
3031
"a": {"title": "A", "type": "integer"},
3132
"b": {"title": "B", "type": "integer"},

tests/docs_src/test_lifespan.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ async def test_context_parameter_never_reaches_the_input_schema() -> None:
2929
assert tool.input_schema == snapshot(
3030
{
3131
"type": "object",
32+
"additionalProperties": False,
3233
"properties": {"genre": {"title": "Genre", "type": "string"}},
3334
"required": ["genre"],
3435
"title": "count_booksArguments",

tests/docs_src/test_real_host.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ async def test_the_host_sees_exactly_what_the_decorators_registered() -> None:
2020
assert search.input_schema == snapshot(
2121
{
2222
"type": "object",
23+
"additionalProperties": False,
2324
"properties": {"query": {"title": "Query", "type": "string"}},
2425
"required": ["query"],
2526
"title": "search_booksArguments",

tests/docs_src/test_tools.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ async def test_signature_becomes_the_schema() -> None:
2020
assert tool.input_schema == snapshot(
2121
{
2222
"type": "object",
23+
"additionalProperties": False,
2324
"properties": {
2425
"query": {"title": "Query", "type": "string"},
2526
"limit": {"title": "Limit", "type": "integer"},
@@ -49,6 +50,7 @@ async def test_default_value_makes_the_argument_optional() -> None:
4950
assert tool.input_schema == snapshot(
5051
{
5152
"type": "object",
53+
"additionalProperties": False,
5254
"properties": {
5355
"query": {"title": "Query", "type": "string"},
5456
"limit": {"default": 10, "title": "Limit", "type": "integer"},

tests/server/mcpserver/test_func_metadata.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import pytest
1212
from dirty_equals import IsPartialDict
1313
from mcp_types import CallToolResult, InputRequiredResult
14-
from pydantic import BaseModel, Field
14+
from pydantic import BaseModel, Field, ValidationError
1515

1616
from mcp.server.mcpserver.exceptions import InvalidSignature
1717
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
@@ -275,6 +275,7 @@ async def test_lambda_function():
275275

276276
# Test schema
277277
assert meta.arg_model.model_json_schema() == {
278+
"additionalProperties": False,
278279
"properties": {
279280
"x": {"title": "x", "type": "string"},
280281
"y": {"default": 5, "title": "y", "type": "string"},
@@ -464,6 +465,7 @@ def test_complex_function_json_schema():
464465
"my_model_a_forward_ref",
465466
"my_model_b",
466467
],
468+
"additionalProperties": False,
467469
"title": "complex_arguments_fnArguments",
468470
"type": "object",
469471
}
@@ -1297,6 +1299,17 @@ def fn() -> CallToolResult | str | InputRequiredResult: ... # pragma: no branch
12971299
func_metadata(fn)
12981300

12991301

1302+
def test_unknown_argument_raises_validation_error():
1303+
"""SDK-defined: a tools/call argument name not on the tool schema fails validation."""
1304+
1305+
def read_doc(topic: str = "") -> str:
1306+
return topic
1307+
1308+
meta = func_metadata(read_doc)
1309+
with pytest.raises(ValidationError):
1310+
meta.arg_model.model_validate({"path": "something"})
1311+
1312+
13001313
def test_union_of_only_input_required_subclasses_yields_no_output_schema():
13011314
class StepA(InputRequiredResult):
13021315
pass

0 commit comments

Comments
 (0)