From 0600834da4be80f1f77acceea9091eef4b52eb6d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 7 Jul 2025 12:07:16 -0400 Subject: [PATCH] Fix single-element list unwrapping in tool content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/fastmcp/prompts/prompt.py | 4 +-- src/fastmcp/resources/resource.py | 2 +- src/fastmcp/tools/tool.py | 5 +-- src/fastmcp/tools/tool_transform.py | 7 +--- tests/client/test_client.py | 12 +++---- tests/resources/test_function_resources.py | 4 +-- tests/server/test_server_interactions.py | 2 +- tests/test_examples.py | 2 +- tests/tools/test_tool.py | 42 +++++++++++++++++----- tests/tools/test_tool_manager.py | 2 +- tests/tools/test_tool_transform.py | 2 +- 11 files changed, 51 insertions(+), 33 deletions(-) diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index bd159663c..1477fab53 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -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", diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index a88174b8a..b16d0dd89 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -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() diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 6cca87919..e6195a2d3 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -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, ) diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index 0f965c275..88ae17495 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -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 diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 499a3f256..c3bd40261 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -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: diff --git a/tests/resources/test_function_resources.py b/tests/resources/test_function_resources.py index af69896be..c6c0696d2 100644 --- a/tests/resources/test_function_resources.py +++ b/tests/resources/test_function_resources.py @@ -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.""" diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 8d905919e..986fb275a 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -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): diff --git a/tests/test_examples.py b/tests/test_examples.py index 0edeee96e..62fe38f28 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -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): diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index afe86d2ff..93410f6be 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -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.""" diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index 1e3611548..42ef6038e 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -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): diff --git a/tests/tools/test_tool_transform.py b/tests/tools/test_tool_transform.py index e0460d9b6..85fced3f9 100644 --- a/tests/tools/test_tool_transform.py +++ b/tests/tools/test_tool_transform.py @@ -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."""