Repository: jlowin/fastmcp
Pull request #308 (2025-05-03 11:46:24)

Support custom Serializer for Tools
Example for #207

running the example prints:

```
Tool Result:
[TextContent(type='text', text='name: Test\nvalue: 123\nstatus: true\n', annotations=None)]
``` 


Patch:
diff --git a/examples/serializer.py b/examples/serializer.py
new file mode 100644
index 0000000..72e6084
--- /dev/null
+++ b/examples/serializer.py
@@ -0,0 +1,32 @@
+import asyncio
+from typing import Any
+
+import yaml
+
+from fastmcp import FastMCP
+
+
+# Define a simple custom serializer
+def custom_dict_serializer(data: Any) -> str:
+    return yaml.dump(data, width=100, sort_keys=False)
+
+
+server = FastMCP(name="CustomSerializerExample", tool_serializer=custom_dict_serializer)
+
+
+@server.tool()
+def get_example_data() -> dict:
+    """Returns some example data."""
+    return {"name": "Test", "value": 123, "status": True}
+
+
+async def example_usage():
+    result = await server._mcp_call_tool("get_example_data", {})
+    print("Tool Result:")
+    print(result)
+    print("This is an example of using a custom serializer with FastMCP.")
+
+
+if __name__ == "__main__":
+    asyncio.run(example_usage())
+    server.run()
diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py
index c7fb0c0..b98ba82 100644
--- a/src/fastmcp/server/openapi.py
+++ b/src/fastmcp/server/openapi.py
@@ -5,6 +5,7 @@ from __future__ import annotations
 import enum
 import json
 import re
+from collections.abc import Callable
 from dataclasses import dataclass
 from re import Pattern
 from typing import TYPE_CHECKING, Any, Literal
@@ -127,6 +128,7 @@ class OpenAPITool(Tool):
         tags: set[str] = set(),
         timeout: float | None = None,
         annotations: ToolAnnotations | None = None,
+        serializer: Callable[[Any], str] | None = None,
     ):
         super().__init__(
             name=name,
@@ -138,6 +140,7 @@ class OpenAPITool(Tool):
             context_kwarg="context",  # Default context keyword argument
             tags=tags,
             annotations=annotations,
+            serializer=serializer,
         )
         self._client = client
         self._route = route
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index 59cb6f0..4a47a91 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -205,6 +205,7 @@ class FastMCP(Generic[LifespanResultT]):
             | None
         ) = None,
         tags: set[str] | None = None,
+        tool_serializer: Callable[[Any], str] | None = None,
         **settings: Any,
     ):
         self.tags: set[str] = tags or set()
@@ -226,7 +227,8 @@ class FastMCP(Generic[LifespanResultT]):
             lifespan=_lifespan_wrapper(self, lifespan),
         )
         self._tool_manager = ToolManager(
-            duplicate_behavior=self.settings.on_duplicate_tools
+            duplicate_behavior=self.settings.on_duplicate_tools,
+            serializer=tool_serializer,
         )
         self._resource_manager = ResourceManager(
             duplicate_behavior=self.settings.on_duplicate_resources
diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py
index 8e641c3..a721eba 100644
--- a/src/fastmcp/tools/tool.py
+++ b/src/fastmcp/tools/tool.py
@@ -45,6 +45,9 @@ class Tool(BaseModel):
     annotations: ToolAnnotations | None = Field(
         None, description="Additional annotations about the tool"
     )
+    serializer: Callable[[Any], str] | None = Field(
+        None, description="Optional custom serializer for tool results"
+    )
 
     @classmethod
     def from_function(
@@ -55,6 +58,7 @@ class Tool(BaseModel):
         context_kwarg: str | None = None,
         tags: set[str] | None = None,
         annotations: ToolAnnotations | None = None,
+        serializer: Callable[[Any], str] | None = None,
     ) -> Tool:
         """Create a Tool from a function."""
         from fastmcp import Context
@@ -100,6 +104,7 @@ class Tool(BaseModel):
             context_kwarg=context_kwarg,
             tags=tags or set(),
             annotations=annotations,
+            serializer=serializer,
         )
 
     async def run(
@@ -120,7 +125,7 @@ class Tool(BaseModel):
                 arguments_to_validate=arguments,
                 arguments_to_pass_directly=pass_args,
             )
-            return _convert_to_content(result)
+            return _convert_to_content(result, serializer=self.serializer)
         except Exception as e:
             raise ToolError(f"Error executing tool {self.name}: {e}") from e
 
@@ -141,6 +146,7 @@ class Tool(BaseModel):
 
 def _convert_to_content(
     result: Any,
+    serializer: Callable[[Any], str] | None = None,
     _process_as_single_item: bool = False,
 ) -> list[TextContent | ImageContent | EmbeddedResource]:
     """Convert a result to a sequence of content objects."""
@@ -176,6 +182,9 @@ def _convert_to_content(
         return other_content + mcp_types
 
     if not isinstance(result, str):
-        result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
+        if serializer is not None:
+            result = serializer(result)
+        else:
+            result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
 
     return [TextContent(type="text", text=result)]
diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py
index 47a9889..e096fae 100644
--- a/src/fastmcp/tools/tool_manager.py
+++ b/src/fastmcp/tools/tool_manager.py
@@ -22,8 +22,13 @@ logger = get_logger(__name__)
 class ToolManager:
     """Manages FastMCP tools."""
 
-    def __init__(self, duplicate_behavior: DuplicateBehavior | None = None):
+    def __init__(
+        self,
+        duplicate_behavior: DuplicateBehavior | None = None,
+        serializer: Callable[[Any], str] | None = None,
+    ):
         self._tools: dict[str, Tool] = {}
+        self._serializer = serializer
 
         # Default to "warn" if None is provided
         if duplicate_behavior is None:
@@ -70,6 +75,7 @@ class ToolManager:
             description=description,
             tags=tags,
             annotations=annotations,
+            serializer=self._serializer,
         )
         return self.add_tool(tool)
 


Test patch:
diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py
index 64d91a1..fc7eddd 100644
--- a/tests/tools/test_tool_manager.py
+++ b/tests/tools/test_tool_manager.py
@@ -1,6 +1,6 @@
 import json
 import logging
-from typing import Annotated
+from typing import Annotated, Any
 
 import pytest
 from mcp.server.session import ServerSessionT
@@ -392,6 +392,29 @@ class TestCallTools:
         assert isinstance(result[0], TextContent)
         assert result[0].text == '[\n  "rex",\n  "gertrude"\n]'
 
+    async def test_call_tool_with_custom_serializer(self):
+        """Test that a custom serializer provided to FastMCP is used by tools."""
+
+        def custom_serializer(data: Any) -> str:
+            if isinstance(data, dict):
+                return f"CUSTOM:{json.dumps(data)}"
+            return json.dumps(data)
+
+        # Instantiate FastMCP with the custom serializer
+        mcp = FastMCP(tool_serializer=custom_serializer)
+        manager = mcp._tool_manager
+
+        def get_data() -> dict:
+            return {"key": "value", "number": 123}
+
+        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 == 'CUSTOM:{"key": "value", "number": 123}'
+
 
 class TestToolSchema:
     async def test_context_arg_excluded_from_schema(self):