From bf47060e6ef08e140f6ce183f20d977c6ccd01ec Mon Sep 17 00:00:00 2001 From: Nik <84yk8btb9f@privaterelay.appleid.com> Date: Thu, 22 Oct 2026 13:35:26 +0300 Subject: [PATCH] fix(mcpserver): avoid collisions when prefixing reserved tool parameter names --- .../mcpserver/utilities/func_metadata.py | 15 +++-- tests/server/mcpserver/tools/test_base.py | 66 +++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 96d25ec5ab..0fe26eb61e 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -339,13 +339,18 @@ def func_metadata( if param.annotation is inspect.Parameter.empty: field_metadata.append(WithJsonSchema({"title": param.name, "type": "string"})) - # Check if the parameter name conflicts with BaseModel attributes - # This is necessary because Pydantic warns about shadowing parent attributes - if hasattr(BaseModel, field_name) and callable(getattr(BaseModel, field_name)): + # Check if the parameter name conflicts with ArgModelBase attributes + # This is necessary because Pydantic warns or errors about shadowing parent attributes + if hasattr(ArgModelBase, field_name): # Use an alias to avoid the shadowing warning field_kwargs["alias"] = field_name - # Use a prefixed field name - field_name = f"field_{field_name}" + # Use a prefixed field name that avoids collisions with other parameters + candidate = f"field_{field_name}" + counter = 1 + while candidate in params or candidate in dynamic_pydantic_model_params or hasattr(ArgModelBase, candidate): + candidate = f"field_{field_name}_{counter}" + counter += 1 + field_name = candidate if param.default is not inspect.Parameter.empty: dynamic_pydantic_model_params[field_name] = ( diff --git a/tests/server/mcpserver/tools/test_base.py b/tests/server/mcpserver/tools/test_base.py index 5ed47f51d5..84293d9ed2 100644 --- a/tests/server/mcpserver/tools/test_base.py +++ b/tests/server/mcpserver/tools/test_base.py @@ -147,3 +147,69 @@ def fetch( raise NotImplementedError assert [tool.name for tool in await mcp.list_tools()] == ["fetch"] + + +@pytest.mark.anyio +async def test_tool_reserved_parameter_does_not_collide_with_prefixed_field_name(): + from mcp_types import TextContent + + from mcp import Client + from mcp.server.mcpserver import MCPServer + + mcp = MCPServer(name="srv") + + @mcp.tool() + def inspect(model_dump: int, field_model_dump: int) -> str: + return f"seen:{model_dump}:{field_model_dump}" + + @mcp.tool() + def inspect_reversed(field_model_dump: int, model_dump: int) -> str: + return f"seen_rev:{field_model_dump}:{model_dump}" + + @mcp.tool() + def inspect_multiple_collisions(model_dump: int, field_model_dump: int, field_model_dump_1: int) -> str: + return f"seen_multi:{model_dump}:{field_model_dump}:{field_model_dump_1}" + + @mcp.tool() + def inspect_argmodel_reserved(model_dump_one_level: int, model_fields: str) -> str: + return f"seen_reserved:{model_dump_one_level}:{model_fields}" + + tools = await mcp.list_tools() + tools_by_name = {tool.name: tool for tool in tools} + + assert "model_dump" in tools_by_name["inspect"].input_schema["properties"] + assert "field_model_dump" in tools_by_name["inspect"].input_schema["properties"] + + assert "model_dump" in tools_by_name["inspect_reversed"].input_schema["properties"] + assert "field_model_dump" in tools_by_name["inspect_reversed"].input_schema["properties"] + + multi_props = tools_by_name["inspect_multiple_collisions"].input_schema["properties"] + assert {"model_dump", "field_model_dump", "field_model_dump_1"} <= multi_props.keys() + + reserved_props = tools_by_name["inspect_argmodel_reserved"].input_schema["properties"] + assert {"model_dump_one_level", "model_fields"} <= reserved_props.keys() + + async with Client(mcp) as client: + res1 = await client.call_tool( + "inspect", + arguments={"model_dump": 1, "field_model_dump": 2}, + ) + assert res1.content == [TextContent(type="text", text="seen:1:2")] + + res2 = await client.call_tool( + "inspect_reversed", + arguments={"field_model_dump": 2, "model_dump": 1}, + ) + assert res2.content == [TextContent(type="text", text="seen_rev:2:1")] + + res3 = await client.call_tool( + "inspect_multiple_collisions", + arguments={"model_dump": 1, "field_model_dump": 2, "field_model_dump_1": 3}, + ) + assert res3.content == [TextContent(type="text", text="seen_multi:1:2:3")] + + res4 = await client.call_tool( + "inspect_argmodel_reserved", + arguments={"model_dump_one_level": 42, "model_fields": "foo"}, + ) + assert res4.content == [TextContent(type="text", text="seen_reserved:42:foo")]