mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 14:04:18 +02:00
Align output serialization with return types
This commit is contained in:
parent
98bf8557d5
commit
af71f669a3
5 changed files with 104 additions and 7 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue