Skip to content

Commit 49991fd

Browse files
authored
Fix BaseModel method name conflicts in func_metadata (#1123)
1 parent d28a1a6 commit 49991fd

File tree

3 files changed

+147
-9
lines changed

3 files changed

+147
-9
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def from_function(
7373
skip_names=[context_kwarg] if context_kwarg is not None else [],
7474
structured_output=structured_output,
7575
)
76-
parameters = func_arg_metadata.arg_model.model_json_schema()
76+
parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True)
7777

7878
return cls(
7979
fn=fn,

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

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,11 @@ def model_dump_one_level(self) -> dict[str, Any]:
4747
That is, sub-models etc are not dumped - they are kept as pydantic models.
4848
"""
4949
kwargs: dict[str, Any] = {}
50-
for field_name in self.__class__.model_fields.keys():
51-
kwargs[field_name] = getattr(self, field_name)
50+
for field_name, field_info in self.__class__.model_fields.items():
51+
value = getattr(self, field_name)
52+
# Use the alias if it exists, otherwise use the field name
53+
output_name = field_info.alias if field_info.alias else field_name
54+
kwargs[output_name] = value
5255
return kwargs
5356

5457
model_config = ConfigDict(
@@ -127,20 +130,31 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
127130
dicts (JSON objects) as JSON strings, which can be pre-parsed here.
128131
"""
129132
new_data = data.copy() # Shallow copy
130-
for field_name in self.arg_model.model_fields.keys():
131-
if field_name not in data.keys():
133+
134+
# Build a mapping from input keys (including aliases) to field info
135+
key_to_field_info: dict[str, FieldInfo] = {}
136+
for field_name, field_info in self.arg_model.model_fields.items():
137+
# Map both the field name and its alias (if any) to the field info
138+
key_to_field_info[field_name] = field_info
139+
if field_info.alias:
140+
key_to_field_info[field_info.alias] = field_info
141+
142+
for data_key in data.keys():
143+
if data_key not in key_to_field_info:
132144
continue
133-
if isinstance(data[field_name], str) and self.arg_model.model_fields[field_name].annotation is not str:
145+
146+
field_info = key_to_field_info[data_key]
147+
if isinstance(data[data_key], str) and field_info.annotation is not str:
134148
try:
135-
pre_parsed = json.loads(data[field_name])
149+
pre_parsed = json.loads(data[data_key])
136150
except json.JSONDecodeError:
137151
continue # Not JSON - skip
138152
if isinstance(pre_parsed, str | int | float):
139153
# This is likely that the raw value is e.g. `"hello"` which we
140154
# Should really be parsed as '"hello"' in Python - but if we parse
141155
# it as JSON it'll turn into just 'hello'. So we skip it.
142156
continue
143-
new_data[field_name] = pre_parsed
157+
new_data[data_key] = pre_parsed
144158
assert new_data.keys() == data.keys()
145159
return new_data
146160

@@ -222,7 +236,19 @@ def func_metadata(
222236
_get_typed_annotation(annotation, globalns),
223237
param.default if param.default is not inspect.Parameter.empty else PydanticUndefined,
224238
)
225-
dynamic_pydantic_model_params[param.name] = (field_info.annotation, field_info)
239+
240+
# Check if the parameter name conflicts with BaseModel attributes
241+
# This is necessary because Pydantic warns about shadowing parent attributes
242+
if hasattr(BaseModel, param.name) and callable(getattr(BaseModel, param.name)):
243+
# Use an alias to avoid the shadowing warning
244+
field_info.alias = param.name
245+
field_info.validation_alias = param.name
246+
field_info.serialization_alias = param.name
247+
# Use a prefixed internal name
248+
internal_name = f"field_{param.name}"
249+
dynamic_pydantic_model_params[internal_name] = (field_info.annotation, field_info)
250+
else:
251+
dynamic_pydantic_model_params[param.name] = (field_info.annotation, field_info)
226252
continue
227253

228254
arguments_model = create_model(

tests/server/fastmcp/test_func_metadata.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,3 +976,115 @@ def func_with_aliases() -> ModelWithAliases:
976976
assert "field_second" not in structured_content_defaults
977977
assert structured_content_defaults["first"] is None
978978
assert structured_content_defaults["second"] is None
979+
980+
981+
def test_basemodel_reserved_names():
982+
"""Test that functions with parameters named after BaseModel methods work correctly"""
983+
984+
def func_with_reserved_names(
985+
model_dump: str,
986+
model_validate: int,
987+
dict: list[str],
988+
json: dict[str, Any],
989+
validate: bool,
990+
copy: float,
991+
normal_param: str,
992+
) -> str:
993+
return f"{model_dump}, {model_validate}, {dict}, {json}, {validate}, {copy}, {normal_param}"
994+
995+
meta = func_metadata(func_with_reserved_names)
996+
997+
# Check that the schema has all the original parameter names (using aliases)
998+
schema = meta.arg_model.model_json_schema(by_alias=True)
999+
assert "model_dump" in schema["properties"]
1000+
assert "model_validate" in schema["properties"]
1001+
assert "dict" in schema["properties"]
1002+
assert "json" in schema["properties"]
1003+
assert "validate" in schema["properties"]
1004+
assert "copy" in schema["properties"]
1005+
assert "normal_param" in schema["properties"]
1006+
1007+
1008+
@pytest.mark.anyio
1009+
async def test_basemodel_reserved_names_validation():
1010+
"""Test that validation and calling works with reserved parameter names"""
1011+
1012+
def func_with_reserved_names(
1013+
model_dump: str,
1014+
model_validate: int,
1015+
dict: list[str],
1016+
json: dict[str, Any],
1017+
validate: bool,
1018+
normal_param: str,
1019+
) -> str:
1020+
return f"{model_dump}|{model_validate}|{len(dict)}|{json}|{validate}|{normal_param}"
1021+
1022+
meta = func_metadata(func_with_reserved_names)
1023+
1024+
# Test validation with reserved names
1025+
result = await meta.call_fn_with_arg_validation(
1026+
func_with_reserved_names,
1027+
fn_is_async=False,
1028+
arguments_to_validate={
1029+
"model_dump": "test_dump",
1030+
"model_validate": 42,
1031+
"dict": ["a", "b", "c"],
1032+
"json": {"key": "value"},
1033+
"validate": True,
1034+
"normal_param": "normal",
1035+
},
1036+
arguments_to_pass_directly=None,
1037+
)
1038+
1039+
assert result == "test_dump|42|3|{'key': 'value'}|True|normal"
1040+
1041+
# Test that the model can still call its own methods
1042+
model_instance = meta.arg_model.model_validate(
1043+
{
1044+
"model_dump": "dump_value",
1045+
"model_validate": 123,
1046+
"dict": ["x", "y"],
1047+
"json": {"foo": "bar"},
1048+
"validate": False,
1049+
"normal_param": "test",
1050+
}
1051+
)
1052+
1053+
# The model should still have its methods accessible
1054+
assert hasattr(model_instance, "model_dump")
1055+
assert callable(model_instance.model_dump)
1056+
1057+
# model_dump_one_level should return the original parameter names
1058+
dumped = model_instance.model_dump_one_level()
1059+
assert dumped["model_dump"] == "dump_value"
1060+
assert dumped["model_validate"] == 123
1061+
assert dumped["dict"] == ["x", "y"]
1062+
assert dumped["json"] == {"foo": "bar"}
1063+
assert dumped["validate"] is False
1064+
assert dumped["normal_param"] == "test"
1065+
1066+
1067+
def test_basemodel_reserved_names_with_json_preparsing():
1068+
"""Test that pre_parse_json works correctly with reserved parameter names"""
1069+
1070+
def func_with_reserved_json(
1071+
json: dict[str, Any],
1072+
model_dump: list[int],
1073+
normal: str,
1074+
) -> str:
1075+
return "ok"
1076+
1077+
meta = func_metadata(func_with_reserved_json)
1078+
1079+
# Test pre-parsing with reserved names
1080+
result = meta.pre_parse_json(
1081+
{
1082+
"json": '{"nested": "data"}', # JSON string that should be parsed
1083+
"model_dump": "[1, 2, 3]", # JSON string that should be parsed
1084+
"normal": "plain string", # Should remain as string
1085+
}
1086+
)
1087+
1088+
assert result["json"] == {"nested": "data"}
1089+
assert result["model_dump"] == [1, 2, 3]
1090+
assert result["normal"] == "plain string"

0 commit comments

Comments
 (0)