Skip to content

Commit 070ef1c

Browse files
committed
Fix BaseModel method name conflicts in func_metadata
When function parameters have names that conflict with BaseModel methods (e.g., 'model_dump', 'dict', 'json'), Pydantic v2 warns about shadowing parent attributes. This fix uses aliases to map conflicting parameter names to prefixed internal fields while preserving the original names in the external API. - Dynamically detect BaseModel method conflicts using hasattr/callable - Apply aliases only to conflicting parameters - Update model_dump_one_level and pre_parse_json to handle aliases - Add by_alias=True to schema generation for correct parameter names - Add tests for functions with reserved parameter names
1 parent 0a4e8d4 commit 070ef1c

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
@@ -884,3 +884,115 @@ def func_with_aliases() -> ModelWithAliases:
884884
assert "field_second" not in structured_content_defaults
885885
assert structured_content_defaults["first"] is None
886886
assert structured_content_defaults["second"] is None
887+
888+
889+
def test_basemodel_reserved_names():
890+
"""Test that functions with parameters named after BaseModel methods work correctly"""
891+
892+
def func_with_reserved_names(
893+
model_dump: str,
894+
model_validate: int,
895+
dict: list[str],
896+
json: dict[str, Any],
897+
validate: bool,
898+
copy: float,
899+
normal_param: str,
900+
) -> str:
901+
return f"{model_dump}, {model_validate}, {dict}, {json}, {validate}, {copy}, {normal_param}"
902+
903+
meta = func_metadata(func_with_reserved_names)
904+
905+
# Check that the schema has all the original parameter names (using aliases)
906+
schema = meta.arg_model.model_json_schema(by_alias=True)
907+
assert "model_dump" in schema["properties"]
908+
assert "model_validate" in schema["properties"]
909+
assert "dict" in schema["properties"]
910+
assert "json" in schema["properties"]
911+
assert "validate" in schema["properties"]
912+
assert "copy" in schema["properties"]
913+
assert "normal_param" in schema["properties"]
914+
915+
916+
@pytest.mark.anyio
917+
async def test_basemodel_reserved_names_validation():
918+
"""Test that validation and calling works with reserved parameter names"""
919+
920+
def func_with_reserved_names(
921+
model_dump: str,
922+
model_validate: int,
923+
dict: list[str],
924+
json: dict[str, Any],
925+
validate: bool,
926+
normal_param: str,
927+
) -> str:
928+
return f"{model_dump}|{model_validate}|{len(dict)}|{json}|{validate}|{normal_param}"
929+
930+
meta = func_metadata(func_with_reserved_names)
931+
932+
# Test validation with reserved names
933+
result = await meta.call_fn_with_arg_validation(
934+
func_with_reserved_names,
935+
fn_is_async=False,
936+
arguments_to_validate={
937+
"model_dump": "test_dump",
938+
"model_validate": 42,
939+
"dict": ["a", "b", "c"],
940+
"json": {"key": "value"},
941+
"validate": True,
942+
"normal_param": "normal",
943+
},
944+
arguments_to_pass_directly=None,
945+
)
946+
947+
assert result == "test_dump|42|3|{'key': 'value'}|True|normal"
948+
949+
# Test that the model can still call its own methods
950+
model_instance = meta.arg_model.model_validate(
951+
{
952+
"model_dump": "dump_value",
953+
"model_validate": 123,
954+
"dict": ["x", "y"],
955+
"json": {"foo": "bar"},
956+
"validate": False,
957+
"normal_param": "test",
958+
}
959+
)
960+
961+
# The model should still have its methods accessible
962+
assert hasattr(model_instance, "model_dump")
963+
assert callable(model_instance.model_dump)
964+
965+
# model_dump_one_level should return the original parameter names
966+
dumped = model_instance.model_dump_one_level()
967+
assert dumped["model_dump"] == "dump_value"
968+
assert dumped["model_validate"] == 123
969+
assert dumped["dict"] == ["x", "y"]
970+
assert dumped["json"] == {"foo": "bar"}
971+
assert dumped["validate"] is False
972+
assert dumped["normal_param"] == "test"
973+
974+
975+
def test_basemodel_reserved_names_with_json_preparsing():
976+
"""Test that pre_parse_json works correctly with reserved parameter names"""
977+
978+
def func_with_reserved_json(
979+
json: dict[str, Any],
980+
model_dump: list[int],
981+
normal: str,
982+
) -> str:
983+
return "ok"
984+
985+
meta = func_metadata(func_with_reserved_json)
986+
987+
# Test pre-parsing with reserved names
988+
result = meta.pre_parse_json(
989+
{
990+
"json": '{"nested": "data"}', # JSON string that should be parsed
991+
"model_dump": "[1, 2, 3]", # JSON string that should be parsed
992+
"normal": "plain string", # Should remain as string
993+
}
994+
)
995+
996+
assert result["json"] == {"nested": "data"}
997+
assert result["model_dump"] == [1, 2, 3]
998+
assert result["normal"] == "plain string"

0 commit comments

Comments
 (0)