Spaces:
Running
Running
Merge pull request #308 from strawgate/custom-serializer-example
Browse files- examples/serializer.py +32 -0
- src/fastmcp/server/openapi.py +3 -0
- src/fastmcp/server/server.py +3 -1
- src/fastmcp/tools/tool.py +11 -2
- src/fastmcp/tools/tool_manager.py +7 -1
- tests/tools/test_tool_manager.py +24 -1
examples/serializer.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
import yaml
|
| 5 |
+
|
| 6 |
+
from fastmcp import FastMCP
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
# Define a simple custom serializer
|
| 10 |
+
def custom_dict_serializer(data: Any) -> str:
|
| 11 |
+
return yaml.dump(data, width=100, sort_keys=False)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
server = FastMCP(name="CustomSerializerExample", tool_serializer=custom_dict_serializer)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@server.tool()
|
| 18 |
+
def get_example_data() -> dict:
|
| 19 |
+
"""Returns some example data."""
|
| 20 |
+
return {"name": "Test", "value": 123, "status": True}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
async def example_usage():
|
| 24 |
+
result = await server._mcp_call_tool("get_example_data", {})
|
| 25 |
+
print("Tool Result:")
|
| 26 |
+
print(result)
|
| 27 |
+
print("This is an example of using a custom serializer with FastMCP.")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
if __name__ == "__main__":
|
| 31 |
+
asyncio.run(example_usage())
|
| 32 |
+
server.run()
|
src/fastmcp/server/openapi.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
| 5 |
import enum
|
| 6 |
import json
|
| 7 |
import re
|
|
|
|
| 8 |
from dataclasses import dataclass
|
| 9 |
from re import Pattern
|
| 10 |
from typing import TYPE_CHECKING, Any, Literal
|
|
@@ -127,6 +128,7 @@ class OpenAPITool(Tool):
|
|
| 127 |
tags: set[str] = set(),
|
| 128 |
timeout: float | None = None,
|
| 129 |
annotations: ToolAnnotations | None = None,
|
|
|
|
| 130 |
):
|
| 131 |
super().__init__(
|
| 132 |
name=name,
|
|
@@ -138,6 +140,7 @@ class OpenAPITool(Tool):
|
|
| 138 |
context_kwarg="context", # Default context keyword argument
|
| 139 |
tags=tags,
|
| 140 |
annotations=annotations,
|
|
|
|
| 141 |
)
|
| 142 |
self._client = client
|
| 143 |
self._route = route
|
|
|
|
| 5 |
import enum
|
| 6 |
import json
|
| 7 |
import re
|
| 8 |
+
from collections.abc import Callable
|
| 9 |
from dataclasses import dataclass
|
| 10 |
from re import Pattern
|
| 11 |
from typing import TYPE_CHECKING, Any, Literal
|
|
|
|
| 128 |
tags: set[str] = set(),
|
| 129 |
timeout: float | None = None,
|
| 130 |
annotations: ToolAnnotations | None = None,
|
| 131 |
+
serializer: Callable[[Any], str] | None = None,
|
| 132 |
):
|
| 133 |
super().__init__(
|
| 134 |
name=name,
|
|
|
|
| 140 |
context_kwarg="context", # Default context keyword argument
|
| 141 |
tags=tags,
|
| 142 |
annotations=annotations,
|
| 143 |
+
serializer=serializer,
|
| 144 |
)
|
| 145 |
self._client = client
|
| 146 |
self._route = route
|
src/fastmcp/server/server.py
CHANGED
|
@@ -205,6 +205,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 205 |
| None
|
| 206 |
) = None,
|
| 207 |
tags: set[str] | None = None,
|
|
|
|
| 208 |
**settings: Any,
|
| 209 |
):
|
| 210 |
self.tags: set[str] = tags or set()
|
|
@@ -226,7 +227,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 226 |
lifespan=_lifespan_wrapper(self, lifespan),
|
| 227 |
)
|
| 228 |
self._tool_manager = ToolManager(
|
| 229 |
-
duplicate_behavior=self.settings.on_duplicate_tools
|
|
|
|
| 230 |
)
|
| 231 |
self._resource_manager = ResourceManager(
|
| 232 |
duplicate_behavior=self.settings.on_duplicate_resources
|
|
|
|
| 205 |
| None
|
| 206 |
) = None,
|
| 207 |
tags: set[str] | None = None,
|
| 208 |
+
tool_serializer: Callable[[Any], str] | None = None,
|
| 209 |
**settings: Any,
|
| 210 |
):
|
| 211 |
self.tags: set[str] = tags or set()
|
|
|
|
| 227 |
lifespan=_lifespan_wrapper(self, lifespan),
|
| 228 |
)
|
| 229 |
self._tool_manager = ToolManager(
|
| 230 |
+
duplicate_behavior=self.settings.on_duplicate_tools,
|
| 231 |
+
serializer=tool_serializer,
|
| 232 |
)
|
| 233 |
self._resource_manager = ResourceManager(
|
| 234 |
duplicate_behavior=self.settings.on_duplicate_resources
|
src/fastmcp/tools/tool.py
CHANGED
|
@@ -45,6 +45,9 @@ class Tool(BaseModel):
|
|
| 45 |
annotations: ToolAnnotations | None = Field(
|
| 46 |
None, description="Additional annotations about the tool"
|
| 47 |
)
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
@classmethod
|
| 50 |
def from_function(
|
|
@@ -55,6 +58,7 @@ class Tool(BaseModel):
|
|
| 55 |
context_kwarg: str | None = None,
|
| 56 |
tags: set[str] | None = None,
|
| 57 |
annotations: ToolAnnotations | None = None,
|
|
|
|
| 58 |
) -> Tool:
|
| 59 |
"""Create a Tool from a function."""
|
| 60 |
from fastmcp import Context
|
|
@@ -100,6 +104,7 @@ class Tool(BaseModel):
|
|
| 100 |
context_kwarg=context_kwarg,
|
| 101 |
tags=tags or set(),
|
| 102 |
annotations=annotations,
|
|
|
|
| 103 |
)
|
| 104 |
|
| 105 |
async def run(
|
|
@@ -120,7 +125,7 @@ class Tool(BaseModel):
|
|
| 120 |
arguments_to_validate=arguments,
|
| 121 |
arguments_to_pass_directly=pass_args,
|
| 122 |
)
|
| 123 |
-
return _convert_to_content(result)
|
| 124 |
except Exception as e:
|
| 125 |
raise ToolError(f"Error executing tool {self.name}: {e}") from e
|
| 126 |
|
|
@@ -141,6 +146,7 @@ class Tool(BaseModel):
|
|
| 141 |
|
| 142 |
def _convert_to_content(
|
| 143 |
result: Any,
|
|
|
|
| 144 |
_process_as_single_item: bool = False,
|
| 145 |
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
| 146 |
"""Convert a result to a sequence of content objects."""
|
|
@@ -176,6 +182,9 @@ def _convert_to_content(
|
|
| 176 |
return other_content + mcp_types
|
| 177 |
|
| 178 |
if not isinstance(result, str):
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
return [TextContent(type="text", text=result)]
|
|
|
|
| 45 |
annotations: ToolAnnotations | None = Field(
|
| 46 |
None, description="Additional annotations about the tool"
|
| 47 |
)
|
| 48 |
+
serializer: Callable[[Any], str] | None = Field(
|
| 49 |
+
None, description="Optional custom serializer for tool results"
|
| 50 |
+
)
|
| 51 |
|
| 52 |
@classmethod
|
| 53 |
def from_function(
|
|
|
|
| 58 |
context_kwarg: str | None = None,
|
| 59 |
tags: set[str] | None = None,
|
| 60 |
annotations: ToolAnnotations | None = None,
|
| 61 |
+
serializer: Callable[[Any], str] | None = None,
|
| 62 |
) -> Tool:
|
| 63 |
"""Create a Tool from a function."""
|
| 64 |
from fastmcp import Context
|
|
|
|
| 104 |
context_kwarg=context_kwarg,
|
| 105 |
tags=tags or set(),
|
| 106 |
annotations=annotations,
|
| 107 |
+
serializer=serializer,
|
| 108 |
)
|
| 109 |
|
| 110 |
async def run(
|
|
|
|
| 125 |
arguments_to_validate=arguments,
|
| 126 |
arguments_to_pass_directly=pass_args,
|
| 127 |
)
|
| 128 |
+
return _convert_to_content(result, serializer=self.serializer)
|
| 129 |
except Exception as e:
|
| 130 |
raise ToolError(f"Error executing tool {self.name}: {e}") from e
|
| 131 |
|
|
|
|
| 146 |
|
| 147 |
def _convert_to_content(
|
| 148 |
result: Any,
|
| 149 |
+
serializer: Callable[[Any], str] | None = None,
|
| 150 |
_process_as_single_item: bool = False,
|
| 151 |
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
| 152 |
"""Convert a result to a sequence of content objects."""
|
|
|
|
| 182 |
return other_content + mcp_types
|
| 183 |
|
| 184 |
if not isinstance(result, str):
|
| 185 |
+
if serializer is not None:
|
| 186 |
+
result = serializer(result)
|
| 187 |
+
else:
|
| 188 |
+
result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
|
| 189 |
|
| 190 |
return [TextContent(type="text", text=result)]
|
src/fastmcp/tools/tool_manager.py
CHANGED
|
@@ -22,8 +22,13 @@ logger = get_logger(__name__)
|
|
| 22 |
class ToolManager:
|
| 23 |
"""Manages FastMCP tools."""
|
| 24 |
|
| 25 |
-
def __init__(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
self._tools: dict[str, Tool] = {}
|
|
|
|
| 27 |
|
| 28 |
# Default to "warn" if None is provided
|
| 29 |
if duplicate_behavior is None:
|
|
@@ -70,6 +75,7 @@ class ToolManager:
|
|
| 70 |
description=description,
|
| 71 |
tags=tags,
|
| 72 |
annotations=annotations,
|
|
|
|
| 73 |
)
|
| 74 |
return self.add_tool(tool)
|
| 75 |
|
|
|
|
| 22 |
class ToolManager:
|
| 23 |
"""Manages FastMCP tools."""
|
| 24 |
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
duplicate_behavior: DuplicateBehavior | None = None,
|
| 28 |
+
serializer: Callable[[Any], str] | None = None,
|
| 29 |
+
):
|
| 30 |
self._tools: dict[str, Tool] = {}
|
| 31 |
+
self._serializer = serializer
|
| 32 |
|
| 33 |
# Default to "warn" if None is provided
|
| 34 |
if duplicate_behavior is None:
|
|
|
|
| 75 |
description=description,
|
| 76 |
tags=tags,
|
| 77 |
annotations=annotations,
|
| 78 |
+
serializer=self._serializer,
|
| 79 |
)
|
| 80 |
return self.add_tool(tool)
|
| 81 |
|
tests/tools/test_tool_manager.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import json
|
| 2 |
import logging
|
| 3 |
-
from typing import Annotated
|
| 4 |
|
| 5 |
import pytest
|
| 6 |
from mcp.server.session import ServerSessionT
|
|
@@ -392,6 +392,29 @@ class TestCallTools:
|
|
| 392 |
assert isinstance(result[0], TextContent)
|
| 393 |
assert result[0].text == '[\n "rex",\n "gertrude"\n]'
|
| 394 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
|
| 396 |
class TestToolSchema:
|
| 397 |
async def test_context_arg_excluded_from_schema(self):
|
|
|
|
| 1 |
import json
|
| 2 |
import logging
|
| 3 |
+
from typing import Annotated, Any
|
| 4 |
|
| 5 |
import pytest
|
| 6 |
from mcp.server.session import ServerSessionT
|
|
|
|
| 392 |
assert isinstance(result[0], TextContent)
|
| 393 |
assert result[0].text == '[\n "rex",\n "gertrude"\n]'
|
| 394 |
|
| 395 |
+
async def test_call_tool_with_custom_serializer(self):
|
| 396 |
+
"""Test that a custom serializer provided to FastMCP is used by tools."""
|
| 397 |
+
|
| 398 |
+
def custom_serializer(data: Any) -> str:
|
| 399 |
+
if isinstance(data, dict):
|
| 400 |
+
return f"CUSTOM:{json.dumps(data)}"
|
| 401 |
+
return json.dumps(data)
|
| 402 |
+
|
| 403 |
+
# Instantiate FastMCP with the custom serializer
|
| 404 |
+
mcp = FastMCP(tool_serializer=custom_serializer)
|
| 405 |
+
manager = mcp._tool_manager
|
| 406 |
+
|
| 407 |
+
def get_data() -> dict:
|
| 408 |
+
return {"key": "value", "number": 123}
|
| 409 |
+
|
| 410 |
+
manager.add_tool_from_fn(get_data)
|
| 411 |
+
|
| 412 |
+
result = await manager.call_tool("get_data", {})
|
| 413 |
+
assert isinstance(result, list)
|
| 414 |
+
assert len(result) == 1
|
| 415 |
+
assert isinstance(result[0], TextContent)
|
| 416 |
+
assert result[0].text == 'CUSTOM:{"key": "value", "number": 123}'
|
| 417 |
+
|
| 418 |
|
| 419 |
class TestToolSchema:
|
| 420 |
async def test_context_arg_excluded_from_schema(self):
|