Skip to content
Closed
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
4 changes: 1 addition & 3 deletions src/mcp/server/mcpserver/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 10 additions & 5 deletions src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 '_'")
Expand All @@ -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))],
Expand All @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions tests/server/mcpserver/tools/test_base.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"))
Loading