Jeremiah Lowin commited on
Commit
64d1b4e
·
unverified ·
2 Parent(s): 1457b6a007c3be

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

Browse files
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
- result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
 
 
 
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__(self, duplicate_behavior: DuplicateBehavior | None = None):
 
 
 
 
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):