diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index be4afb4e9b..8cf21bfa77 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -126,7 +126,12 @@ def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult: if isinstance(result, CallToolResult): if self.output_schema is not None: assert self.output_model is not None, "Output model must be set if output schema is defined" - self.output_model.model_validate(result.structured_content) + validated = self.output_model.model_validate(result.structured_content) + # Normalize structured content the same way as ordinary return values, so + # computed fields and serialization aliases match the output schema. + return result.model_copy( + update={"structured_content": validated.model_dump(mode="json", by_alias=True)} + ) return result unstructured_content = _convert_to_content(result) @@ -445,7 +450,7 @@ def _try_create_model_and_schema( # If we successfully created a model, try to get its schema # Use StrictJsonSchema to raise exceptions instead of warnings try: - schema = model.model_json_schema(schema_generator=StrictJsonSchema) + schema = model.model_json_schema(schema_generator=StrictJsonSchema, mode="serialization") except ( PydanticUserError, TypeError, diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 62a9612b95..577642b962 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -11,7 +11,7 @@ import pytest from dirty_equals import IsPartialDict from mcp_types import CallToolResult, InputRequiredResult -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, computed_field from mcp.server.mcpserver.exceptions import InvalidSignature from mcp.server.mcpserver.utilities.func_metadata import func_metadata @@ -884,6 +884,26 @@ def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, Per assert isinstance(meta.convert_result(func_returning_annotated_tool_call_result()), CallToolResult) +def test_tool_call_result_annotated_structured_content_is_normalized(): + """A directly-returned CallToolResult has its structured_content normalized, so + serialization aliases match the declared output schema.""" + + class Person(BaseModel): + name: str = Field(serialization_alias="full_name") + + def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, Person]: + return CallToolResult(content=[], structured_content={"name": "Brandon"}) + + meta = func_metadata(func_returning_annotated_tool_call_result) + + assert meta.output_schema is not None + assert "full_name" in meta.output_schema["properties"] + + converted = meta.convert_result(func_returning_annotated_tool_call_result()) + assert isinstance(converted, CallToolResult) + assert converted.structured_content == {"full_name": "Brandon"} + + def test_tool_call_result_annotated_unioned_with_input_required_result_is_equivalent_to_the_bare_annotated_form(): """Stripping `InputRequiredResult` makes the residual behave exactly as if it were the declared return annotation, including the `Annotated[CallToolResult, Model]` special case @@ -1112,6 +1132,56 @@ def func_with_aliases() -> ModelWithAliases: # pragma: no cover assert structured_content_defaults["second"] is None +def test_structured_output_computed_field(): + """A computed field is in the serialized output, so it must appear in the schema.""" + + class ModelWithComputed(BaseModel): + value: int + + @computed_field + @property + def doubled(self) -> int: + return self.value * 2 + + def func_with_computed() -> ModelWithComputed: # pragma: no cover + return ModelWithComputed(value=3) + + meta = func_metadata(func_with_computed) + + assert meta.output_schema is not None + assert "doubled" in meta.output_schema["properties"] + + converted = meta.convert_result(ModelWithComputed(value=3)) + assert isinstance(converted, CallToolResult) + structured_content = converted.structured_content + assert structured_content is not None + assert structured_content == {"value": 3, "doubled": 6} + # Every serialized key must be advertised in the schema. + assert set(structured_content) <= set(meta.output_schema["properties"]) + + +def test_structured_output_serialization_alias(): + """A serialization_alias changes the dumped key, so the schema must use it too.""" + + class ModelWithSerializationAlias(BaseModel): + value: int = Field(serialization_alias="the_value") + + def func_with_serialization_alias() -> ModelWithSerializationAlias: # pragma: no cover + return ModelWithSerializationAlias(value=7) + + meta = func_metadata(func_with_serialization_alias) + + assert meta.output_schema is not None + assert "the_value" in meta.output_schema["properties"] + assert "value" not in meta.output_schema["properties"] + + converted = meta.convert_result(ModelWithSerializationAlias(value=7)) + assert isinstance(converted, CallToolResult) + structured_content = converted.structured_content + assert structured_content is not None + assert structured_content == {"the_value": 7} + + def test_basemodel_reserved_names(): """Test that functions with parameters named after BaseModel methods work correctly"""