diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index be4afb4e9..12ce64102 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 f8c02c973..cb2fd50ae 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 af5e69249..7f390ee9c 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 617d113b2..06c79b11a 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 6dba9277e..35de4cc72 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 11989850a..dd430cce2 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 ec6e98d7d..b3585508b 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 36b0670d0..b617e8545 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 c4051794f..7824ecb64 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 62a9612b9..532f9d137 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,16 @@ 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: ... # pragma: no branch + + 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