diff --git a/fastmcp_slim/fastmcp/tools/base.py b/fastmcp_slim/fastmcp/tools/base.py index 90f550555..614649481 100644 --- a/fastmcp_slim/fastmcp/tools/base.py +++ b/fastmcp_slim/fastmcp/tools/base.py @@ -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) diff --git a/fastmcp_slim/fastmcp/tools/function_parsing.py b/fastmcp_slim/fastmcp/tools/function_parsing.py index cc7b253a0..3024199fd 100644 --- a/fastmcp_slim/fastmcp/tools/function_parsing.py +++ b/fastmcp_slim/fastmcp/tools/function_parsing.py @@ -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 diff --git a/fastmcp_slim/fastmcp/tools/tool_transform.py b/fastmcp_slim/fastmcp/tools/tool_transform.py index e7e0cea06..3d6697fc5 100644 --- a/fastmcp_slim/fastmcp/tools/tool_transform.py +++ b/fastmcp_slim/fastmcp/tools/tool_transform.py @@ -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: diff --git a/tests/tools/tool/test_results.py b/tests/tools/tool/test_results.py index 263ca6c65..fc3668273 100644 --- a/tests/tools/tool/test_results.py +++ b/tests/tools/tool/test_results.py @@ -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] diff --git a/tests/tools/tool_transform/test_tool_transform.py b/tests/tools/tool_transform/test_tool_transform.py index 2e0b7e975..c7a6b88ea 100644 --- a/tests/tools/tool_transform/test_tool_transform.py +++ b/tests/tools/tool_transform/test_tool_transform.py @@ -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: