diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index 4a8bed792e..fff5071168 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -105,9 +105,7 @@ def from_function( ) parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True) - # Match `model_dump_one_level`'s kwarg keys (alias when present, else field name) - # so a by-name resolver param resolves to a key that exists at call time. - tool_arg_names = {field.alias or name for name, field in func_arg_metadata.arg_model.model_fields.items()} + tool_arg_names = set(func_arg_metadata.arg_model.param_names.values()) resolver_plans = build_resolver_plans(resolved_params, tool_arg_names) return cls( diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index cc32433568..360bdb9352 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable, Sequence from itertools import chain from types import GenericAlias -from typing import Annotated, Any, Union, cast, get_args, get_origin +from typing import Annotated, Any, ClassVar, Union, cast, get_args, get_origin import anyio import anyio.to_thread @@ -96,17 +96,18 @@ def _inline_root_ref(schema: dict[str, Any]) -> dict[str, Any]: class ArgModelBase(BaseModel): """A model representing the arguments to a function.""" + param_names: ClassVar[dict[str, str]] = {} + def model_dump_one_level(self) -> dict[str, Any]: """Return a dict of the model's fields, one level deep. That is, sub-models etc are not dumped - they are kept as Pydantic models. """ + param_names = self.__class__.param_names kwargs: dict[str, Any] = {} - for field_name, field_info in self.__class__.model_fields.items(): + for field_name in self.__class__.model_fields: value = getattr(self, field_name) - # Use the alias if it exists, otherwise use the field name - output_name = field_info.alias if field_info.alias else field_name - kwargs[output_name] = value + kwargs[param_names.get(field_name, field_name)] = value return kwargs model_config = ConfigDict(arbitrary_types_allowed=True) @@ -326,6 +327,7 @@ def func_metadata( raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e params = sig.parameters dynamic_pydantic_model_params: dict[str, Any] = {} + param_name_map: dict[str, str] = {} for param in params.values(): if param.name.startswith("_"): # pragma: no cover raise InvalidSignature(f"Parameter {param.name} of {func.__name__} cannot start with '_'") @@ -347,6 +349,8 @@ def func_metadata( # Use a prefixed field name field_name = f"field_{field_name}" + param_name_map[field_name] = param.name + if param.default is not inspect.Parameter.empty: dynamic_pydantic_model_params[field_name] = ( Annotated[(annotation, *field_metadata, Field(**field_kwargs))], @@ -360,6 +364,7 @@ def func_metadata( __base__=ArgModelBase, **dynamic_pydantic_model_params, ) + arguments_model.param_names = param_name_map if structured_output is False: return FuncMetadata(arg_model=arguments_model) diff --git a/tests/server/mcpserver/tools/test_base.py b/tests/server/mcpserver/tools/test_base.py index 0cb583028d..4109b638e0 100644 --- a/tests/server/mcpserver/tools/test_base.py +++ b/tests/server/mcpserver/tools/test_base.py @@ -1,5 +1,8 @@ +from typing import Annotated + import mcp_types as types import pytest +from pydantic import Field from mcp import Client from mcp.server.mcpserver import Context, MCPServer @@ -55,3 +58,31 @@ async def boom() -> str: assert isinstance(result, types.CallToolResult) assert result.is_error is True + + +@pytest.mark.anyio +async def test_field_alias_maps_wire_name_back_to_python_parameter(): + """Regression: a Field(alias=...) publishes the alias in the JSON schema + but the validated wire input must be forwarded under the Python parameter + name so the function receives it as a keyword argument it declares.""" + + AliasInt = Annotated[int, Field(alias="externalX", ge=1)] + + mcp = MCPServer(name="srv") + + @mcp.tool() + async def echo(x: AliasInt) -> int: + return x + + tool_list = list(mcp._tool_manager._tools.values()) + assert len(tool_list) == 1 + schema = tool_list[0].parameters + assert "externalX" in schema.get("properties", {}), "schema must use alias" + assert "x" not in schema.get("properties", {}), "schema must not expose Python name" + + async with Client(mcp) as client: + result = await client.call_tool("echo", {"externalX": 42}) + + assert isinstance(result, types.CallToolResult) + assert result.is_error is not True + assert any(block.text == "42" for block in result.content if hasattr(block, "text"))