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