Align output serialization with return types

This commit is contained in:
Jeremiah Lowin 2026-08-06 09:01:22 -04:00
commit af71f669a3
No known key found for this signature in database
5 changed files with 104 additions and 7 deletions

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import inspect
from collections.abc import Callable
from typing import (
TYPE_CHECKING,
@ -38,6 +39,7 @@ from fastmcp.utilities.types import (
Image,
NotSet,
NotSetT,
get_cached_typeadapter,
)
if TYPE_CHECKING:
@ -65,9 +67,20 @@ def default_serializer(data: Any) -> str:
return _JSONABLE_ADAPTER.dump_json(data, fallback=str).decode()
def _serialize_to_jsonable(data: Any) -> Any:
def _serialize_to_jsonable(data: Any, annotation: Any = Any) -> Any:
"""Serialize through Pydantic while preserving each model's configuration."""
return _JSONABLE_ADAPTER.dump_python(data, mode="json")
if (
annotation is inspect.Signature.empty
or annotation is None
or annotation is Any
or annotation is ...
or isinstance(annotation, str)
):
adapter = _JSONABLE_ADAPTER
else:
adapter = get_cached_typeadapter(annotation)
return adapter.dump_python(data, mode="json")
class ToolResult(BaseModel):
@ -341,6 +354,10 @@ class Tool(FastMCPComponent):
"""
raise NotImplementedError("Subclasses must implement run()")
def _serialize_output(self, raw_value: Any) -> Any:
"""Serialize a tool result to JSON-compatible Python values."""
return _serialize_to_jsonable(raw_value)
def convert_result(self, raw_value: Any) -> ToolResult:
"""Convert a raw result to ToolResult.
@ -382,7 +399,7 @@ class Tool(FastMCPComponent):
return ToolResult(content=content)
try:
structured = _serialize_to_jsonable(raw_value)
structured = self._serialize_output(raw_value)
except (pydantic_core.PydanticSerializationError, UnicodeDecodeError):
return ToolResult(content=content)

View file

@ -35,6 +35,7 @@ from fastmcp.tools.base import (
InputRequiredToolResult,
Tool,
ToolResult,
_serialize_to_jsonable,
)
from fastmcp.tools.function_parsing import ParsedFunction, _is_object_schema
from fastmcp.utilities.async_utils import (
@ -214,6 +215,10 @@ class FunctionTool(Tool):
),
] = True
def _serialize_output(self, raw_value: Any) -> Any:
"""Serialize using the return annotation that produced the output schema."""
return _serialize_to_jsonable(raw_value, self.return_type)
@classmethod
def from_function(
cls,

View file

@ -309,11 +309,16 @@ class TransformedTool(Tool):
parent_tool: SkipJsonSchema[Tool]
fn: SkipJsonSchema[Callable[..., Any]]
return_type: Annotated[SkipJsonSchema[Any], Field(exclude=True)] = None
forwarding_fn: SkipJsonSchema[
Callable[..., Any]
] # Always present, handles arg transformation
transform_args: dict[str, ArgTransform]
def _serialize_output(self, raw_value: Any) -> Any:
"""Serialize using the custom transform's declared return type."""
return _serialize_to_jsonable(raw_value, self.return_type)
async def run(self, arguments: dict[str, Any]) -> ToolResult:
"""Run the tool with context set for forward() functions.
@ -401,14 +406,14 @@ class TransformedTool(Tool):
# First handle structured content based on output schema, if any
if self.output_schema is not None:
if self.output_schema.get("x-fastmcp-wrap-result"):
structured_output = {"result": _serialize_to_jsonable(result)}
structured_output = {"result": self._serialize_output(result)}
else:
structured_output = result
structured_output = self._serialize_output(result)
# If no output schema, try to serialize the result. If it is a dict, use
# it as structured content. If it is not a dict, ignore it.
if structured_output is None:
try:
structured_output = _serialize_to_jsonable(result)
structured_output = self._serialize_output(result)
if not isinstance(structured_output, dict):
structured_output = None
except Exception:
@ -631,6 +636,7 @@ class TransformedTool(Tool):
transformed_tool = cls(
fn=final_fn,
return_type=parsed_fn.return_type if parsed_fn is not None else None,
forwarding_fn=forwarding_fn,
parent_tool=tool,
name=final_name,

View file

@ -4,7 +4,7 @@ from typing import Annotated, Any
import pytest
from mcp_types import CallToolResult, TextContent
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, with_config
from pydantic.dataclasses import dataclass as pydantic_dataclass
from typing_extensions import TypedDict
@ -481,6 +481,53 @@ class TestSerializeByAlias:
}
assert result.structured_content == {"dataValue": "data"}
async def test_configured_standard_dataclass_can_enable_aliases(self):
"""A configured stdlib dataclass uses aliases in schema and output."""
@with_config(ConfigDict(serialize_by_alias=True))
@dataclass
class Output:
value: Annotated[str, Field(serialization_alias="dataValue")]
mcp = FastMCP()
@mcp.tool
def get_output() -> Output:
return Output(value="data")
async with Client(mcp) as client:
tools = {tool.name: tool for tool in await client.list_tools()}
result = await client.call_tool("get_output", {})
assert set(tools["get_output"].output_schema["properties"]) == { # type: ignore[index]
"dataValue"
}
assert result.structured_content == {"dataValue": "data"}
async def test_nested_configured_standard_dataclass_can_enable_aliases(self):
"""Typed containers preserve a nested dataclass's alias configuration."""
@with_config(ConfigDict(serialize_by_alias=True))
@dataclass
class Output:
value: Annotated[str, Field(serialization_alias="dataValue")]
mcp = FastMCP()
@mcp.tool
def get_output() -> list[Output]:
return [Output(value="data")]
async with Client(mcp) as client:
tools = {tool.name: tool for tool in await client.list_tools()}
result = await client.call_tool("get_output", {})
item_schema = tools["get_output"].output_schema["properties"]["result"][ # type: ignore[index]
"items"
]
assert set(item_schema["properties"]) == {"dataValue"}
assert result.structured_content == {"result": [{"dataValue": "data"}]}
async def test_typed_dict_uses_pydantic_alias_default(self):
"""A TypedDict schema uses the field names emitted by Pydantic."""

View file

@ -1,6 +1,7 @@
"""Core tool transform functionality."""
import re
from dataclasses import dataclass
from typing import Annotated, Any
import pytest
@ -722,6 +723,27 @@ async def test_transform_fn_wrapped_result_respects_serialize_by_alias():
assert result.structured_content == {"result": {"id": "42"}}
async def test_transform_fn_configured_dataclass_respects_serialize_by_alias():
"""A transform uses its return annotation for nested dataclass serialization."""
from pydantic import ConfigDict, with_config
@with_config(ConfigDict(serialize_by_alias=True))
@dataclass
class Item:
id: Annotated[str, Field(serialization_alias="itemId")]
def base() -> None:
pass
async def transform() -> list[Item]:
return [Item(id="42")]
transformed = Tool.from_tool(base, transform_fn=transform)
result = await transformed.run({})
assert result.structured_content == {"result": [{"itemId": "42"}]}
class TestProxy:
@pytest.fixture
def mcp_server(self) -> FastMCP: