Merge pull request #308 from strawgate/custom-serializer-example

Support custom Serializer for Tools
This commit is contained in:
Jeremiah Lowin 2025-05-03 12:18:20 -04:00 committed by GitHub
commit a553f3871a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 80 additions and 5 deletions

32
examples/serializer.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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