Skip to content

Commit 1e13764

Browse files
authored
Merge pull request #1773 from jlowin/content_block_handling
Cleanup Tool Content Conversion
2 parents fe4f31c + 408415c commit 1e13764

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

@@ -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+
481529
def _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))]

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)