Jeremiah Lowin commited on
Commit
83a905a
·
unverified ·
2 Parent(s): 75569fa27acfdd

Merge pull request #310 from jlowin/serialize-fallback

Browse files
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,12 @@ if TYPE_CHECKING:
23
 
24
  from fastmcp.server import Context
25
 
 
 
 
 
 
 
26
 
27
  class Tool(BaseModel):
28
  """Internal tool registration info."""
@@ -182,9 +189,17 @@ def _convert_to_content(
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)]
 
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
+ def default_serializer(data: Any) -> str:
31
+ return pydantic_core.to_json(data, fallback=str, indent=2).decode()
32
+
33
 
34
  class Tool(BaseModel):
35
  """Internal tool registration info."""
 
189
  return other_content + mcp_types
190
 
191
  if not isinstance(result, str):
192
+ if serializer is None:
193
+ result = default_serializer(result)
194
  else:
195
+ try:
196
+ result = serializer(result)
197
+ except Exception as e:
198
+ logger.warning(
199
+ "Error serializing tool result: %s",
200
+ e,
201
+ exc_info=True,
202
+ )
203
+ result = default_serializer(result)
204
 
205
  return [TextContent(type="text", text=result)]
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):