Fix single-element list unwrapping in tool content

Single-element lists like [1] were being incorrectly unwrapped to "1"
in unstructured content while multi-element lists remained as lists.
This created inconsistent behavior where the structure was lost for
single items.

This fix ensures lists always preserve their structure in unstructured
content regardless of length, making behavior consistent and predictable.

Also removes pretty-printing from JSON serialization for more compact
output across tools, prompts, and resources.

Fixes #1064

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeremiah Lowin 2025-07-07 12:07:16 -04:00
commit 0600834da4
11 changed files with 51 additions and 33 deletions

View file

@ -350,9 +350,7 @@ class FunctionPrompt(Prompt):
)
)
else:
content = pydantic_core.to_json(
msg, fallback=str, indent=2
).decode()
content = pydantic_core.to_json(msg, fallback=str).decode()
messages.append(
PromptMessage(
role="user",

View file

@ -192,4 +192,4 @@ class FunctionResource(Resource):
elif isinstance(result, str):
return result
else:
return pydantic_core.to_json(result, fallback=str, indent=2).decode()
return pydantic_core.to_json(result, fallback=str).decode()

View file

@ -46,7 +46,7 @@ class _UnserializableType:
def default_serializer(data: Any) -> str:
return pydantic_core.to_json(data, fallback=str, indent=2).decode()
return pydantic_core.to_json(data, fallback=str).decode()
class ToolResult:
@ -434,6 +434,7 @@ def _convert_to_content(
_process_as_single_item: bool = False,
) -> list[ContentBlock]:
"""Convert a result to a sequence of content objects."""
if result is None:
return []
@ -467,7 +468,7 @@ def _convert_to_content(
if other_content:
other_content = _convert_to_content(
other_content[0] if len(other_content) == 1 else other_content,
other_content,
serializer=serializer,
_process_as_single_item=True,
)

View file

@ -9,7 +9,7 @@ from typing import Any, Literal
from mcp.types import ToolAnnotations
from pydantic import ConfigDict
from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult
from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult, _convert_to_content
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import NotSet, NotSetT, get_cached_typeadapter
@ -233,7 +233,6 @@ class TransformedTool(Tool):
Returns:
ToolResult object containing content and optional structured output.
"""
from fastmcp.tools.tool import _convert_to_content
# Fill in missing arguments with schema defaults to ensure
# ArgTransform defaults take precedence over function defaults
@ -274,7 +273,6 @@ class TransformedTool(Tool):
if isinstance(result, ToolResult):
if self.output_schema is None:
# Check if this is from a custom function that returns ToolResult
import inspect
return_annotation = inspect.signature(self.fn).return_annotation
if return_annotation is ToolResult:
@ -298,7 +296,6 @@ class TransformedTool(Tool):
return result
# Otherwise convert to content and create ToolResult with proper structured content
from fastmcp.tools.tool import _convert_to_content
unstructured_result = _convert_to_content(
result, serializer=self.serializer
@ -433,8 +430,6 @@ class TransformedTool(Tool):
final_output_schema = parsed_fn.output_schema
if final_output_schema is None:
# Check if function returns ToolResult - if so, don't fall back to parent
import inspect
return_annotation = inspect.signature(
transform_fn
).return_annotation

View file

@ -536,9 +536,9 @@ async def test_resource_template(fastmcp_server):
# Check the content matches what we expect for the provided user_id
content_str = str(result[0])
assert '"id": "123"' in content_str
assert '"name": "User 123"' in content_str
assert '"active": true' in content_str
assert '"id":"123"' in content_str
assert '"name":"User 123"' in content_str
assert '"active":true' in content_str
async def test_list_resource_templates_mcp(fastmcp_server):
@ -595,7 +595,7 @@ async def test_template_access_via_client(fastmcp_server):
uri = cast(AnyUrl, "data://user/456")
result = await client.read_resource(uri)
content_str = str(result[0])
assert '"id": "456"' in content_str
assert '"id":"456"' in content_str
async def test_tagged_resource_metadata(tagged_resources_server):
@ -635,8 +635,8 @@ async def test_tagged_template_functionality(tagged_resources_server):
uri = cast(AnyUrl, "template://123")
result = await client.read_resource(uri)
content_str = str(result[0])
assert '"id": "123"' in content_str
assert '"type": "template_data"' in content_str
assert '"id":"123"' in content_str
assert '"type":"template_data"' in content_str
class TestErrorHandling:

View file

@ -67,7 +67,7 @@ class TestFunctionResource:
)
content = await resource.read()
assert isinstance(content, str)
assert '"key": "value"' in content
assert '"key":"value"' in content
async def test_error_handling(self):
"""Test error handling in FunctionResource."""
@ -95,7 +95,7 @@ class TestFunctionResource:
fn=lambda: MyModel(name="test"),
)
content = await resource.read()
assert content == '{\n "name": "test"\n}'
assert content == '{"name":"test"}'
async def test_custom_type_conversion(self):
"""Test handling of custom types."""

View file

@ -170,7 +170,7 @@ class TestTools:
async def test_tool_returns_list(self, tool_server: FastMCP):
async with Client(tool_server) as client:
result = await client.call_tool("list_tool", {})
assert result.content[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined]
assert result.content[0].text == '["x",2]' # type: ignore[attr-defined]
assert result.data == ["x", 2]
async def test_file_text_tool(self, tool_server: FastMCP):

View file

@ -25,7 +25,7 @@ async def test_complex_inputs():
"name_shrimp", {"tank": tank, "extra_names": ["charlie"]}
)
assert len(result.content) == 1
assert result.content[0].text == '[\n "bob",\n "alice",\n "charlie"\n]' # type: ignore[attr-defined]
assert result.content[0].text == '["bob","alice","charlie"]' # type: ignore[attr-defined]
async def test_desktop(monkeypatch):

View file

@ -553,7 +553,7 @@ class TestToolFromFunctionOutputSchema:
# Dict objects automatically become structured content even without schema
assert result.structured_content == {"message": "Hello, world!"}
assert len(result.content) == 1
assert result.content[0].text == '{\n "message": "Hello, world!"\n}' # type: ignore[attr-defined]
assert result.content[0].text == '{"message":"Hello, world!"}' # type: ignore[attr-defined]
async def test_output_schema_none_disables_structured_content(self):
"""Test that output_schema=None explicitly disables structured content."""
@ -607,7 +607,7 @@ class TestToolFromFunctionOutputSchema:
result = await tool.run({})
# Dict result with object schema is used directly
assert result.structured_content == {"value": 42}
assert result.content[0].text == '{\n "value": 42\n}' # type: ignore[attr-defined]
assert result.content[0].text == '{"value":42}' # type: ignore[attr-defined]
async def test_explicit_object_schema_with_non_dict_return_fails(self):
"""Test that explicit object schemas fail when function returns non-dict."""
@ -859,7 +859,7 @@ class TestConvertResultToContent:
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == '{\n "a": 1,\n "b": 2\n}'
assert result[0].text == '{"a":1,"b":2}'
def test_list_of_basic_types(self):
"""Test that a list of basic types is converted to a single TextContent."""
@ -867,7 +867,7 @@ class TestConvertResultToContent:
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == '[\n 1,\n "two",\n {\n "c": 3\n }\n]'
assert result[0].text == '[1,"two",{"c":3}]'
def test_list_of_mcp_types(self):
"""Test that a list of MCP types is returned as a list of those types."""
@ -898,7 +898,7 @@ class TestConvertResultToContent:
assert image_content_count == 1
text_item = next(item for item in result if isinstance(item, TextContent))
assert text_item.text == '{\n "a": 1\n}'
assert text_item.text == '[{"a":1}]'
image_item = next(item for item in result if isinstance(item, ImageContent))
assert image_item.data == "ZmFrZWltYWdlZGF0YQ=="
@ -920,7 +920,7 @@ class TestConvertResultToContent:
assert image_content_count == 1
text_item = next(item for item in result if isinstance(item, TextContent))
assert text_item.text == '[\n {\n "a": 1\n },\n {\n "b": 2\n }\n]'
assert text_item.text == '[[{"a":1},{"b":2}]]'
image_item = next(item for item in result if isinstance(item, ImageContent))
assert image_item.data == "ZmFrZWltYWdlZGF0YQ=="
@ -942,7 +942,7 @@ class TestConvertResultToContent:
assert audio_content_count == 1
text_item = next(item for item in result if isinstance(item, TextContent))
assert text_item.text == '{\n "a": 1\n}'
assert text_item.text == '[{"a":1}]'
audio_item = next(item for item in result if isinstance(item, AudioContent))
assert audio_item.data == "ZmFrZWF1ZGlvZGF0YQ=="
@ -967,7 +967,7 @@ class TestConvertResultToContent:
assert embedded_content_count == 1
text_item = next(item for item in result if isinstance(item, TextContent))
assert text_item.text == '{\n "a": 1\n}'
assert text_item.text == '[{"a":1}]'
embedded_item = next(
item
@ -1031,7 +1031,7 @@ class TestConvertResultToContent:
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == '[\n 1,\n "two",\n {\n "c": 3\n }\n]'
assert result[0].text == '[1,"two",{"c":3}]'
content1 = TextContent(type="text", text="hello")
result = _convert_to_content([1, content1], _process_as_single_item=True)
@ -1044,6 +1044,30 @@ class TestConvertResultToContent:
{"type": "text", "text": "hello", "annotations": None, "_meta": None},
]
def test_single_element_list_preserves_structure(self):
"""Test that single-element lists preserve their list structure."""
# Test with a single integer
result = _convert_to_content([1])
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "[1]" # Should be "[1]", not "1"
# Test with a single string
result = _convert_to_content(["hello"])
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == '["hello"]' # Should be ["hello"], not "hello"
# Test with a single dict
result = _convert_to_content([{"a": 1}])
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == '[{"a":1}]' # Should be wrapped in a list
class TestAutomaticStructuredContent:
"""Tests for automatic structured content generation based on return types."""

View file

@ -489,7 +489,7 @@ class TestCallTools:
},
)
assert result.content[0].text == '[\n "rex",\n "gertrude"\n]' # type: ignore[attr-defined]
assert result.content[0].text == '["rex","gertrude"]' # type: ignore[attr-defined]
assert result.structured_content == {"result": ["rex", "gertrude"]}
async def test_call_tool_with_custom_serializer(self):

View file

@ -1157,7 +1157,7 @@ class TestTransformToolOutputSchema:
result = await new_tool.run({"x": 3})
# Should wrap string result
assert result.structured_content == {"result": 'Custom: {\n "value": 3\n}'}
assert result.structured_content == {"result": 'Custom: {"value":3}'}
def test_transform_custom_function_fallback_to_parent(self, base_string_tool):
"""Test that custom function without output annotation falls back to parent."""