Skip to content

Commit 8dffa07

Browse files
committed
fix(server): forward aliased field input under the Python parameter name
Field(alias=...) publishes the alias as the wire key (correct), but model_dump_one_level returns that alias as the kwargs key too. The function's actual parameter is the Python name, so fn(**kwargs) fails with "unexpected keyword argument". Track the original parameter name for each model field in ArgModelBase.param_names (populated during func_metadata) and use it in model_dump_one_level instead of the alias. The SDK-internal reserved-name aliases (field_model_dump -> model_dump) continue to work because param_names records the original inspect.Parameter.name, which is the alias in that case. Resolvers' tool_arg_names set now uses param_names too, so a by-name resolver parameter matches the function's parameter name rather than the wire alias. Fixes #3099
1 parent d2290ca commit 8dffa07

3 files changed

Lines changed: 42 additions & 8 deletions

File tree

src/mcp/server/mcpserver/tools/base.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,7 @@ def from_function(
105105
)
106106
parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True)
107107

108-
# Match `model_dump_one_level`'s kwarg keys (alias when present, else field name)
109-
# so a by-name resolver param resolves to a key that exists at call time.
110-
tool_arg_names = {field.alias or name for name, field in func_arg_metadata.arg_model.model_fields.items()}
108+
tool_arg_names = set(func_arg_metadata.arg_model.param_names.values())
111109
resolver_plans = build_resolver_plans(resolved_params, tool_arg_names)
112110

113111
return cls(

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from collections.abc import Awaitable, Callable, Sequence
66
from itertools import chain
77
from types import GenericAlias
8-
from typing import Annotated, Any, Union, cast, get_args, get_origin
8+
from typing import Annotated, Any, ClassVar, Union, cast, get_args, get_origin
99

1010
import anyio
1111
import anyio.to_thread
@@ -96,17 +96,18 @@ def _inline_root_ref(schema: dict[str, Any]) -> dict[str, Any]:
9696
class ArgModelBase(BaseModel):
9797
"""A model representing the arguments to a function."""
9898

99+
param_names: ClassVar[dict[str, str]] = {}
100+
99101
def model_dump_one_level(self) -> dict[str, Any]:
100102
"""Return a dict of the model's fields, one level deep.
101103
102104
That is, sub-models etc are not dumped - they are kept as Pydantic models.
103105
"""
106+
param_names = self.__class__.param_names
104107
kwargs: dict[str, Any] = {}
105-
for field_name, field_info in self.__class__.model_fields.items():
108+
for field_name in self.__class__.model_fields:
106109
value = getattr(self, field_name)
107-
# Use the alias if it exists, otherwise use the field name
108-
output_name = field_info.alias if field_info.alias else field_name
109-
kwargs[output_name] = value
110+
kwargs[param_names.get(field_name, field_name)] = value
110111
return kwargs
111112

112113
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -326,6 +327,7 @@ def func_metadata(
326327
raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e
327328
params = sig.parameters
328329
dynamic_pydantic_model_params: dict[str, Any] = {}
330+
param_name_map: dict[str, str] = {}
329331
for param in params.values():
330332
if param.name.startswith("_"): # pragma: no cover
331333
raise InvalidSignature(f"Parameter {param.name} of {func.__name__} cannot start with '_'")
@@ -347,6 +349,8 @@ def func_metadata(
347349
# Use a prefixed field name
348350
field_name = f"field_{field_name}"
349351

352+
param_name_map[field_name] = param.name
353+
350354
if param.default is not inspect.Parameter.empty:
351355
dynamic_pydantic_model_params[field_name] = (
352356
Annotated[(annotation, *field_metadata, Field(**field_kwargs))],
@@ -360,6 +364,7 @@ def func_metadata(
360364
__base__=ArgModelBase,
361365
**dynamic_pydantic_model_params,
362366
)
367+
arguments_model.param_names = param_name_map
363368

364369
if structured_output is False:
365370
return FuncMetadata(arg_model=arguments_model)

tests/server/mcpserver/tools/test_base.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
from typing import Annotated
2+
13
import mcp_types as types
24
import pytest
5+
from pydantic import Field
36

47
from mcp import Client
58
from mcp.server.mcpserver import Context, MCPServer
@@ -55,3 +58,31 @@ async def boom() -> str:
5558

5659
assert isinstance(result, types.CallToolResult)
5760
assert result.is_error is True
61+
62+
63+
@pytest.mark.anyio
64+
async def test_field_alias_maps_wire_name_back_to_python_parameter():
65+
"""Regression: a Field(alias=...) publishes the alias in the JSON schema
66+
but the validated wire input must be forwarded under the Python parameter
67+
name so the function receives it as a keyword argument it declares."""
68+
69+
AliasInt = Annotated[int, Field(alias="externalX", ge=1)]
70+
71+
mcp = MCPServer(name="srv")
72+
73+
@mcp.tool()
74+
async def echo(x: AliasInt) -> int:
75+
return x
76+
77+
tool_list = list(mcp._tool_manager._tools.values())
78+
assert len(tool_list) == 1
79+
schema = tool_list[0].parameters
80+
assert "externalX" in schema.get("properties", {}), "schema must use alias"
81+
assert "x" not in schema.get("properties", {}), "schema must not expose Python name"
82+
83+
async with Client(mcp) as client:
84+
result = await client.call_tool("echo", {"externalX": 42})
85+
86+
assert isinstance(result, types.CallToolResult)
87+
assert result.is_error is not True
88+
assert any(block.text == "42" for block in result.content if hasattr(block, "text"))

0 commit comments

Comments
 (0)