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
@@ -168,7 +172,7 @@ def from_function(
168172 annotations : ToolAnnotations | None = None ,
169173 exclude_args : list [str ] | None = None ,
170174 output_schema : dict [str , Any ] | None | NotSetT | Literal [False ] = NotSet ,
171- serializer : Callable [[ Any ], str ] | None = None ,
175+ serializer : ToolResultSerializerType | None = None ,
172176 meta : dict [str , Any ] | None = None ,
173177 enabled : bool | None = None ,
174178 ) -> FunctionTool :
@@ -210,7 +214,7 @@ def from_tool(
210214 tags : set [str ] | None = None ,
211215 annotations : ToolAnnotations | None | NotSetT = NotSet ,
212216 output_schema : dict [str , Any ] | None | NotSetT | Literal [False ] = NotSet ,
213- serializer : Callable [[ Any ], str ] | None = None ,
217+ serializer : ToolResultSerializerType | None = None ,
214218 meta : dict [str , Any ] | None | NotSetT = NotSet ,
215219 transform_args : dict [str , ArgTransform ] | None = None ,
216220 enabled : bool | None = None ,
@@ -248,7 +252,7 @@ def from_function(
248252 annotations : ToolAnnotations | None = None ,
249253 exclude_args : list [str ] | None = None ,
250254 output_schema : dict [str , Any ] | None | NotSetT | Literal [False ] = NotSet ,
251- serializer : Callable [[ Any ], str ] | None = None ,
255+ serializer : ToolResultSerializerType | None = None ,
252256 meta : dict [str , Any ] | None = None ,
253257 enabled : bool | None = None ,
254258 ) -> FunctionTool :
@@ -317,27 +321,33 @@ async def run(self, arguments: dict[str, Any]) -> ToolResult:
317321
318322 unstructured_result = _convert_to_content (result , serializer = self .serializer )
319323
320- structured_output = None
321- # First handle structured content based on output schema, if any
322- if self .output_schema is not None :
323- if self .output_schema .get ("x-fastmcp-wrap-result" ):
324- # Schema says wrap - always wrap in result key
325- structured_output = {"result" : result }
326- else :
327- structured_output = result
328- # If no output schema, try to serialize the result. If it is a dict, use
329- # it as structured content. If it is not a dict, ignore it.
330- if structured_output is None :
324+ if self .output_schema is None :
325+ # Do not produce a structured output for MCP Content Types
326+ if isinstance (result , ContentBlock | Audio | Image | File ) or (
327+ isinstance (result , list | tuple )
328+ and any (isinstance (item , ContentBlock ) for item in result )
329+ ):
330+ return ToolResult (content = unstructured_result )
331+
332+ # Otherwise, try to serialize the result as a dict
331333 try :
332- structured_output = pydantic_core .to_jsonable_python (result )
333- if not isinstance (structured_output , dict ):
334- structured_output = None
335- except Exception :
334+ structured_content = pydantic_core .to_jsonable_python (result )
335+ if isinstance (structured_content , dict ):
336+ return ToolResult (
337+ content = unstructured_result ,
338+ structured_content = structured_content ,
339+ )
340+
341+ except pydantic_core .PydanticSerializationError :
336342 pass
337343
344+ return ToolResult (content = unstructured_result )
345+
346+ wrap_result = self .output_schema .get ("x-fastmcp-wrap-result" )
347+
338348 return ToolResult (
339349 content = unstructured_result ,
340- structured_content = structured_output ,
350+ structured_content = { "result" : result } if wrap_result else result ,
341351 )
342352
343353
@@ -478,98 +488,69 @@ def from_function(
478488 )
479489
480490
491+ def _serialize_with_fallback (
492+ result : Any , serializer : ToolResultSerializerType | None = None
493+ ) -> str :
494+ if serializer is not None :
495+ try :
496+ return serializer (result )
497+ except Exception as e :
498+ logger .warning (
499+ "Error serializing tool result: %s" ,
500+ e ,
501+ exc_info = True ,
502+ )
503+
504+ return default_serializer (result )
505+
506+
507+ def _convert_to_single_content_block (
508+ item : Any ,
509+ serializer : ToolResultSerializerType | None = None ,
510+ ) -> ContentBlock :
511+ if isinstance (item , ContentBlock ):
512+ return item
513+
514+ if isinstance (item , Image ):
515+ return item .to_image_content ()
516+
517+ if isinstance (item , Audio ):
518+ return item .to_audio_content ()
519+
520+ if isinstance (item , File ):
521+ return item .to_resource_content ()
522+
523+ if isinstance (item , str ):
524+ return TextContent (type = "text" , text = item )
525+
526+ return TextContent (type = "text" , text = _serialize_with_fallback (item , serializer ))
527+
528+
481529def _convert_to_content (
482530 result : Any ,
483- serializer : Callable [[Any ], str ] | None = None ,
484- _process_as_single_item : bool = False ,
531+ serializer : ToolResultSerializerType | None = None ,
485532) -> list [ContentBlock ]:
486533 """Convert a result to a sequence of content objects."""
487534
488535 if result is None :
489536 return []
490537
491- if isinstance (result , ContentBlock ):
492- return [result ]
493-
494- if isinstance (result , Image ):
495- return [result .to_image_content ()]
496-
497- elif isinstance (result , Audio ):
498- return [result .to_audio_content ()]
499-
500- elif isinstance (result , File ):
501- return [result .to_resource_content ()]
502-
503- if isinstance (result , list | tuple ) and not _process_as_single_item :
504- # if the result is a list, then it could either be a list of MCP types,
505- # or a "regular" list that the tool is returning, or a mix of both.
506- #
507- # Group adjacent non-MCP types together while preserving order
508-
509- content_items = []
510- non_mcp_batch = []
511-
512- def flush_non_mcp_batch ():
513- """Convert accumulated non-MCP items to a single TextContent block."""
514- if non_mcp_batch :
515- if len (non_mcp_batch ) == 1 :
516- # Single item - convert directly to avoid combining when not needed
517- content_items .extend (
518- _convert_to_content (
519- non_mcp_batch [0 ],
520- serializer = serializer ,
521- _process_as_single_item = True ,
522- )
523- )
524- else :
525- # Multiple items - combine into a single text block
526- combined_text = ""
527- for item in non_mcp_batch :
528- if isinstance (item , str ):
529- combined_text += item
530- else :
531- if serializer is None :
532- combined_text += default_serializer (item )
533- else :
534- try :
535- combined_text += serializer (item )
536- except Exception as e :
537- logger .warning (
538- "Error serializing tool result: %s" ,
539- e ,
540- exc_info = True ,
541- )
542- combined_text += default_serializer (item )
543- content_items .append (TextContent (type = "text" , text = combined_text ))
544- non_mcp_batch .clear ()
545-
546- for item in result :
547- if isinstance (item , ContentBlock | Image | Audio | File ):
548- # Flush any accumulated non-MCP items first
549- flush_non_mcp_batch ()
550- # Add the MCP item
551- content_items .extend (_convert_to_content (item ))
552- else :
553- # Accumulate non-MCP items
554- non_mcp_batch .append (item )
555-
556- # Flush any remaining non-MCP items
557- flush_non_mcp_batch ()
558-
559- return content_items
560-
561- if not isinstance (result , str ):
562- if serializer is None :
563- result = default_serializer (result )
564- else :
565- try :
566- result = serializer (result )
567- except Exception as e :
568- logger .warning (
569- "Error serializing tool result: %s" ,
570- e ,
571- exc_info = True ,
572- )
573- result = default_serializer (result )
574-
575- return [TextContent (type = "text" , text = result )]
538+ if not isinstance (result , (list | tuple )):
539+ return [_convert_to_single_content_block (result , serializer )]
540+
541+ # If all items are ContentBlocks, return them as is
542+ if all (isinstance (item , ContentBlock ) for item in result ):
543+ return result
544+
545+ # If any item is a ContentBlock, convert non-ContentBlock items to TextContent
546+ # without aggregating them
547+ if any (isinstance (item , ContentBlock ) for item in result ):
548+ return [
549+ _convert_to_single_content_block (item , serializer )
550+ if not isinstance (item , ContentBlock )
551+ else item
552+ for item in result
553+ ]
554+
555+ # If none of the items are ContentBlocks, aggregate all items into a single TextContent
556+ return [TextContent (type = "text" , text = _serialize_with_fallback (result , serializer ))]
0 commit comments