1010 Any ,
1111 Generic ,
1212 Literal ,
13+ TypeAlias ,
1314 get_type_hints ,
1415)
1516
@@ -55,6 +56,9 @@ class _UnserializableType:
5556 pass
5657
5758
59+ ToolResultSerializerType : TypeAlias = Callable [[Any ], str ]
60+
61+
5862def default_serializer (data : Any ) -> str :
5963 return pydantic_core .to_json (data , fallback = str ).decode ()
6064
@@ -70,12 +74,12 @@ def __init__(
7074 elif content is None :
7175 content = structured_content
7276
73- self .content = _convert_to_content (content )
77+ self .content : list [ ContentBlock ] = _convert_to_content (result = content )
7478
7579 if structured_content is not None :
7680 try :
7781 structured_content = pydantic_core .to_jsonable_python (
78- structured_content
82+ value = structured_content
7983 )
8084 except pydantic_core .PydanticSerializationError as e :
8185 logger .error (
@@ -112,7 +116,7 @@ class Tool(FastMCPComponent):
112116 Field (description = "Additional annotations about the tool" ),
113117 ] = None
114118 serializer : Annotated [
115- Callable [[ Any ], str ] | None ,
119+ ToolResultSerializerType | None ,
116120 Field (description = "Optional custom serializer for tool results" ),
117121 ] = None
118122
@@ -166,7 +170,7 @@ def from_function(
166170 annotations : ToolAnnotations | None = None ,
167171 exclude_args : list [str ] | None = None ,
168172 output_schema : dict [str , Any ] | None | NotSetT | Literal [False ] = NotSet ,
169- serializer : Callable [[ Any ], str ] | None = None ,
173+ serializer : ToolResultSerializerType | None = None ,
170174 meta : dict [str , Any ] | None = None ,
171175 enabled : bool | None = None ,
172176 ) -> FunctionTool :
@@ -208,7 +212,7 @@ def from_tool(
208212 tags : set [str ] | None = None ,
209213 annotations : ToolAnnotations | None | NotSetT = NotSet ,
210214 output_schema : dict [str , Any ] | None | NotSetT | Literal [False ] = NotSet ,
211- serializer : Callable [[ Any ], str ] | None = None ,
215+ serializer : ToolResultSerializerType | None = None ,
212216 meta : dict [str , Any ] | None | NotSetT = NotSet ,
213217 transform_args : dict [str , ArgTransform ] | None = None ,
214218 enabled : bool | None = None ,
@@ -246,7 +250,7 @@ def from_function(
246250 annotations : ToolAnnotations | None = None ,
247251 exclude_args : list [str ] | None = None ,
248252 output_schema : dict [str , Any ] | None | NotSetT | Literal [False ] = NotSet ,
249- serializer : Callable [[ Any ], str ] | None = None ,
253+ serializer : ToolResultSerializerType | None = None ,
250254 meta : dict [str , Any ] | None = None ,
251255 enabled : bool | None = None ,
252256 ) -> FunctionTool :
@@ -315,27 +319,33 @@ async def run(self, arguments: dict[str, Any]) -> ToolResult:
315319
316320 unstructured_result = _convert_to_content (result , serializer = self .serializer )
317321
318- structured_output = None
319- # First handle structured content based on output schema, if any
320- if self .output_schema is not None :
321- if self .output_schema .get ("x-fastmcp-wrap-result" ):
322- # Schema says wrap - always wrap in result key
323- structured_output = {"result" : result }
324- else :
325- structured_output = result
326- # If no output schema, try to serialize the result. If it is a dict, use
327- # it as structured content. If it is not a dict, ignore it.
328- if structured_output is None :
322+ if self .output_schema is None :
323+ # Do not produce a structured output for MCP Content Types
324+ if isinstance (result , ContentBlock | Audio | Image | File ) or (
325+ isinstance (result , list | tuple )
326+ and any (isinstance (item , ContentBlock ) for item in result )
327+ ):
328+ return ToolResult (content = unstructured_result )
329+
330+ # Otherwise, try to serialize the result as a dict
329331 try :
330- structured_output = pydantic_core .to_jsonable_python (result )
331- if not isinstance (structured_output , dict ):
332- structured_output = None
333- except Exception :
332+ structured_content = pydantic_core .to_jsonable_python (result )
333+ if isinstance (structured_content , dict ):
334+ return ToolResult (
335+ content = unstructured_result ,
336+ structured_content = structured_content ,
337+ )
338+
339+ except pydantic_core .PydanticSerializationError :
334340 pass
335341
342+ return ToolResult (content = unstructured_result )
343+
344+ wrap_result = self .output_schema .get ("x-fastmcp-wrap-result" )
345+
336346 return ToolResult (
337347 content = unstructured_result ,
338- structured_content = structured_output ,
348+ structured_content = { "result" : result } if wrap_result else result ,
339349 )
340350
341351
@@ -476,98 +486,69 @@ def from_function(
476486 )
477487
478488
489+ def _serialize_with_fallback (
490+ result : Any , serializer : ToolResultSerializerType | None = None
491+ ) -> str :
492+ if serializer is not None :
493+ try :
494+ return serializer (result )
495+ except Exception as e :
496+ logger .warning (
497+ "Error serializing tool result: %s" ,
498+ e ,
499+ exc_info = True ,
500+ )
501+
502+ return default_serializer (result )
503+
504+
505+ def _convert_to_single_content_block (
506+ item : Any ,
507+ serializer : ToolResultSerializerType | None = None ,
508+ ) -> ContentBlock :
509+ if isinstance (item , ContentBlock ):
510+ return item
511+
512+ if isinstance (item , Image ):
513+ return item .to_image_content ()
514+
515+ if isinstance (item , Audio ):
516+ return item .to_audio_content ()
517+
518+ if isinstance (item , File ):
519+ return item .to_resource_content ()
520+
521+ if isinstance (item , str ):
522+ return TextContent (type = "text" , text = item )
523+
524+ return TextContent (type = "text" , text = _serialize_with_fallback (item , serializer ))
525+
526+
479527def _convert_to_content (
480528 result : Any ,
481- serializer : Callable [[Any ], str ] | None = None ,
482- _process_as_single_item : bool = False ,
529+ serializer : ToolResultSerializerType | None = None ,
483530) -> list [ContentBlock ]:
484531 """Convert a result to a sequence of content objects."""
485532
486533 if result is None :
487534 return []
488535
489- if isinstance (result , ContentBlock ):
490- return [result ]
491-
492- if isinstance (result , Image ):
493- return [result .to_image_content ()]
494-
495- elif isinstance (result , Audio ):
496- return [result .to_audio_content ()]
497-
498- elif isinstance (result , File ):
499- return [result .to_resource_content ()]
500-
501- if isinstance (result , list | tuple ) and not _process_as_single_item :
502- # if the result is a list, then it could either be a list of MCP types,
503- # or a "regular" list that the tool is returning, or a mix of both.
504- #
505- # Group adjacent non-MCP types together while preserving order
506-
507- content_items = []
508- non_mcp_batch = []
509-
510- def flush_non_mcp_batch ():
511- """Convert accumulated non-MCP items to a single TextContent block."""
512- if non_mcp_batch :
513- if len (non_mcp_batch ) == 1 :
514- # Single item - convert directly to avoid combining when not needed
515- content_items .extend (
516- _convert_to_content (
517- non_mcp_batch [0 ],
518- serializer = serializer ,
519- _process_as_single_item = True ,
520- )
521- )
522- else :
523- # Multiple items - combine into a single text block
524- combined_text = ""
525- for item in non_mcp_batch :
526- if isinstance (item , str ):
527- combined_text += item
528- else :
529- if serializer is None :
530- combined_text += default_serializer (item )
531- else :
532- try :
533- combined_text += serializer (item )
534- except Exception as e :
535- logger .warning (
536- "Error serializing tool result: %s" ,
537- e ,
538- exc_info = True ,
539- )
540- combined_text += default_serializer (item )
541- content_items .append (TextContent (type = "text" , text = combined_text ))
542- non_mcp_batch .clear ()
543-
544- for item in result :
545- if isinstance (item , ContentBlock | Image | Audio | File ):
546- # Flush any accumulated non-MCP items first
547- flush_non_mcp_batch ()
548- # Add the MCP item
549- content_items .extend (_convert_to_content (item ))
550- else :
551- # Accumulate non-MCP items
552- non_mcp_batch .append (item )
553-
554- # Flush any remaining non-MCP items
555- flush_non_mcp_batch ()
556-
557- return content_items
558-
559- if not isinstance (result , str ):
560- if serializer is None :
561- result = default_serializer (result )
562- else :
563- try :
564- result = serializer (result )
565- except Exception as e :
566- logger .warning (
567- "Error serializing tool result: %s" ,
568- e ,
569- exc_info = True ,
570- )
571- result = default_serializer (result )
572-
573- return [TextContent (type = "text" , text = result )]
536+ if not isinstance (result , (list | tuple )):
537+ return [_convert_to_single_content_block (result , serializer )]
538+
539+ # If all items are ContentBlocks, return them as is
540+ if all (isinstance (item , ContentBlock ) for item in result ):
541+ return result
542+
543+ # If any item is a ContentBlock, convert non-ContentBlock items to TextContent
544+ # without aggregating them
545+ if any (isinstance (item , ContentBlock ) for item in result ):
546+ return [
547+ _convert_to_single_content_block (item , serializer )
548+ if not isinstance (item , ContentBlock )
549+ else item
550+ for item in result
551+ ]
552+
553+ # If none of the items are ContentBlocks, aggregate all items into a single TextContent
554+ return [TextContent (type = "text" , text = _serialize_with_fallback (result , serializer ))]
0 commit comments