Skip to content

Commit d71bee8

Browse files
committed
fix(server): forward tool args under the real parameter name, not the schema alias
a tool parameter with an explicit alias, e.g. city: Annotated[str, Field(alias="location")], is advertised in the input schema as "location" and clients send "location". at call time model_dump_one_level forwarded the value under the alias, so the function was invoked as fn(location=...) and raised TypeError: got an unexpected keyword argument 'location'. track each field's real python parameter name at metadata-build time and forward arguments under it. the internal alias used to dodge BaseModel attribute shadowing (field "field_schema" -> param "schema") is preserved because its real name is recorded the same way. the resolver arg-name matching in tools/base.py reads the same map so by-name resolvers keep resolving.
1 parent d2290ca commit d71bee8

3 files changed

Lines changed: 42 additions & 9 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,9 @@ 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+
# Match `model_dump_one_level`'s kwarg keys (the real parameter names) so a
109+
# by-name resolver param resolves to a key that exists at call time.
110+
tool_arg_names = set(func_arg_metadata.arg_model.param_names.values())
111111
resolver_plans = build_resolver_plans(resolved_params, tool_arg_names)
112112

113113
return cls(

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

Lines changed: 15 additions & 6 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,22 @@ 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+
# Maps each model field name to the real Python parameter name to forward it under.
100+
# They differ for aliased parameters: our internal shadow-rename (field "field_schema"
101+
# -> param "schema") and a user Field(alias=...) (field "city" stays the param name,
102+
# the alias is only the wire name). field_info.alias alone can't tell the two apart.
103+
param_names: ClassVar[dict[str, str]] = {}
104+
99105
def model_dump_one_level(self) -> dict[str, Any]:
100106
"""Return a dict of the model's fields, one level deep.
101107
102108
That is, sub-models etc are not dumped - they are kept as Pydantic models.
103109
"""
104110
kwargs: dict[str, Any] = {}
105-
for field_name, field_info in self.__class__.model_fields.items():
106-
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
111+
for field_name in self.__class__.model_fields:
112+
# Forward under the real parameter name, not the schema alias - the alias is
113+
# a wire name and isn't necessarily a valid kwarg for the underlying function.
114+
kwargs[self.param_names.get(field_name, field_name)] = getattr(self, field_name)
110115
return kwargs
111116

112117
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -326,6 +331,7 @@ def func_metadata(
326331
raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e
327332
params = sig.parameters
328333
dynamic_pydantic_model_params: dict[str, Any] = {}
334+
param_names: dict[str, str] = {}
329335
for param in params.values():
330336
if param.name.startswith("_"): # pragma: no cover
331337
raise InvalidSignature(f"Parameter {param.name} of {func.__name__} cannot start with '_'")
@@ -347,6 +353,8 @@ def func_metadata(
347353
# Use a prefixed field name
348354
field_name = f"field_{field_name}"
349355

356+
param_names[field_name] = param.name
357+
350358
if param.default is not inspect.Parameter.empty:
351359
dynamic_pydantic_model_params[field_name] = (
352360
Annotated[(annotation, *field_metadata, Field(**field_kwargs))],
@@ -360,6 +368,7 @@ def func_metadata(
360368
__base__=ArgModelBase,
361369
**dynamic_pydantic_model_params,
362370
)
371+
arguments_model.param_names = param_names
363372

364373
if structured_output is False:
365374
return FuncMetadata(arg_model=arguments_model)

tests/server/mcpserver/test_func_metadata.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1410,6 +1410,30 @@ def func_with_reserved_names(
14101410
assert dumped["normal_param"] == "test"
14111411

14121412

1413+
@pytest.mark.anyio
1414+
async def test_call_with_aliased_parameter():
1415+
"""A parameter with an explicit Field alias is advertised under the alias in the
1416+
input schema but forwarded to the function under its real parameter name."""
1417+
1418+
def func_with_aliased_param(city: Annotated[str, Field(alias="location")]) -> str:
1419+
return f"weather in {city}"
1420+
1421+
meta = func_metadata(func_with_aliased_param)
1422+
1423+
# The input schema advertises the alias, not the parameter name.
1424+
schema = meta.arg_model.model_json_schema(by_alias=True)
1425+
assert "location" in schema["properties"]
1426+
assert "city" not in schema["properties"]
1427+
1428+
# A client sends the alias; the function must still be called with `city=`.
1429+
result = await meta.call_fn(
1430+
func_with_aliased_param,
1431+
fn_is_async=False,
1432+
arguments=meta.validate_arguments({"location": "Paris"}),
1433+
)
1434+
assert result == "weather in Paris"
1435+
1436+
14131437
def test_basemodel_reserved_names_with_json_preparsing():
14141438
"""Test that pre_parse_json works correctly with reserved parameter names"""
14151439

0 commit comments

Comments
 (0)