Skip to content

Commit c2d0e13

Browse files
committed
fix: alias-normalize CallToolResult structured_content after validate
1 parent 08a3bc8 commit c2d0e13

2 files changed

Lines changed: 38 additions & 2 deletions

File tree

‎src/mcp/server/mcpserver/utilities/func_metadata.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,15 @@ def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult:
203203
output_model = self.output_model if self.output_schema is not None else None
204204
if isinstance(result, CallToolResult):
205205
if output_model is not None and not result.is_error:
206-
self._output_adapter(output_model).validate_python(result.structured_content)
206+
# Match the plain-return path: accept Python or alias keys, then emit
207+
# wire keys (aliases) so structuredContent agrees with outputSchema.
208+
adapter = self._output_adapter(output_model)
209+
validated = adapter.validate_python(result.structured_content, by_alias=True, by_name=True)
210+
if isinstance(validated, BaseModel):
211+
structured_content = validated.model_dump(mode="json", by_alias=True)
212+
else:
213+
structured_content = adapter.dump_python(validated, mode="json", by_alias=True)
214+
return result.model_copy(update={"structured_content": structured_content})
207215
return result
208216

209217
unstructured_content = _convert_to_content(result)

‎tests/server/mcpserver/test_func_metadata.py‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import pytest
1212
from dirty_equals import IsPartialDict
1313
from mcp_types import CallToolResult, ContentBlock, EmbeddedResource, InputRequiredResult, TextContent
14-
from pydantic import BaseModel, Field, ValidationError
14+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
1515
from typing_extensions import NotRequired, ReadOnly, Required
1616

1717
from mcp import MCPDeprecationWarning
@@ -1322,6 +1322,34 @@ def func_with_aliases() -> ModelWithAliases: # pragma: no cover
13221322
assert structured_content_defaults["second"] is None
13231323

13241324

1325+
def test_call_tool_result_structured_content_alias_normalized():
1326+
"""CallToolResult.structured_content must be alias-normalized like plain returns."""
1327+
1328+
class ModelWithAliases(BaseModel):
1329+
model_config = ConfigDict(populate_by_name=True)
1330+
field_first: str | None = Field(default=None, alias="first")
1331+
field_second: str | None = Field(default=None, alias="second")
1332+
1333+
def via_call_tool_result() -> Annotated[CallToolResult, ModelWithAliases]:
1334+
return CallToolResult(
1335+
content=[],
1336+
structured_content={"field_first": "hello", "field_second": "world"},
1337+
)
1338+
1339+
meta = func_metadata(via_call_tool_result)
1340+
assert meta.output_schema is not None
1341+
assert "first" in meta.output_schema["properties"]
1342+
assert "field_first" not in meta.output_schema["properties"]
1343+
1344+
converted = meta.convert_result(via_call_tool_result())
1345+
assert isinstance(converted, CallToolResult)
1346+
structured_content = converted.structured_content
1347+
assert structured_content is not None
1348+
assert structured_content == {"first": "hello", "second": "world"}
1349+
assert "field_first" not in structured_content
1350+
assert "field_second" not in structured_content
1351+
1352+
13251353
def test_basemodel_reserved_names():
13261354
"""Test that functions with parameters named after BaseModel methods work correctly"""
13271355

0 commit comments

Comments
 (0)