diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index cc32433568..a9604fafd5 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -203,7 +203,15 @@ def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult: output_model = self.output_model if self.output_schema is not None else None if isinstance(result, CallToolResult): if output_model is not None and not result.is_error: - self._output_adapter(output_model).validate_python(result.structured_content) + # Match the plain-return path: accept Python or alias keys, then emit + # wire keys (aliases) so structuredContent agrees with outputSchema. + adapter = self._output_adapter(output_model) + validated = adapter.validate_python(result.structured_content, by_alias=True, by_name=True) + if isinstance(validated, BaseModel): + structured_content = validated.model_dump(mode="json", by_alias=True) + else: + structured_content = adapter.dump_python(validated, mode="json", by_alias=True) + return result.model_copy(update={"structured_content": structured_content}) return result unstructured_content = _convert_to_content(result) diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index dba0637ded..eabd357e1b 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, ContentBlock, EmbeddedResource, InputRequiredResult, TextContent -from pydantic import BaseModel, Field, ValidationError +from pydantic import BaseModel, ConfigDict, Field, ValidationError from typing_extensions import NotRequired, ReadOnly, Required from mcp import MCPDeprecationWarning @@ -1322,6 +1322,34 @@ def func_with_aliases() -> ModelWithAliases: # pragma: no cover assert structured_content_defaults["second"] is None +def test_call_tool_result_structured_content_alias_normalized(): + """CallToolResult.structured_content must be alias-normalized like plain returns.""" + + class ModelWithAliases(BaseModel): + model_config = ConfigDict(populate_by_name=True) + field_first: str | None = Field(default=None, alias="first") + field_second: str | None = Field(default=None, alias="second") + + def via_call_tool_result() -> Annotated[CallToolResult, ModelWithAliases]: + return CallToolResult( + content=[], + structured_content={"field_first": "hello", "field_second": "world"}, + ) + + meta = func_metadata(via_call_tool_result) + assert meta.output_schema is not None + assert "first" in meta.output_schema["properties"] + assert "field_first" not in meta.output_schema["properties"] + + converted = meta.convert_result(via_call_tool_result()) + assert isinstance(converted, CallToolResult) + structured_content = converted.structured_content + assert structured_content is not None + assert structured_content == {"first": "hello", "second": "world"} + assert "field_first" not in structured_content + assert "field_second" not in structured_content + + def test_basemodel_reserved_names(): """Test that functions with parameters named after BaseModel methods work correctly"""