diff --git a/docs/patterns/composition.mdx b/docs/patterns/composition.mdx index bb1ac9c8b..e18d04273 100644 --- a/docs/patterns/composition.mdx +++ b/docs/patterns/composition.mdx @@ -207,6 +207,8 @@ main_mcp.mount( ### Direct vs. Proxy Mounting + + FastMCP supports two modes for mounting servers: 1. **Direct Mounting** (default): The parent server directly accesses the mounted server's objects in memory for optimal performance and observability. In this mode: diff --git a/docs/servers/fastmcp.mdx b/docs/servers/fastmcp.mdx index 7bdc5b1bd..bd9022b8c 100644 --- a/docs/servers/fastmcp.mdx +++ b/docs/servers/fastmcp.mdx @@ -295,6 +295,40 @@ print(mcp.settings.on_duplicate_tools) # Output: "error" All of these can be configured directly as parameters when creating the `FastMCP` instance. +### Custom Tool Serialization + + + +By default, FastMCP serializes tool return values to JSON when they need to be converted to text. You can customize this behavior by providing a `tool_serializer` function when creating your server: + +```python +import yaml +from fastmcp import FastMCP + +# Define a custom serializer that formats dictionaries as YAML +def yaml_serializer(data): + return yaml.dump(data, sort_keys=False) + +# Create a server with the custom serializer +mcp = FastMCP(name="MyServer", tool_serializer=yaml_serializer) + +@mcp.tool() +def get_config(): + """Returns configuration in YAML format.""" + return {"api_key": "abc123", "debug": True, "rate_limit": 100} +``` + +The serializer function takes any data object and returns a string representation. This is applied to **all non-string return values** from your tools. Tools that already return strings bypass the serializer. + +This customization is useful when you want to: +- Format data in a specific way (like YAML or custom formats) +- Control specific serialization options (like indentation or sorting) +- Add metadata or transform data before sending it to clients + + +If the serializer function raises an exception, the tool will fall back to the default JSON serialization to avoid breaking the server. + + ## Authentication diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index a721eba62..e60dece6a 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -11,6 +11,7 @@ from pydantic import BaseModel, BeforeValidator, Field from fastmcp.exceptions import ToolError from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata +from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import ( Image, _convert_set_defaults, @@ -23,6 +24,12 @@ if TYPE_CHECKING: from fastmcp.server import Context +logger = get_logger(__name__) + + +def default_serializer(data: Any) -> str: + return pydantic_core.to_json(data, fallback=str, indent=2).decode() + class Tool(BaseModel): """Internal tool registration info.""" @@ -182,9 +189,17 @@ def _convert_to_content( return other_content + mcp_types if not isinstance(result, str): - if serializer is not None: - result = serializer(result) + if serializer is None: + result = default_serializer(result) else: - result = pydantic_core.to_json(result, fallback=str, indent=2).decode() + try: + result = serializer(result) + except Exception as e: + logger.warning( + "Error serializing tool result: %s", + e, + exc_info=True, + ) + result = default_serializer(result) return [TextContent(type="text", text=result)] diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index fc7eddddc..6ae73d9e7 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -1,7 +1,9 @@ import json import logging +import uuid from typing import Annotated, Any +import pydantic_core import pytest from mcp.server.session import ServerSessionT from mcp.shared.context import LifespanContextT @@ -415,6 +417,28 @@ class TestCallTools: assert isinstance(result[0], TextContent) assert result[0].text == 'CUSTOM:{"key": "value", "number": 123}' + async def test_custom_serializer_fallback_on_error(self): + """Test that a broken custom serializer gracefully falls back.""" + + uuid_result = uuid.uuid4() + + def custom_serializer(data: Any) -> str: + return json.dumps(data) + + mcp = FastMCP(tool_serializer=custom_serializer) + manager = mcp._tool_manager + + def get_data() -> uuid.UUID: + return uuid_result + + manager.add_tool_from_fn(get_data) + + result = await manager.call_tool("get_data", {}) + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == pydantic_core.to_json(uuid_result).decode() + class TestToolSchema: async def test_context_arg_excluded_from_schema(self):