mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Delegate typed tool output serialization to Pydantic (#4771)
This commit is contained in:
parent
803da5319c
commit
04f9971120
7 changed files with 179 additions and 197 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue