fix(tools): honor serialize_by_alias in tool result serialization (#4391)

This commit is contained in:
Jeremiah Lowin 2026-06-27 10:24:39 -04:00 committed by GitHub
commit dc4b0e202d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 315 additions and 12 deletions

View file

@ -62,8 +62,34 @@ logger = get_logger(__name__)
ToolResultSerializerType: TypeAlias = Callable[[Any], str]
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).decode()
return pydantic_core.to_json(
data, fallback=str, by_alias=resolve_serialize_by_alias(data)
).decode()
class ToolResult(BaseModel):
@ -110,7 +136,8 @@ class ToolResult(BaseModel):
try:
structured_content = pydantic_core.to_jsonable_python(
value=structured_content
value=structured_content,
by_alias=resolve_serialize_by_alias(structured_content),
)
except pydantic_core.PydanticSerializationError as e:
logger.error(
@ -321,7 +348,9 @@ class Tool(FastMCPComponent):
return ToolResult(content=content)
try:
structured = pydantic_core.to_jsonable_python(raw_value)
structured = pydantic_core.to_jsonable_python(
raw_value, by_alias=resolve_serialize_by_alias(raw_value)
)
except (pydantic_core.PydanticSerializationError, UnicodeDecodeError):
return ToolResult(content=content)

View file

@ -10,10 +10,10 @@ from dataclasses import dataclass
from typing import Annotated, Any, Generic, Union, get_args, get_origin, get_type_hints
import mcp.types
from pydantic import PydanticSchemaGenerationError
from pydantic import BaseModel, PydanticSchemaGenerationError
from typing_extensions import TypeVar as TypeVarExt
from fastmcp.tools.base import ToolResult
from fastmcp.tools.base import ToolResult, resolve_serialize_by_alias
from fastmcp.utilities.docstring_parsing import ParsedDocstring, parse_docstring
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.logging import get_logger
@ -56,6 +56,53 @@ def _contains_prefab_type(tp: Any) -> bool:
return False
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
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.
"""
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)
T = TypeVarExt("T", default=Any)
logger = get_logger(__name__)
@ -282,8 +329,13 @@ 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")
base_schema = type_adapter.json_schema(
mode="serialization", by_alias=by_alias
)
# Generate schema for wrapped type if it's non-object
# because MCP requires that output schemas are objects
@ -293,7 +345,9 @@ class ParsedFunction:
# Use the wrapped result schema directly
wrapped_type = _WrappedResult[clean_output_type]
wrapped_adapter = get_cached_typeadapter(wrapped_type)
output_schema = wrapped_adapter.json_schema(mode="serialization")
output_schema = wrapped_adapter.json_schema(
mode="serialization", by_alias=by_alias
)
output_schema["x-fastmcp-wrap-result"] = True
else:
output_schema = base_schema

View file

@ -17,7 +17,12 @@ from pydantic.json_schema import SkipJsonSchema
import fastmcp
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.tools.base import Tool, ToolResult, _convert_to_content
from fastmcp.tools.base import (
Tool,
ToolResult,
_convert_to_content,
resolve_serialize_by_alias,
)
from fastmcp.tools.function_parsing import ParsedFunction
from fastmcp.utilities.async_utils import (
call_sync_fn_in_threadpool,
@ -346,15 +351,23 @@ 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"):
# Schema says wrap - always wrap in result key
structured_output = {"result": 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)
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:

View file

@ -1,9 +1,12 @@
import json
from dataclasses import dataclass
from typing import Any
from typing import Annotated, Any
import pytest
from mcp.types import CallToolResult, TextContent
from pydantic import BaseModel, ConfigDict, Field
from fastmcp import Client, FastMCP
from fastmcp.tools.base import Tool, ToolResult
@ -233,3 +236,160 @@ class TestSerializationAlias:
component_data = result.structured_content
assert component_data["componentId"] == "test123"
assert "id" not in component_data
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.
"""
async def test_serialize_by_alias_false_uses_field_names(self):
"""serialize_by_alias=False emits field names in schema, structured, and text."""
class Biofile(BaseModel):
model_config = ConfigDict(serialize_by_alias=False)
id: str = Field(alias="_id")
filepath: str
mcp = FastMCP()
@mcp.tool
def get_biofile() -> Annotated[Biofile, Field(description="data")]:
return Biofile(_id="123", filepath="/p")
async with Client(mcp) as client:
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 json.loads(result.content[0].text) == { # type: ignore[union-attr]
"id": "123",
"filepath": "/p",
}
assert set(tools["get_biofile"].outputSchema["properties"]) == { # type: ignore[index]
"id",
"filepath",
}
async def test_unset_config_preserves_alias_default(self):
"""A model with an alias but no serialize config keeps emitting the alias."""
class Biofile(BaseModel):
id: str = Field(alias="_id")
filepath: str
mcp = FastMCP()
@mcp.tool
def get_biofile() -> Biofile:
return Biofile(_id="123", filepath="/p")
async with Client(mcp) as client:
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 set(tools["get_biofile"].outputSchema["properties"]) == { # type: ignore[index]
"_id",
"filepath",
}
async def test_serialize_by_alias_true_uses_alias(self):
"""serialize_by_alias=True emits aliases, same as the default."""
class Biofile(BaseModel):
model_config = ConfigDict(serialize_by_alias=True)
id: str = Field(alias="_id")
mcp = FastMCP()
@mcp.tool
def get_biofile() -> Biofile:
return Biofile(_id="123")
async with Client(mcp) as client:
tools = {t.name: t for t in await client.list_tools()}
result = await client.call_tool("get_biofile", {})
assert result.structured_content == {"_id": "123"}
assert set(tools["get_biofile"].outputSchema["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"].outputSchema["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"].outputSchema["properties"]) # type: ignore[index]
assert schema_props == set(result.structured_content) # type: ignore[arg-type]

View file

@ -658,6 +658,53 @@ async def test_from_tool_decorated_function_via_client():
assert "Result 0 for hello" in result.content[0].text
async def test_transform_fn_result_respects_serialize_by_alias():
"""A model returned by a transform_fn honors serialize_by_alias when no schema."""
from pydantic import ConfigDict
class Item(BaseModel):
model_config = ConfigDict(serialize_by_alias=False)
id: str = Field(alias="_id")
def base() -> None:
pass
async def transform() -> Any:
return Item(_id="42")
transformed = Tool.from_tool(base, transform_fn=transform, output_schema=None)
result = await transformed.run({})
assert result.structured_content == {"id": "42"}
async def test_transform_fn_wrapped_result_respects_serialize_by_alias():
"""A wrapped transform result serializes the inner model before nesting.
Optional model returns get a wrap-result schema; the inner model must be
serialized with its own config before being placed under "result", or the
wrapped dict masks the config and the data no longer matches the schema.
"""
from pydantic import ConfigDict
class Item(BaseModel):
model_config = ConfigDict(serialize_by_alias=False)
id: str = Field(alias="_id")
def base() -> None:
pass
async def transform() -> Item | None:
return Item(_id="42")
transformed = Tool.from_tool(base, transform_fn=transform)
assert transformed.output_schema is not None
assert transformed.output_schema.get("x-fastmcp-wrap-result")
result = await transformed.run({})
assert result.structured_content == {"result": {"id": "42"}}
class TestProxy:
@pytest.fixture
def mcp_server(self) -> FastMCP: