diff --git a/fastmcp_slim/fastmcp/tools/base.py b/fastmcp_slim/fastmcp/tools/base.py index ccba1f365..9e11ad5c9 100644 --- a/fastmcp_slim/fastmcp/tools/base.py +++ b/fastmcp_slim/fastmcp/tools/base.py @@ -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): diff --git a/fastmcp_slim/fastmcp/tools/function_parsing.py b/fastmcp_slim/fastmcp/tools/function_parsing.py index 5972e7d0d..72d594794 100644 --- a/fastmcp_slim/fastmcp/tools/function_parsing.py +++ b/fastmcp_slim/fastmcp/tools/function_parsing.py @@ -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: diff --git a/fastmcp_slim/fastmcp/tools/function_tool.py b/fastmcp_slim/fastmcp/tools/function_tool.py index 4204679a8..e5787f466 100644 --- a/fastmcp_slim/fastmcp/tools/function_tool.py +++ b/fastmcp_slim/fastmcp/tools/function_tool.py @@ -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( diff --git a/fastmcp_slim/fastmcp/tools/tool_transform.py b/fastmcp_slim/fastmcp/tools/tool_transform.py index 2ac0a2dc3..436f3f2d3 100644 --- a/fastmcp_slim/fastmcp/tools/tool_transform.py +++ b/fastmcp_slim/fastmcp/tools/tool_transform.py @@ -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, diff --git a/tests/tools/tool/test_output_schema.py b/tests/tools/tool/test_output_schema.py index a3b745c4e..c650134d9 100644 --- a/tests/tools/tool/test_output_schema.py +++ b/tests/tools/tool/test_output_schema.py @@ -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): diff --git a/tests/tools/tool/test_results.py b/tests/tools/tool/test_results.py index ebe26700b..9edcbb327 100644 --- a/tests/tools/tool/test_results.py +++ b/tests/tools/tool/test_results.py @@ -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] diff --git a/tests/tools/tool_transform/test_tool_transform.py b/tests/tools/tool_transform/test_tool_transform.py index 2cde40b7f..3dc89239b 100644 --- a/tests/tools/tool_transform/test_tool_transform.py +++ b/tests/tools/tool_transform/test_tool_transform.py @@ -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: