Merge pull request #310 from jlowin/serialize-fallback

Ensure that tool serialization has a graceful fallback
This commit is contained in:
Jeremiah Lowin 2025-05-03 17:54:40 -04:00 committed by GitHub
commit 752d8ad197
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 78 additions and 3 deletions

View file

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

View file

@ -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" />

View file

@ -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)]

View file

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