mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
fix(tools): honor serialize_by_alias in tool result serialization (#4391)
This commit is contained in:
parent
cccff4849e
commit
dc4b0e202d
5 changed files with 315 additions and 12 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue