From 5b4a5e69cd3352906f0439188a96f6795cd37628 Mon Sep 17 00:00:00 2001 From: elbachir-salik Date: Mon, 6 Jul 2026 17:32:01 +0100 Subject: [PATCH 1/3] fix: forbid extra arguments in FastMCP tool arg models (#3067) --- .../server/mcpserver/utilities/func_metadata.py | 2 +- tests/client/test_client.py | 1 + tests/docs_src/test_client.py | 1 + tests/docs_src/test_context.py | 3 ++- tests/docs_src/test_dependencies.py | 10 +++++++--- tests/docs_src/test_first_steps.py | 1 + tests/docs_src/test_lifespan.py | 1 + tests/docs_src/test_real_host.py | 1 + tests/docs_src/test_tools.py | 2 ++ tests/server/mcpserver/test_func_metadata.py | 15 ++++++++++++++- 10 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index be4afb4e9b..12ce641025 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -60,7 +60,7 @@ def model_dump_one_level(self) -> dict[str, Any]: kwargs[output_name] = value return kwargs - model_config = ConfigDict(arbitrary_types_allowed=True) + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") class FuncMetadata(BaseModel): diff --git a/tests/client/test_client.py b/tests/client/test_client.py index f8c02c9734..cb2fd50ae7 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -156,6 +156,7 @@ async def test_client_list_tools(app: MCPServer): description="Greet someone by name.", input_schema={ "properties": {"name": {"title": "Name", "type": "string"}}, + "additionalProperties": False, "required": ["name"], "title": "greetArguments", "type": "object", diff --git a/tests/docs_src/test_client.py b/tests/docs_src/test_client.py index af5e692491..7f390ee9ca 100644 --- a/tests/docs_src/test_client.py +++ b/tests/docs_src/test_client.py @@ -53,6 +53,7 @@ async def test_list_tools_returns_the_full_definition() -> None: assert tool.input_schema == snapshot( { "type": "object", + "additionalProperties": False, "properties": { "query": {"title": "Query", "type": "string"}, "limit": {"default": 10, "title": "Limit", "type": "integer"}, diff --git a/tests/docs_src/test_context.py b/tests/docs_src/test_context.py index 617d113b2b..06c79b11a6 100644 --- a/tests/docs_src/test_context.py +++ b/tests/docs_src/test_context.py @@ -20,6 +20,7 @@ async def test_the_context_parameter_is_not_in_the_input_schema() -> None: assert tool.input_schema == snapshot( { "type": "object", + "additionalProperties": False, "properties": {"query": {"title": "Query", "type": "string"}}, "required": ["query"], "title": "search_booksArguments", @@ -56,7 +57,7 @@ async def test_a_context_only_tool_takes_no_arguments() -> None: async with Client(tutorial002.mcp) as client: tools = {tool.name: tool for tool in (await client.list_tools()).tools} assert tools["describe_catalog"].input_schema == snapshot( - {"type": "object", "properties": {}, "title": "describe_catalogArguments"} + {"type": "object", "additionalProperties": False, "properties": {}, "title": "describe_catalogArguments"} ) diff --git a/tests/docs_src/test_dependencies.py b/tests/docs_src/test_dependencies.py index 6dba9277e4..35de4cc723 100644 --- a/tests/docs_src/test_dependencies.py +++ b/tests/docs_src/test_dependencies.py @@ -31,6 +31,7 @@ async def test_the_resolved_parameter_is_invisible_to_the_model() -> None: assert tool.input_schema == snapshot( { "type": "object", + "additionalProperties": False, "properties": {"title": {"title": "Title", "type": "string"}}, "required": ["title"], "title": "reserve_bookArguments", @@ -38,12 +39,15 @@ async def test_the_resolved_parameter_is_invisible_to_the_model() -> None: ) -async def test_a_client_supplied_value_for_a_resolved_parameter_is_ignored() -> None: - """tutorial001: the resolver's value is the only one the tool can receive.""" +async def test_a_client_supplied_value_for_a_resolved_parameter_is_rejected() -> None: + """tutorial001: resolved parameters are not in the schema, so extra arguments fail validation.""" async with Client(tutorial001.mcp) as client: result = await client.call_tool("reserve_book", {"title": "Dune", "stock": {"title": "Dune", "copies": 999}}) - assert result.content == [TextContent(type="text", text="Reserved 'Dune' (6 copies left).")] + assert result.is_error + assert isinstance(result.content[0], TextContent) + # pydantic's "Extra inputs are not permitted" wording changes across versions. + assert result.content[0].text.startswith("Error executing tool reserve_book:") async def test_a_resolver_can_depend_on_another_resolver() -> None: diff --git a/tests/docs_src/test_first_steps.py b/tests/docs_src/test_first_steps.py index 11989850a2..dd430cce27 100644 --- a/tests/docs_src/test_first_steps.py +++ b/tests/docs_src/test_first_steps.py @@ -26,6 +26,7 @@ async def test_each_decorator_registers_one_primitive() -> None: assert tool.input_schema == snapshot( { "type": "object", + "additionalProperties": False, "properties": { "a": {"title": "A", "type": "integer"}, "b": {"title": "B", "type": "integer"}, diff --git a/tests/docs_src/test_lifespan.py b/tests/docs_src/test_lifespan.py index ec6e98d7de..b3585508b1 100644 --- a/tests/docs_src/test_lifespan.py +++ b/tests/docs_src/test_lifespan.py @@ -29,6 +29,7 @@ async def test_context_parameter_never_reaches_the_input_schema() -> None: assert tool.input_schema == snapshot( { "type": "object", + "additionalProperties": False, "properties": {"genre": {"title": "Genre", "type": "string"}}, "required": ["genre"], "title": "count_booksArguments", diff --git a/tests/docs_src/test_real_host.py b/tests/docs_src/test_real_host.py index 36b0670d0d..b617e85457 100644 --- a/tests/docs_src/test_real_host.py +++ b/tests/docs_src/test_real_host.py @@ -20,6 +20,7 @@ async def test_the_host_sees_exactly_what_the_decorators_registered() -> None: assert search.input_schema == snapshot( { "type": "object", + "additionalProperties": False, "properties": {"query": {"title": "Query", "type": "string"}}, "required": ["query"], "title": "search_booksArguments", diff --git a/tests/docs_src/test_tools.py b/tests/docs_src/test_tools.py index c4051794f4..7824ecb64a 100644 --- a/tests/docs_src/test_tools.py +++ b/tests/docs_src/test_tools.py @@ -20,6 +20,7 @@ async def test_signature_becomes_the_schema() -> None: assert tool.input_schema == snapshot( { "type": "object", + "additionalProperties": False, "properties": { "query": {"title": "Query", "type": "string"}, "limit": {"title": "Limit", "type": "integer"}, @@ -49,6 +50,7 @@ async def test_default_value_makes_the_argument_optional() -> None: assert tool.input_schema == snapshot( { "type": "object", + "additionalProperties": False, "properties": { "query": {"title": "Query", "type": "string"}, "limit": {"default": 10, "title": "Limit", "type": "integer"}, diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 62a9612b95..7bd6826028 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -11,7 +11,7 @@ import pytest from dirty_equals import IsPartialDict from mcp_types import CallToolResult, InputRequiredResult -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ValidationError from mcp.server.mcpserver.exceptions import InvalidSignature from mcp.server.mcpserver.utilities.func_metadata import func_metadata @@ -275,6 +275,7 @@ async def test_lambda_function(): # Test schema assert meta.arg_model.model_json_schema() == { + "additionalProperties": False, "properties": { "x": {"title": "x", "type": "string"}, "y": {"default": 5, "title": "y", "type": "string"}, @@ -464,6 +465,7 @@ def test_complex_function_json_schema(): "my_model_a_forward_ref", "my_model_b", ], + "additionalProperties": False, "title": "complex_arguments_fnArguments", "type": "object", } @@ -1297,6 +1299,17 @@ def fn() -> CallToolResult | str | InputRequiredResult: ... # pragma: no branch func_metadata(fn) +def test_unknown_argument_raises_validation_error(): + """SDK-defined: a tools/call argument name not on the tool schema fails validation.""" + + def read_doc(topic: str = "") -> str: + return topic + + meta = func_metadata(read_doc) + with pytest.raises(ValidationError): + meta.arg_model.model_validate({"path": "something"}) + + def test_union_of_only_input_required_subclasses_yields_no_output_schema(): class StepA(InputRequiredResult): pass From fbb2e68c4c39d5f11c724fb038c462b77a34e579 Mon Sep 17 00:00:00 2001 From: elbachir-salik Date: Mon, 6 Jul 2026 17:40:42 +0100 Subject: [PATCH 2/3] refactor: update read_doc function to use ellipsis for no-op implementation in test_unknown_argument_raises_validation_error --- tests/server/mcpserver/test_func_metadata.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 7bd6826028..532f9d1374 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -1302,8 +1302,7 @@ def fn() -> CallToolResult | str | InputRequiredResult: ... # pragma: no branch def test_unknown_argument_raises_validation_error(): """SDK-defined: a tools/call argument name not on the tool schema fails validation.""" - def read_doc(topic: str = "") -> str: - return topic + def read_doc(topic: str = "") -> str: ... # pragma: no branch meta = func_metadata(read_doc) with pytest.raises(ValidationError): From 23dba52d528ff20ec860e245e7d6d1cdfcf05d9a Mon Sep 17 00:00:00 2001 From: elbachir-salik Date: Mon, 6 Jul 2026 17:53:53 +0100 Subject: [PATCH 3/3] ci: retry failed job