mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
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:
parent
fe7155b675
commit
0600834da4
11 changed files with 51 additions and 33 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue