From d10e426d035435c3281388d2b70982029847ae94 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sat, 3 May 2025 12:30:41 -0400
Subject: [PATCH 1/2] Ensure that tool serialization has a graceful fallback
---
docs/patterns/composition.mdx | 2 ++
docs/servers/fastmcp.mdx | 34 ++++++++++++++++++++++++++++++++
src/fastmcp/tools/tool.py | 14 ++++++++++++-
tests/tools/test_tool_manager.py | 24 ++++++++++++++++++++++
4 files changed, 73 insertions(+), 1 deletion(-)
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..b4be29bea 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,8 @@ if TYPE_CHECKING:
from fastmcp.server import Context
+logger = get_logger(__name__)
+
class Tool(BaseModel):
"""Internal tool registration info."""
@@ -183,7 +186,16 @@ def _convert_to_content(
if not isinstance(result, str):
if serializer is not None:
- result = serializer(result)
+ try:
+ result = serializer(result)
+ except Exception as e:
+ logger.warning(
+ "Error serializing tool result: %s",
+ e,
+ exc_info=True,
+ )
+
+ result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
else:
result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
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):
From 616996f5ee9c7689c5b9b2520aa05b86a0740a0a Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sat, 3 May 2025 17:51:54 -0400
Subject: [PATCH 2/2] Update tool.py
---
src/fastmcp/tools/tool.py | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py
index b4be29bea..e60dece6a 100644
--- a/src/fastmcp/tools/tool.py
+++ b/src/fastmcp/tools/tool.py
@@ -27,6 +27,10 @@ if TYPE_CHECKING:
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."""
@@ -185,7 +189,9 @@ def _convert_to_content(
return other_content + mcp_types
if not isinstance(result, str):
- if serializer is not None:
+ if serializer is None:
+ result = default_serializer(result)
+ else:
try:
result = serializer(result)
except Exception as e:
@@ -194,9 +200,6 @@ def _convert_to_content(
e,
exc_info=True,
)
-
- result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
- else:
- result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
+ result = default_serializer(result)
return [TextContent(type="text", text=result)]