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):