Repository: jlowin/fastmcp
Pull request #310 (2025-05-03 16:31:30)

Ensure that tool serialization has a graceful fallback
Currently if a tool_serializer is provided that doesn't handle the returned tool result (e.g. a non-JSONable object), it errors and breaks the server. This falls back to the default in that case to avoid breaking.


Patch:
diff --git a/docs/patterns/composition.mdx b/docs/patterns/composition.mdx
index bb1ac9c..e18d042 100644
--- a/docs/patterns/composition.mdx
+++ b/docs/patterns/composition.mdx
@@ -207,6 +207,8 @@ main_mcp.mount(
 
 ### Direct vs. Proxy Mounting
 
+<VersionBadge version="2.2.7" />
+
 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 7bdc5b1..bd9022b 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
+
+<VersionBadge version="2.2.7" />
+
+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
+
+<Tip>
+If the serializer function raises an exception, the tool will fall back to the default JSON serialization to avoid breaking the server.
+</Tip>
+
 ## Authentication
 
 <VersionBadge version="2.2.7" />
diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py
index a721eba..e60dece 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)]


Test patch:
diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py
index fc7eddd..6ae73d9 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):