Skip to content

Commit 6fed073

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 3a6f299 commit 6fed073

3 files changed

Lines changed: 43 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
@@ -99,9 +99,9 @@ def from_function(
9999
)
100100
parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True)
101101

102-
# Match `model_dump_one_level`'s kwarg keys (alias when present, else field name)
103-
# so a by-name resolver param resolves to a key that exists at call time.
104-
tool_arg_names = {field.alias or name for name, field in func_arg_metadata.arg_model.model_fields.items()}
102+
# Match `model_dump_one_level`'s kwarg keys (the real parameter names) so a
103+
# by-name resolver param resolves to a key that exists at call time.
104+
tool_arg_names = set(func_arg_metadata.arg_model.param_names.values())
105105
resolver_plans = build_resolver_plans(resolved_params, tool_arg_names)
106106

107107
return cls(

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

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from collections.abc import Awaitable, Callable, Sequence
55
from itertools import chain
66
from types import GenericAlias
7-
from typing import Annotated, Any, Union, cast, get_args, get_origin, get_type_hints
7+
from typing import Annotated, Any, ClassVar, Union, cast, get_args, get_origin, get_type_hints
88

99
import anyio
1010
import anyio.to_thread
@@ -47,17 +47,22 @@ def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None:
4747
class ArgModelBase(BaseModel):
4848
"""A model representing the arguments to a function."""
4949

50+
# Maps each model field name to the real Python parameter name to forward it under.
51+
# They differ for aliased parameters: our internal shadow-rename (field "field_schema"
52+
# -> param "schema") and a user Field(alias=...) (field "city" stays the param name,
53+
# the alias is only the wire name). field_info.alias alone can't tell the two apart.
54+
param_names: ClassVar[dict[str, str]] = {}
55+
5056
def model_dump_one_level(self) -> dict[str, Any]:
5157
"""Return a dict of the model's fields, one level deep.
5258
5359
That is, sub-models etc are not dumped - they are kept as Pydantic models.
5460
"""
5561
kwargs: dict[str, Any] = {}
56-
for field_name, field_info in self.__class__.model_fields.items():
57-
value = getattr(self, field_name)
58-
# Use the alias if it exists, otherwise use the field name
59-
output_name = field_info.alias if field_info.alias else field_name
60-
kwargs[output_name] = value
62+
for field_name in self.__class__.model_fields:
63+
# Forward under the real parameter name, not the schema alias - the alias is
64+
# a wire name and isn't necessarily a valid kwarg for the underlying function.
65+
kwargs[self.param_names.get(field_name, field_name)] = getattr(self, field_name)
6166
return kwargs
6267

6368
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -237,6 +242,7 @@ def func_metadata(
237242
raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e
238243
params = sig.parameters
239244
dynamic_pydantic_model_params: dict[str, Any] = {}
245+
param_names: dict[str, str] = {}
240246
for param in params.values():
241247
if param.name.startswith("_"): # pragma: no cover
242248
raise InvalidSignature(f"Parameter {param.name} of {func.__name__} cannot start with '_'")
@@ -258,6 +264,8 @@ def func_metadata(
258264
# Use a prefixed field name
259265
field_name = f"field_{field_name}"
260266

267+
param_names[field_name] = param.name
268+
261269
if param.default is not inspect.Parameter.empty:
262270
dynamic_pydantic_model_params[field_name] = (
263271
Annotated[(annotation, *field_metadata, Field(**field_kwargs))],
@@ -271,6 +279,7 @@ def func_metadata(
271279
__base__=ArgModelBase,
272280
**dynamic_pydantic_model_params,
273281
)
282+
arguments_model.param_names = param_names
274283

275284
if structured_output is False:
276285
return FuncMetadata(arg_model=arguments_model)

tests/server/mcpserver/test_func_metadata.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1198,6 +1198,31 @@ def func_with_reserved_names(
11981198
assert dumped["normal_param"] == "test"
11991199

12001200

1201+
@pytest.mark.anyio
1202+
async def test_call_with_aliased_parameter():
1203+
"""A parameter with an explicit Field alias is advertised under the alias in the
1204+
input schema but forwarded to the function under its real parameter name."""
1205+
1206+
def func_with_aliased_param(city: Annotated[str, Field(alias="location")]) -> str:
1207+
return f"weather in {city}"
1208+
1209+
meta = func_metadata(func_with_aliased_param)
1210+
1211+
# The input schema advertises the alias, not the parameter name.
1212+
schema = meta.arg_model.model_json_schema(by_alias=True)
1213+
assert "location" in schema["properties"]
1214+
assert "city" not in schema["properties"]
1215+
1216+
# A client sends the alias; the function must still be called with `city=`.
1217+
result = await meta.call_fn_with_arg_validation(
1218+
func_with_aliased_param,
1219+
fn_is_async=False,
1220+
arguments_to_validate={"location": "Paris"},
1221+
arguments_to_pass_directly=None,
1222+
)
1223+
assert result == "weather in Paris"
1224+
1225+
12011226
def test_basemodel_reserved_names_with_json_preparsing():
12021227
"""Test that pre_parse_json works correctly with reserved parameter names"""
12031228

0 commit comments

Comments
 (0)