From af71f669a390e8d61072dfcadc020eaf72ce1437 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:01:22 -0400 Subject: [PATCH] Align output serialization with return types --- fastmcp_slim/fastmcp/tools/base.py | 23 +++++++-- fastmcp_slim/fastmcp/tools/function_tool.py | 5 ++ fastmcp_slim/fastmcp/tools/tool_transform.py | 12 +++-- tests/tools/tool/test_results.py | 49 ++++++++++++++++++- .../tool_transform/test_tool_transform.py | 22 +++++++++ 5 files changed, 104 insertions(+), 7 deletions(-) diff --git a/fastmcp_slim/fastmcp/tools/base.py b/fastmcp_slim/fastmcp/tools/base.py index 34166ae61..bf02433f7 100644 --- a/fastmcp_slim/fastmcp/tools/base.py +++ b/fastmcp_slim/fastmcp/tools/base.py @@ -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) diff --git a/fastmcp_slim/fastmcp/tools/function_tool.py b/fastmcp_slim/fastmcp/tools/function_tool.py index 4204679a8..82466b5d3 100644 --- a/fastmcp_slim/fastmcp/tools/function_tool.py +++ b/fastmcp_slim/fastmcp/tools/function_tool.py @@ -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, diff --git a/fastmcp_slim/fastmcp/tools/tool_transform.py b/fastmcp_slim/fastmcp/tools/tool_transform.py index d5fd4cf5f..4b7b3751f 100644 --- a/fastmcp_slim/fastmcp/tools/tool_transform.py +++ b/fastmcp_slim/fastmcp/tools/tool_transform.py @@ -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, diff --git a/tests/tools/tool/test_results.py b/tests/tools/tool/test_results.py index 2165070c7..b9a1c1da8 100644 --- a/tests/tools/tool/test_results.py +++ b/tests/tools/tool/test_results.py @@ -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.""" diff --git a/tests/tools/tool_transform/test_tool_transform.py b/tests/tools/tool_transform/test_tool_transform.py index 2cde40b7f..e827c1576 100644 --- a/tests/tools/tool_transform/test_tool_transform.py +++ b/tests/tools/tool_transform/test_tool_transform.py @@ -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: