Skip to content

Commit 64010ff

Browse files
committed
Cleanup content conversion
1 parent 3dd7373 commit 64010ff

File tree

5 files changed

+385
-480
lines changed

5 files changed

+385
-480
lines changed

src/fastmcp/tools/tool.py

Lines changed: 90 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
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+
5862
def 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+
479527
def _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))]

tests/server/test_server_interactions.py

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import Annotated, Any, Literal
99

1010
import pytest
11+
from inline_snapshot import snapshot
1112
from mcp import McpError
1213
from mcp.types import (
1314
AudioContent,
@@ -21,6 +22,7 @@
2122
from typing_extensions import TypedDict
2223

2324
from fastmcp import Client, Context, FastMCP
25+
from fastmcp.client.client import CallToolResult
2426
from fastmcp.client.transports import FastMCPTransport
2527
from fastmcp.exceptions import ToolError
2628
from fastmcp.prompts.prompt import Prompt, PromptMessage
@@ -187,7 +189,7 @@ async def test_tool_returns_list(self, tool_server: FastMCP):
187189
result = await client.call_tool("list_tool", {})
188190
# Adjacent non-MCP list items are combined into single content block
189191
assert len(result.content) == 1
190-
assert result.content[0].text == "x2" # type: ignore[attr-defined]
192+
assert result.content[0].text == '["x",2]' # type: ignore[attr-defined]
191193
assert result.data == ["x", 2]
192194

193195
async def test_file_text_tool(self, tool_server: FastMCP):
@@ -1157,20 +1159,37 @@ def mixed_output() -> list[Any]:
11571159
result = await client.call_tool("mixed_output", {})
11581160

11591161
# Should have multiple content blocks
1160-
assert len(result.content) >= 2
1161-
1162-
# Should have structured output with wrapped result
1163-
expected_data = [
1164-
"text message",
1165-
{"structured": "data"},
1166-
{
1167-
"type": "text",
1168-
"text": "direct MCP content",
1169-
"annotations": None,
1170-
"_meta": None,
1171-
},
1172-
]
1173-
assert result.structured_content == {"result": expected_data}
1162+
assert result == snapshot(
1163+
CallToolResult(
1164+
content=[
1165+
TextContent(type="text", text="text message"),
1166+
TextContent(type="text", text='{"structured":"data"}'),
1167+
TextContent(type="text", text="direct MCP content"),
1168+
],
1169+
structured_content={
1170+
"result": [
1171+
"text message",
1172+
{"structured": "data"},
1173+
{
1174+
"type": "text",
1175+
"text": "direct MCP content",
1176+
"annotations": None,
1177+
"_meta": None,
1178+
},
1179+
]
1180+
},
1181+
data=[
1182+
"text message",
1183+
{"structured": "data"},
1184+
{
1185+
"type": "text",
1186+
"text": "direct MCP content",
1187+
"annotations": None,
1188+
"_meta": None,
1189+
},
1190+
],
1191+
)
1192+
)
11741193

11751194
async def test_output_schema_serialization_edge_cases(self):
11761195
"""Test edge cases in output schema serialization."""

0 commit comments

Comments
 (0)