diff --git a/src/mcp/server/fastmcp/utilities/func_metadata.py b/src/mcp/server/fastmcp/utilities/func_metadata.py index a6f905ee5b..a8f9652def 100644 --- a/src/mcp/server/fastmcp/utilities/func_metadata.py +++ b/src/mcp/server/fastmcp/utilities/func_metadata.py @@ -111,7 +111,7 @@ def convert_result(self, result: Any) -> Any: assert self.output_model is not None, "Output model must be set if output schema is defined" validated = self.output_model.model_validate(result) - structured_content = validated.model_dump(mode="json") + structured_content = validated.model_dump(mode="json", by_alias=True) return (unstructured_content, structured_content) diff --git a/tests/server/fastmcp/test_func_metadata.py b/tests/server/fastmcp/test_func_metadata.py index a520975301..8405099069 100644 --- a/tests/server/fastmcp/test_func_metadata.py +++ b/tests/server/fastmcp/test_func_metadata.py @@ -839,3 +839,48 @@ def func_returning_namedtuple() -> Point: func_metadata(func_returning_namedtuple, structured_output=True) assert "is not serializable for structured output" in str(exc_info.value) assert "Point" in str(exc_info.value) + + +def test_structured_output_aliases(): + """Test that field aliases are consistent between schema and output""" + + class ModelWithAliases(BaseModel): + field_first: str | None = Field(default=None, alias="first", description="The first field.") + field_second: str | None = Field(default=None, alias="second", description="The second field.") + + def func_with_aliases() -> ModelWithAliases: + # When aliases are defined, we must use the aliased names to set values + return ModelWithAliases(**{"first": "hello", "second": "world"}) + + meta = func_metadata(func_with_aliases) + + # Check that schema uses aliases + assert meta.output_schema is not None + assert "first" in meta.output_schema["properties"] + assert "second" in meta.output_schema["properties"] + assert "field_first" not in meta.output_schema["properties"] + assert "field_second" not in meta.output_schema["properties"] + + # Check that the actual output uses aliases too + result = ModelWithAliases(**{"first": "hello", "second": "world"}) + unstructured_content, structured_content = meta.convert_result(result) + + # The structured content should use aliases to match the schema + assert "first" in structured_content + assert "second" in structured_content + assert "field_first" not in structured_content + assert "field_second" not in structured_content + assert structured_content["first"] == "hello" + assert structured_content["second"] == "world" + + # Also test the case where we have a model with defaults to ensure aliases work in all cases + result_with_defaults = ModelWithAliases() # Uses default None values + unstructured_content_defaults, structured_content_defaults = meta.convert_result(result_with_defaults) + + # Even with defaults, should use aliases in output + assert "first" in structured_content_defaults + assert "second" in structured_content_defaults + assert "field_first" not in structured_content_defaults + assert "field_second" not in structured_content_defaults + assert structured_content_defaults["first"] is None + assert structured_content_defaults["second"] is None