Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/mcp/server/mcpserver/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,9 @@ 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()}
# Match `model_dump_one_level`'s kwarg keys (the real parameter names) so a
# by-name resolver param resolves to a key that exists at call time.
tool_arg_names = set(func_arg_metadata.arg_model.param_names.values())
resolver_plans = build_resolver_plans(resolved_params, tool_arg_names)

return cls(
Expand Down
21 changes: 15 additions & 6 deletions src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,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, get_type_hints
from typing import Annotated, Any, ClassVar, Union, cast, get_args, get_origin, get_type_hints

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

# Maps each model field name to the real Python parameter name to forward it under.
# They differ for aliased parameters: our internal shadow-rename (field "field_schema"
# -> param "schema") and a user Field(alias=...) (field "city" stays the param name,
# the alias is only the wire name). field_info.alias alone can't tell the two apart.
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.
"""
kwargs: dict[str, Any] = {}
for field_name, field_info in self.__class__.model_fields.items():
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
for field_name in self.__class__.model_fields:
# Forward under the real parameter name, not the schema alias - the alias is
# a wire name and isn't necessarily a valid kwarg for the underlying function.
kwargs[self.param_names.get(field_name, field_name)] = getattr(self, field_name)
return kwargs

model_config = ConfigDict(arbitrary_types_allowed=True)
Expand Down Expand Up @@ -237,6 +242,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_names: 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 '_'")
Expand All @@ -258,6 +264,8 @@ def func_metadata(
# Use a prefixed field name
field_name = f"field_{field_name}"

param_names[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))],
Expand All @@ -271,6 +279,7 @@ def func_metadata(
__base__=ArgModelBase,
**dynamic_pydantic_model_params,
)
arguments_model.param_names = param_names

if structured_output is False:
return FuncMetadata(arg_model=arguments_model)
Expand Down
25 changes: 25 additions & 0 deletions tests/server/mcpserver/test_func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -1198,6 +1198,31 @@ def func_with_reserved_names(
assert dumped["normal_param"] == "test"


@pytest.mark.anyio
async def test_call_with_aliased_parameter():
"""A parameter with an explicit Field alias is advertised under the alias in the
input schema but forwarded to the function under its real parameter name."""

def func_with_aliased_param(city: Annotated[str, Field(alias="location")]) -> str:
return f"weather in {city}"

meta = func_metadata(func_with_aliased_param)

# The input schema advertises the alias, not the parameter name.
schema = meta.arg_model.model_json_schema(by_alias=True)
assert "location" in schema["properties"]
assert "city" not in schema["properties"]

# A client sends the alias; the function must still be called with `city=`.
result = await meta.call_fn_with_arg_validation(
func_with_aliased_param,
fn_is_async=False,
arguments_to_validate={"location": "Paris"},
arguments_to_pass_directly=None,
)
assert result == "weather in Paris"


def test_basemodel_reserved_names_with_json_preparsing():
"""Test that pre_parse_json works correctly with reserved parameter names"""

Expand Down
Loading