Delegate typed tool output serialization to Pydantic (#4771)

This commit is contained in:
Jeremiah Lowin 2026-08-06 19:59:19 -04:00 committed by GitHub
commit 04f9971120
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 179 additions and 197 deletions

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import inspect
from collections.abc import Callable
from typing import (
TYPE_CHECKING,
@ -20,7 +21,13 @@ from mcp_types import (
ToolExecution,
)
from mcp_types import Tool as MCPTool
from pydantic import BaseModel, Field, PrivateAttr, model_validator
from pydantic import (
BaseModel,
Field,
PrivateAttr,
PydanticSchemaGenerationError,
model_validator,
)
from pydantic.json_schema import SkipJsonSchema
from fastmcp.utilities.authorization import AuthCheck
@ -38,6 +45,7 @@ from fastmcp.utilities.types import (
Image,
NotSet,
NotSetT,
get_cached_typeadapter,
)
if TYPE_CHECKING:
@ -48,6 +56,8 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
_JSONABLE_ADAPTER = get_cached_typeadapter(Any)
def _default_title(name: str) -> str:
"""Derive a display title from a tool name.
@ -59,34 +69,27 @@ def _default_title(name: str) -> str:
return name.replace("_", " ").replace("-", " ").title()
def resolve_serialize_by_alias(value: Any) -> bool:
"""Resolve the effective ``by_alias`` setting for serializing *value*.
Pydantic's low-level serialization helpers (``to_json``,
``to_jsonable_python``) default ``by_alias`` to ``True``, which silently
ignores a model's ``serialize_by_alias`` config. When *value* is a Pydantic
model we consult that config instead, falling back to ``True`` to preserve
FastMCP's longstanding default of emitting aliases when no preference is
declared.
"""
if isinstance(value, type):
model = value if issubclass(value, BaseModel) else None
elif isinstance(value, BaseModel):
model = type(value)
else:
model = None
if model is None:
return True
configured = model.model_config.get("serialize_by_alias")
return True if configured is None else configured
def default_serializer(data: Any) -> str:
return pydantic_core.to_json(
data, fallback=str, by_alias=resolve_serialize_by_alias(data)
).decode()
return _JSONABLE_ADAPTER.dump_json(data, fallback=str).decode()
def _serialize_to_jsonable(data: Any, annotation: Any = Any) -> Any:
"""Serialize through Pydantic, falling back for unsupported annotations."""
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:
try:
return get_cached_typeadapter(annotation).dump_python(data, mode="json")
except PydanticSchemaGenerationError:
adapter = _JSONABLE_ADAPTER
return adapter.dump_python(data, mode="json")
class ToolResult(BaseModel):
@ -133,10 +136,7 @@ class ToolResult(BaseModel):
)
try:
structured_content = pydantic_core.to_jsonable_python(
value=structured_content,
by_alias=resolve_serialize_by_alias(structured_content),
)
structured_content = _serialize_to_jsonable(structured_content)
except pydantic_core.PydanticSerializationError as e:
logger.error(
f"Could not serialize structured content. If this is unexpected, set your tool's output_schema to None to disable automatic serialization: {e}"
@ -233,6 +233,7 @@ class Tool(FastMCPComponent):
KEY_PREFIX: ClassVar[str] = "tool"
return_type: Annotated[SkipJsonSchema[Any], Field(exclude=True)] = None
parameters: Annotated[
dict[str, Any], Field(description="JSON schema for tool parameters")
]
@ -392,24 +393,29 @@ class Tool(FastMCPComponent):
if isinstance(raw_value, bytes):
return ToolResult(content=content)
is_content_result = isinstance(
raw_value, ContentBlock | Audio | Image | File
) or (
isinstance(raw_value, list | tuple)
and any(
isinstance(item, ContentBlock | Audio | Image | File)
for item in raw_value
)
)
# Skip structured content for ContentBlock types only if no output_schema
# (if output_schema exists, MCP SDK requires structured_content)
if self.output_schema is None and (
isinstance(raw_value, ContentBlock | Audio | Image | File)
or (
isinstance(raw_value, list | tuple)
and any(isinstance(item, ContentBlock) for item in raw_value)
)
):
if self.output_schema is None and is_content_result:
return ToolResult(content=content)
try:
structured = pydantic_core.to_jsonable_python(
raw_value, by_alias=resolve_serialize_by_alias(raw_value)
)
structured = _serialize_to_jsonable(raw_value, self.return_type)
except (pydantic_core.PydanticSerializationError, UnicodeDecodeError):
return ToolResult(content=content)
if not is_content_result:
content = _convert_to_content(structured)
if self.output_schema is None:
# No schema - only use structured_content for dicts
if isinstance(structured, dict):

View file

@ -10,11 +10,13 @@ from dataclasses import dataclass
from typing import Annotated, Any, Generic, Union, get_args, get_origin, get_type_hints
import mcp_types
from pydantic import BaseModel, PydanticSchemaGenerationError
from pydantic import PydanticSchemaGenerationError
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue
from pydantic_core import core_schema
from typing_extensions import TypeAliasType
from typing_extensions import TypeVar as TypeVarExt
from fastmcp.tools.base import ToolResult, resolve_serialize_by_alias
from fastmcp.tools.base import ToolResult
from fastmcp.utilities.docstring_parsing import ParsedDocstring, parse_docstring
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.logging import get_logger
@ -146,51 +148,30 @@ def _strip_input_required(tp: Any) -> Any:
return Union[tuple(residual)] # noqa: UP007
def _unwrap_model(tp: Any) -> type[BaseModel] | None:
"""Unwrap ``Annotated`` and return the underlying Pydantic model, if any."""
if get_origin(tp) is Annotated:
return _unwrap_model(get_args(tp)[0])
if isinstance(tp, type) and issubclass(tp, BaseModel):
return tp
return None
class _ToolOutputSchemaGenerator(GenerateJsonSchema):
"""Generate each model's schema with its configured serialization aliases.
def _resolve_output_by_alias(tp: Any) -> bool:
"""Resolve ``by_alias`` for the output schema of return type *tp*.
Unwraps ``Annotated`` and ``Optional``/``Union`` wrappers to find the
underlying Pydantic model so the generated schema honors the model's
``serialize_by_alias`` config keeping it consistent with how the runtime
result is serialized. Containers (``list[Model]`` etc.) are not unwrapped:
their schema keeps the default, matching the runtime path which only
special-cases a directly-returned model.
Known limitation: a single schema is generated with one ``by_alias`` value,
while the runtime resolves the alias mode per returned value. They cannot
diverge for a plain single-model return, but a union return can produce more
than one runtime alias mode that no single schema can describe:
- distinct models with *conflicting* ``serialize_by_alias`` (e.g. ``A | B``
where ``A`` opts out but ``B`` opts in), and
- a model arm alongside a container arm (e.g. ``Model | list[Model]``):
a directly-returned model honors its config, but a returned ``list`` is
serialized with the default alias mode, so the two variants disagree.
Pydantic's schema generator does not consult per-model ``serialize_by_alias``
and the runtime does not recurse into containers, so honoring every variant
would require per-arm schema assembly. This is an accepted edge; single-model
returns and unions whose arms all resolve to the same mode are consistent.
Pydantic's serializer consults ``serialize_by_alias`` per model, while its
JSON Schema API otherwise applies one ``by_alias`` value to the whole tree.
"""
origin = get_origin(tp)
if origin is Annotated:
return _resolve_output_by_alias(get_args(tp)[0])
if origin is Union or origin is types.UnionType:
for arg in get_args(tp):
model = _unwrap_model(arg)
if model is not None:
return resolve_serialize_by_alias(model)
return True
return resolve_serialize_by_alias(tp)
def model_schema(self, schema: core_schema.ModelSchema) -> JsonSchemaValue:
previous_by_alias = self.by_alias
configured = schema["cls"].model_config.get("serialize_by_alias")
self.by_alias = False if configured is None else configured
try:
return super().model_schema(schema)
finally:
self.by_alias = previous_by_alias
def dataclass_schema(self, schema: core_schema.DataclassSchema) -> JsonSchemaValue:
previous_by_alias = self.by_alias
configured = (schema.get("config") or {}).get("serialize_by_alias")
self.by_alias = False if configured is None else configured
try:
return super().dataclass_schema(schema)
finally:
self.by_alias = previous_by_alias
T = TypeVarExt("T", default=Any)
@ -449,12 +430,11 @@ class ParsedFunction:
)
try:
# Honor the model's serialize_by_alias config so the schema's
# field names match the serialized result (see base.py).
by_alias = _resolve_output_by_alias(clean_output_type)
type_adapter = get_cached_typeadapter(clean_output_type)
base_schema = type_adapter.json_schema(
mode="serialization", by_alias=by_alias
mode="serialization",
by_alias=False,
schema_generator=_ToolOutputSchemaGenerator,
)
# Generate schema for wrapped type if it's non-object
@ -466,7 +446,9 @@ class ParsedFunction:
wrapped_type = _WrappedResult[clean_output_type]
wrapped_adapter = get_cached_typeadapter(wrapped_type)
output_schema = wrapped_adapter.json_schema(
mode="serialization", by_alias=by_alias
mode="serialization",
by_alias=False,
schema_generator=_ToolOutputSchemaGenerator,
)
output_schema["x-fastmcp-wrap-result"] = True
else:

