Skip to content

Commit 6cf1dd6

Browse files
committed
normalize structured_content for directly-returned CallToolResult
a tool returning an annotated CallToolResult had its structured_content validated but not re-dumped, so computed fields and serialization aliases still disagreed with the serialization-mode output schema. dump the validated model the same way as ordinary return values. also drop a stale pragma: no cover on the computed-field property, which runs during serialization.
1 parent d3054e0 commit 6cf1dd6

2 files changed

Lines changed: 27 additions & 2 deletions

File tree

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,12 @@ def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult:
126126
if isinstance(result, CallToolResult):
127127
if self.output_schema is not None:
128128
assert self.output_model is not None, "Output model must be set if output schema is defined"
129-
self.output_model.model_validate(result.structured_content)
129+
validated = self.output_model.model_validate(result.structured_content)
130+
# Normalize structured content the same way as ordinary return values, so
131+
# computed fields and serialization aliases match the output schema.
132+
return result.model_copy(
133+
update={"structured_content": validated.model_dump(mode="json", by_alias=True)}
134+
)
130135
return result
131136

132137
unstructured_content = _convert_to_content(result)

tests/server/mcpserver/test_func_metadata.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,26 @@ def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, Per
884884
assert isinstance(meta.convert_result(func_returning_annotated_tool_call_result()), CallToolResult)
885885

886886

887+
def test_tool_call_result_annotated_structured_content_is_normalized():
888+
"""A directly-returned CallToolResult has its structured_content normalized, so
889+
serialization aliases match the declared output schema."""
890+
891+
class Person(BaseModel):
892+
name: str = Field(serialization_alias="full_name")
893+
894+
def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, Person]:
895+
return CallToolResult(content=[], structured_content={"name": "Brandon"})
896+
897+
meta = func_metadata(func_returning_annotated_tool_call_result)
898+
899+
assert meta.output_schema is not None
900+
assert "full_name" in meta.output_schema["properties"]
901+
902+
converted = meta.convert_result(func_returning_annotated_tool_call_result())
903+
assert isinstance(converted, CallToolResult)
904+
assert converted.structured_content == {"full_name": "Brandon"}
905+
906+
887907
def test_tool_call_result_annotated_unioned_with_input_required_result_is_equivalent_to_the_bare_annotated_form():
888908
"""Stripping `InputRequiredResult` makes the residual behave exactly as if it were the
889909
declared return annotation, including the `Annotated[CallToolResult, Model]` special case
@@ -1120,7 +1140,7 @@ class ModelWithComputed(BaseModel):
11201140

11211141
@computed_field
11221142
@property
1123-
def doubled(self) -> int: # pragma: no cover
1143+
def doubled(self) -> int:
11241144
return self.value * 2
11251145

11261146
def func_with_computed() -> ModelWithComputed: # pragma: no cover

0 commit comments

Comments
 (0)