Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
969a58f
1
Parent(s): 75569fa
Ensure that tool serialization has a graceful fallback
Browse files- docs/patterns/composition.mdx +2 -0
- docs/servers/fastmcp.mdx +34 -0
- src/fastmcp/tools/tool.py +13 -1
- tests/tools/test_tool_manager.py +24 -0
docs/patterns/composition.mdx
CHANGED
|
@@ -207,6 +207,8 @@ main_mcp.mount(
|
|
| 207 |
|
| 208 |
### Direct vs. Proxy Mounting
|
| 209 |
|
|
|
|
|
|
|
| 210 |
FastMCP supports two modes for mounting servers:
|
| 211 |
|
| 212 |
1. **Direct Mounting** (default): The parent server directly accesses the mounted server's objects in memory for optimal performance and observability. In this mode:
|
|
|
|
| 207 |
|
| 208 |
### Direct vs. Proxy Mounting
|
| 209 |
|
| 210 |
+
<VersionBadge version="2.2.7" />
|
| 211 |
+
|
| 212 |
FastMCP supports two modes for mounting servers:
|
| 213 |
|
| 214 |
1. **Direct Mounting** (default): The parent server directly accesses the mounted server's objects in memory for optimal performance and observability. In this mode:
|
docs/servers/fastmcp.mdx
CHANGED
|
@@ -295,6 +295,40 @@ print(mcp.settings.on_duplicate_tools) # Output: "error"
|
|
| 295 |
|
| 296 |
All of these can be configured directly as parameters when creating the `FastMCP` instance.
|
| 297 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
## Authentication
|
| 299 |
|
| 300 |
<VersionBadge version="2.2.7" />
|
|
|
|
| 295 |
|
| 296 |
All of these can be configured directly as parameters when creating the `FastMCP` instance.
|
| 297 |
|
| 298 |
+
### Custom Tool Serialization
|
| 299 |
+
|
| 300 |
+
<VersionBadge version="2.2.7" />
|
| 301 |
+
|
| 302 |
+
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:
|
| 303 |
+
|
| 304 |
+
```python
|
| 305 |
+
import yaml
|
| 306 |
+
from fastmcp import FastMCP
|
| 307 |
+
|
| 308 |
+
# Define a custom serializer that formats dictionaries as YAML
|
| 309 |
+
def yaml_serializer(data):
|
| 310 |
+
return yaml.dump(data, sort_keys=False)
|
| 311 |
+
|
| 312 |
+
# Create a server with the custom serializer
|
| 313 |
+
mcp = FastMCP(name="MyServer", tool_serializer=yaml_serializer)
|
| 314 |
+
|
| 315 |
+
@mcp.tool()
|
| 316 |
+
def get_config():
|
| 317 |
+
"""Returns configuration in YAML format."""
|
| 318 |
+
return {"api_key": "abc123", "debug": True, "rate_limit": 100}
|
| 319 |
+
```
|
| 320 |
+
|
| 321 |
+
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.
|
| 322 |
+
|
| 323 |
+
This customization is useful when you want to:
|
| 324 |
+
- Format data in a specific way (like YAML or custom formats)
|
| 325 |
+
- Control specific serialization options (like indentation or sorting)
|
| 326 |
+
- Add metadata or transform data before sending it to clients
|
| 327 |
+
|
| 328 |
+
<Tip>
|
| 329 |
+
If the serializer function raises an exception, the tool will fall back to the default JSON serialization to avoid breaking the server.
|
| 330 |
+
</Tip>
|
| 331 |
+
|
| 332 |
## Authentication
|
| 333 |
|
| 334 |
<VersionBadge version="2.2.7" />
|
src/fastmcp/tools/tool.py
CHANGED
|
@@ -11,6 +11,7 @@ from pydantic import BaseModel, BeforeValidator, Field
|
|
| 11 |
|
| 12 |
from fastmcp.exceptions import ToolError
|
| 13 |
from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
|
|
|
|
| 14 |
from fastmcp.utilities.types import (
|
| 15 |
Image,
|
| 16 |
_convert_set_defaults,
|
|
@@ -23,6 +24,8 @@ if TYPE_CHECKING:
|
|
| 23 |
|
| 24 |
from fastmcp.server import Context
|
| 25 |
|
|
|
|
|
|
|
| 26 |
|
| 27 |
class Tool(BaseModel):
|
| 28 |
"""Internal tool registration info."""
|
|
@@ -183,7 +186,16 @@ def _convert_to_content(
|
|
| 183 |
|
| 184 |
if not isinstance(result, str):
|
| 185 |
if serializer is not None:
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
else:
|
| 188 |
result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
|
| 189 |
|
|
|
|
| 11 |
|
| 12 |
from fastmcp.exceptions import ToolError
|
| 13 |
from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
|
| 14 |
+
from fastmcp.utilities.logging import get_logger
|
| 15 |
from fastmcp.utilities.types import (
|
| 16 |
Image,
|
| 17 |
_convert_set_defaults,
|
|
|
|
| 24 |
|
| 25 |
from fastmcp.server import Context
|
| 26 |
|
| 27 |
+
logger = get_logger(__name__)
|
| 28 |
+
|
| 29 |
|
| 30 |
class Tool(BaseModel):
|
| 31 |
"""Internal tool registration info."""
|
|
|
|
| 186 |
|
| 187 |
if not isinstance(result, str):
|
| 188 |
if serializer is not None:
|
| 189 |
+
try:
|
| 190 |
+
result = serializer(result)
|
| 191 |
+
except Exception as e:
|
| 192 |
+
logger.warning(
|
| 193 |
+
"Error serializing tool result: %s",
|
| 194 |
+
e,
|
| 195 |
+
exc_info=True,
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
|
| 199 |
else:
|
| 200 |
result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
|
| 201 |
|
tests/tools/test_tool_manager.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
import json
|
| 2 |
import logging
|
|
|
|
| 3 |
from typing import Annotated, Any
|
| 4 |
|
|
|
|
| 5 |
import pytest
|
| 6 |
from mcp.server.session import ServerSessionT
|
| 7 |
from mcp.shared.context import LifespanContextT
|
|
@@ -415,6 +417,28 @@ class TestCallTools:
|
|
| 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):
|
|
|
|
| 1 |
import json
|
| 2 |
import logging
|
| 3 |
+
import uuid
|
| 4 |
from typing import Annotated, Any
|
| 5 |
|
| 6 |
+
import pydantic_core
|
| 7 |
import pytest
|
| 8 |
from mcp.server.session import ServerSessionT
|
| 9 |
from mcp.shared.context import LifespanContextT
|
|
|
|
| 417 |
assert isinstance(result[0], TextContent)
|
| 418 |
assert result[0].text == 'CUSTOM:{"key": "value", "number": 123}'
|
| 419 |
|
| 420 |
+
async def test_custom_serializer_fallback_on_error(self):
|
| 421 |
+
"""Test that a broken custom serializer gracefully falls back."""
|
| 422 |
+
|
| 423 |
+
uuid_result = uuid.uuid4()
|
| 424 |
+
|
| 425 |
+
def custom_serializer(data: Any) -> str:
|
| 426 |
+
return json.dumps(data)
|
| 427 |
+
|
| 428 |
+
mcp = FastMCP(tool_serializer=custom_serializer)
|
| 429 |
+
manager = mcp._tool_manager
|
| 430 |
+
|
| 431 |
+
def get_data() -> uuid.UUID:
|
| 432 |
+
return uuid_result
|
| 433 |
+
|
| 434 |
+
manager.add_tool_from_fn(get_data)
|
| 435 |
+
|
| 436 |
+
result = await manager.call_tool("get_data", {})
|
| 437 |
+
assert isinstance(result, list)
|
| 438 |
+
assert len(result) == 1
|
| 439 |
+
assert isinstance(result[0], TextContent)
|
| 440 |
+
assert result[0].text == pydantic_core.to_json(uuid_result).decode()
|
| 441 |
+
|
| 442 |
|
| 443 |
class TestToolSchema:
|
| 444 |
async def test_context_arg_excluded_from_schema(self):
|