View file

@ -197,7 +197,6 @@ def _resolve_param_hints(fn: Callable[..., Any]) -> dict[str, Any]:
class FunctionTool(Tool):
fn: SkipJsonSchema[Callable[..., Any]]
return_type: Annotated[SkipJsonSchema[Any], Field(exclude=True)] = None
run_in_thread: Annotated[
bool,
Field(

View file

@ -8,7 +8,6 @@ from dataclasses import dataclass
from typing import Annotated, Any, Literal, cast
import mcp_types
import pydantic_core
from mcp_types import ToolAnnotations
from pydantic import ConfigDict
from pydantic.fields import Field
@ -19,8 +18,6 @@ from fastmcp.tools.base import (
InputRequiredToolResult,
Tool,
ToolResult,
_convert_to_content,
resolve_serialize_by_alias,
)
from fastmcp.tools.function_parsing import ParsedFunction
from fastmcp.utilities.async_utils import (
@ -394,40 +391,7 @@ class TransformedTool(Tool):
else:
return result
# Otherwise convert to content and create ToolResult with proper structured content
unstructured_result = _convert_to_content(result)
structured_output = None
# 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"):
# Schema says wrap - serialize the inner result first (so its
# serialize_by_alias config is honored) before nesting, since
# wrapping in a dict would otherwise mask the model's config.
structured_output = {
"result": pydantic_core.to_jsonable_python(
result, by_alias=resolve_serialize_by_alias(result)
)
}
else:
structured_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 = pydantic_core.to_jsonable_python(
result, by_alias=resolve_serialize_by_alias(result)
)
if not isinstance(structured_output, dict):
structured_output = None
except Exception:
pass
return ToolResult(
content=unstructured_result,
structured_content=structured_output,
)
return self.convert_result(result)
finally:
_current_tool.reset(token)
@ -641,6 +605,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

@ -231,6 +231,10 @@ class TestToolFromFunctionOutputSchema:
tool = Tool.from_function(func)
assert tool.output_schema is None
result = await tool.run({})
assert result.structured_content is None
assert len(result.content) == 1
async def test_mixed_unserializable_return_annotation(self):
class Unserializable:
def __init__(self, data: Any):

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 fastmcp import Client, FastMCP
from fastmcp.tools.base import Tool, ToolResult
@ -200,6 +200,7 @@ class TestSerializationAlias:
class Component(BaseModel):
"""Model with multiple validation aliases but specific serialization alias."""
model_config = ConfigDict(serialize_by_alias=True)
component_id: str = Field(
validation_alias=AliasChoices("id", "componentId"),
serialization_alias="componentId",
@ -243,6 +244,7 @@ class TestSerializationAlias:
class Component(BaseModel):
"""Model with multiple validation aliases but specific serialization alias."""
model_config = ConfigDict(serialize_by_alias=True)
component_id: str = Field(
validation_alias=AliasChoices("id", "componentId"),
serialization_alias="componentId",
@ -277,12 +279,7 @@ class TestSerializationAlias:
class TestSerializeByAlias:
"""Tests that a model's serialize_by_alias config is honored at runtime.
pydantic_core's serialization helpers default by_alias to True, which
silently ignores serialize_by_alias=False. The serialized result and the
generated output schema must both reflect the model's configured behavior.
"""
"""Tests that typed results use Pydantic's serialization behavior."""
async def test_serialize_by_alias_false_uses_field_names(self):
"""serialize_by_alias=False emits field names in schema, structured, and text."""
@ -312,8 +309,8 @@ class TestSerializeByAlias:
"filepath",
}
async def test_unset_config_preserves_alias_default(self):
"""A model with an alias but no serialize config keeps emitting the alias."""
async def test_unset_config_uses_pydantic_default(self):
"""A model with no serialize config uses Pydantic's field-name default."""
class Biofile(BaseModel):
id: str = Field(alias="_id")
@ -329,14 +326,96 @@ class TestSerializeByAlias:
tools = {t.name: t for t in await client.list_tools()}
result = await client.call_tool("get_biofile", {})
assert result.structured_content == {"_id": "123", "filepath": "/p"}
assert result.structured_content == {"id": "123", "filepath": "/p"}
assert set(tools["get_biofile"].output_schema["properties"]) == { # type: ignore[index]
"_id",
"id",
"filepath",
}
async def test_model_in_typed_mapping_respects_config(self):
"""A typed mapping's schema and result use the model's field names."""
class Biofile(BaseModel):
model_config = ConfigDict(serialize_by_alias=False)
id: str = Field(alias="_id")
mcp = FastMCP()
@mcp.tool
def get_biofiles() -> dict[str, Biofile]:
return {"first": Biofile(_id="1")}
async with Client(mcp) as client:
tools = {tool.name: tool for tool in await client.list_tools()}
result = await client.call_tool("get_biofiles", {})
value_schema = tools["get_biofiles"].output_schema["additionalProperties"] # type: ignore[index]
assert set(value_schema["properties"]) == {"id"}
assert result.structured_content == {"first": {"id": "1"}}
async def test_nested_models_use_their_own_alias_configs(self):
"""Nested models can independently enable and disable aliases."""
class NamedValue(BaseModel):
model_config = ConfigDict(serialize_by_alias=False)
value: str = Field(serialization_alias="namedValue")
class AliasedValue(BaseModel):
model_config = ConfigDict(serialize_by_alias=True)
value: str = Field(serialization_alias="aliasedValue")
class Output(BaseModel):
named: NamedValue
aliased: AliasedValue
mcp = FastMCP()
@mcp.tool
def get_output() -> Output:
return Output(
named=NamedValue(value="named"),
aliased=AliasedValue(value="aliased"),
)
async with Client(mcp) as client:
tools = {tool.name: tool for tool in await client.list_tools()}
result = await client.call_tool("get_output", {})
properties = tools["get_output"].output_schema["properties"] # type: ignore[index]
assert set(properties["named"]["properties"]) == {"value"}
assert set(properties["aliased"]["properties"]) == {"aliasedValue"}
assert result.structured_content == {
"named": {"value": "named"},
"aliased": {"aliasedValue": "aliased"},
}
async def test_typed_dataclass_container_uses_declared_adapter(self):
"""A typed container preserves its 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"}]}
assert json.loads(result.content[0].text) == [{"dataValue": "data"}] # type: ignore[union-attr]
async def test_serialize_by_alias_true_uses_alias(self):
"""serialize_by_alias=True emits aliases, same as the default."""
"""serialize_by_alias=True emits aliases."""
class Biofile(BaseModel):
model_config = ConfigDict(serialize_by_alias=True)
@ -354,80 +433,3 @@ class TestSerializeByAlias:
assert result.structured_content == {"_id": "123"}
assert set(tools["get_biofile"].output_schema["properties"]) == {"_id"} # type: ignore[index]
async def test_nested_models_respect_config(self):
"""serialize_by_alias=False propagates through nested models."""
class Inner(BaseModel):
model_config = ConfigDict(serialize_by_alias=False)
inner_id: str = Field(alias="_iid")
class Outer(BaseModel):
model_config = ConfigDict(serialize_by_alias=False)
id: str = Field(alias="_id")
inner: Inner
mcp = FastMCP()
@mcp.tool
def get_outer() -> Outer:
return Outer(_id="1", inner=Inner(_iid="2"))
async with Client(mcp) as client:
result = await client.call_tool("get_outer", {})
assert result.structured_content == {"id": "1", "inner": {"inner_id": "2"}}
async def test_annotated_optional_return_stays_consistent(self):
"""Annotated[Model, ...] | None resolves the model inside the union arm.
Regression: the union arm is a typing.Annotated object, so a naive
isinstance check skipped the model and the schema fell back to aliases
while the runtime serialized field names, breaking client validation.
"""
class Biofile(BaseModel):
model_config = ConfigDict(serialize_by_alias=False)
id: str = Field(alias="_id")
mcp = FastMCP()
@mcp.tool
def get_biofile() -> Annotated[Biofile, Field(description="x")] | None:
return Biofile(_id="1")
async with Client(mcp) as client:
tools = {t.name: t for t in await client.list_tools()}
# client-side validation of structured content against the schema
# raises if they disagree
result = await client.call_tool("get_biofile", {})
schema_props = set(tools["get_biofile"].output_schema["properties"]) # type: ignore[index]
assert schema_props == set(result.structured_content) # type: ignore[arg-type]
assert result.structured_content == {"result": {"id": "1"}}
@pytest.mark.parametrize("serialize_by_alias", [True, False, None])
async def test_schema_and_structured_content_agree(self, serialize_by_alias):
"""The output schema field names always match the structured content keys."""
if serialize_by_alias is None:
config = ConfigDict()
else:
config = ConfigDict(serialize_by_alias=serialize_by_alias)
class Model(BaseModel):
model_config = config
id: str = Field(alias="_id")
name: str
mcp = FastMCP()
@mcp.tool
def get_model() -> Model:
return Model(_id="1", name="x")
async with Client(mcp) as client:
tools = {t.name: t for t in await client.list_tools()}
result = await client.call_tool("get_model", {})
schema_props = set(tools["get_model"].output_schema["properties"]) # type: ignore[index]
assert schema_props == set(result.structured_content) # type: ignore[arg-type]

View file

@ -1,11 +1,13 @@
"""Core tool transform functionality."""
import json
import re
from dataclasses import dataclass
from typing import Annotated, Any
import pytest
from mcp_types import TextContent
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field, with_config
from fastmcp import FastMCP
from fastmcp.client.client import Client
@ -722,6 +724,28 @@ 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."""
@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"}]}
assert isinstance(result.content[0], TextContent)
assert json.loads(result.content[0].text) == [{"itemId": "42"}]
class TestProxy:
@pytest.fixture
def mcp_server(self) -> FastMCP: