Jeremiah Lowin Claude commited on
Commit
ff32168
·
1 Parent(s): 6fe9500

Fix single-element list unwrapping in tool content

Browse files

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>

src/fastmcp/prompts/prompt.py CHANGED
@@ -350,9 +350,7 @@ class FunctionPrompt(Prompt):
350
  )
351
  )
352
  else:
353
- content = pydantic_core.to_json(
354
- msg, fallback=str, indent=2
355
- ).decode()
356
  messages.append(
357
  PromptMessage(
358
  role="user",
 
350
  )
351
  )
352
  else:
353
+ content = pydantic_core.to_json(msg, fallback=str).decode()
 
 
354
  messages.append(
355
  PromptMessage(
356
  role="user",
src/fastmcp/resources/resource.py CHANGED
@@ -192,4 +192,4 @@ class FunctionResource(Resource):
192
  elif isinstance(result, str):
193
  return result
194
  else:
195
- return pydantic_core.to_json(result, fallback=str, indent=2).decode()
 
192
  elif isinstance(result, str):
193
  return result
194
  else:
195
+ return pydantic_core.to_json(result, fallback=str).decode()
src/fastmcp/tools/tool.py CHANGED
@@ -46,7 +46,7 @@ class _UnserializableType:
46
 
47
 
48
  def default_serializer(data: Any) -> str:
49
- return pydantic_core.to_json(data, fallback=str, indent=2).decode()
50
 
51
 
52
  class ToolResult:
@@ -434,6 +434,7 @@ def _convert_to_content(
434
  _process_as_single_item: bool = False,
435
  ) -> list[ContentBlock]:
436
  """Convert a result to a sequence of content objects."""
 
437
  if result is None:
438
  return []
439
 
@@ -467,7 +468,7 @@ def _convert_to_content(
467
 
468
  if other_content:
469
  other_content = _convert_to_content(
470
- other_content[0] if len(other_content) == 1 else other_content,
471
  serializer=serializer,
472
  _process_as_single_item=True,
473
  )
 
46
 
47
 
48
  def default_serializer(data: Any) -> str:
49
+ return pydantic_core.to_json(data, fallback=str).decode()
50
 
51
 
52
  class ToolResult:
 
434
  _process_as_single_item: bool = False,
435
  ) -> list[ContentBlock]:
436
  """Convert a result to a sequence of content objects."""
437
+
438
  if result is None:
439
  return []
440
 
 
468
 
469
  if other_content:
470
  other_content = _convert_to_content(
471
+ other_content,
472
  serializer=serializer,
473
  _process_as_single_item=True,
474
  )
src/fastmcp/tools/tool_transform.py CHANGED
@@ -9,7 +9,7 @@ from typing import Any, Literal
9
  from mcp.types import ToolAnnotations
10
  from pydantic import ConfigDict
11
 
12
- from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult
13
  from fastmcp.utilities.logging import get_logger
14
  from fastmcp.utilities.types import NotSet, NotSetT, get_cached_typeadapter
15
 
@@ -233,7 +233,6 @@ class TransformedTool(Tool):
233
  Returns:
234
  ToolResult object containing content and optional structured output.
235
  """
236
- from fastmcp.tools.tool import _convert_to_content
237
 
238
  # Fill in missing arguments with schema defaults to ensure
239
  # ArgTransform defaults take precedence over function defaults
@@ -274,7 +273,6 @@ class TransformedTool(Tool):
274
  if isinstance(result, ToolResult):
275
  if self.output_schema is None:
276
  # Check if this is from a custom function that returns ToolResult
277
- import inspect
278
 
279
  return_annotation = inspect.signature(self.fn).return_annotation
280
  if return_annotation is ToolResult:
@@ -298,7 +296,6 @@ class TransformedTool(Tool):
298
  return result
299
 
300
  # Otherwise convert to content and create ToolResult with proper structured content
301
- from fastmcp.tools.tool import _convert_to_content
302
 
303
  unstructured_result = _convert_to_content(
304
  result, serializer=self.serializer
@@ -433,8 +430,6 @@ class TransformedTool(Tool):
433
  final_output_schema = parsed_fn.output_schema
434
  if final_output_schema is None:
435
  # Check if function returns ToolResult - if so, don't fall back to parent
436
- import inspect
437
-
438
  return_annotation = inspect.signature(
439
  transform_fn
440
  ).return_annotation
 
9
  from mcp.types import ToolAnnotations
10
  from pydantic import ConfigDict
11
 
12
+ from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult, _convert_to_content
13
  from fastmcp.utilities.logging import get_logger
14
  from fastmcp.utilities.types import NotSet, NotSetT, get_cached_typeadapter
15
 
 
233
  Returns:
234
  ToolResult object containing content and optional structured output.
235
  """
 
236
 
237
  # Fill in missing arguments with schema defaults to ensure
238
  # ArgTransform defaults take precedence over function defaults
 
273
  if isinstance(result, ToolResult):
274
  if self.output_schema is None:
275
  # Check if this is from a custom function that returns ToolResult
 
276
 
277
  return_annotation = inspect.signature(self.fn).return_annotation
278
  if return_annotation is ToolResult:
 
296
  return result
297
 
298
  # Otherwise convert to content and create ToolResult with proper structured content
 
299
 
300
  unstructured_result = _convert_to_content(
301
  result, serializer=self.serializer
 
430
  final_output_schema = parsed_fn.output_schema
431
  if final_output_schema is None:
432
  # Check if function returns ToolResult - if so, don't fall back to parent
 
 
433
  return_annotation = inspect.signature(
434
  transform_fn
435
  ).return_annotation
tests/client/test_client.py CHANGED
@@ -536,9 +536,9 @@ async def test_resource_template(fastmcp_server):
536
 
537
  # Check the content matches what we expect for the provided user_id
538
  content_str = str(result[0])
539
- assert '"id": "123"' in content_str
540
- assert '"name": "User 123"' in content_str
541
- assert '"active": true' in content_str
542
 
543
 
544
  async def test_list_resource_templates_mcp(fastmcp_server):
@@ -595,7 +595,7 @@ async def test_template_access_via_client(fastmcp_server):
595
  uri = cast(AnyUrl, "data://user/456")
596
  result = await client.read_resource(uri)
597
  content_str = str(result[0])
598
- assert '"id": "456"' in content_str
599
 
600
 
601
  async def test_tagged_resource_metadata(tagged_resources_server):
@@ -635,8 +635,8 @@ async def test_tagged_template_functionality(tagged_resources_server):
635
  uri = cast(AnyUrl, "template://123")
636
  result = await client.read_resource(uri)
637
  content_str = str(result[0])
638
- assert '"id": "123"' in content_str
639
- assert '"type": "template_data"' in content_str
640
 
641
 
642
  class TestErrorHandling:
 
536
 
537
  # Check the content matches what we expect for the provided user_id
538
  content_str = str(result[0])
539
+ assert '"id":"123"' in content_str
540
+ assert '"name":"User 123"' in content_str
541
+ assert '"active":true' in content_str
542
 
543
 
544
  async def test_list_resource_templates_mcp(fastmcp_server):
 
595
  uri = cast(AnyUrl, "data://user/456")
596
  result = await client.read_resource(uri)
597
  content_str = str(result[0])
598
+ assert '"id":"456"' in content_str
599
 
600
 
601
  async def test_tagged_resource_metadata(tagged_resources_server):
 
635
  uri = cast(AnyUrl, "template://123")
636
  result = await client.read_resource(uri)
637
  content_str = str(result[0])
638
+ assert '"id":"123"' in content_str
639
+ assert '"type":"template_data"' in content_str
640
 
641
 
642
  class TestErrorHandling:
tests/resources/test_function_resources.py CHANGED
@@ -67,7 +67,7 @@ class TestFunctionResource:
67
  )
68
  content = await resource.read()
69
  assert isinstance(content, str)
70
- assert '"key": "value"' in content
71
 
72
  async def test_error_handling(self):
73
  """Test error handling in FunctionResource."""
@@ -95,7 +95,7 @@ class TestFunctionResource:
95
  fn=lambda: MyModel(name="test"),
96
  )
97
  content = await resource.read()
98
- assert content == '{\n "name": "test"\n}'
99
 
100
  async def test_custom_type_conversion(self):
101
  """Test handling of custom types."""
 
67
  )
68
  content = await resource.read()
69
  assert isinstance(content, str)
70
+ assert '"key":"value"' in content
71
 
72
  async def test_error_handling(self):
73
  """Test error handling in FunctionResource."""
 
95
  fn=lambda: MyModel(name="test"),
96
  )
97
  content = await resource.read()
98
+ assert content == '{"name":"test"}'
99
 
100
  async def test_custom_type_conversion(self):
101
  """Test handling of custom types."""
tests/server/test_server_interactions.py CHANGED
@@ -170,7 +170,7 @@ class TestTools:
170
  async def test_tool_returns_list(self, tool_server: FastMCP):
171
  async with Client(tool_server) as client:
172
  result = await client.call_tool("list_tool", {})
173
- assert result.content[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined]
174
  assert result.data == ["x", 2]
175
 
176
  async def test_file_text_tool(self, tool_server: FastMCP):
 
170
  async def test_tool_returns_list(self, tool_server: FastMCP):
171
  async with Client(tool_server) as client:
172
  result = await client.call_tool("list_tool", {})
173
+ assert result.content[0].text == '["x",2]' # type: ignore[attr-defined]
174
  assert result.data == ["x", 2]
175
 
176
  async def test_file_text_tool(self, tool_server: FastMCP):
tests/test_examples.py CHANGED
@@ -25,7 +25,7 @@ async def test_complex_inputs():
25
  "name_shrimp", {"tank": tank, "extra_names": ["charlie"]}
26
  )
27
  assert len(result.content) == 1
28
- assert result.content[0].text == '[\n "bob",\n "alice",\n "charlie"\n]' # type: ignore[attr-defined]
29
 
30
 
31
  async def test_desktop(monkeypatch):
 
25
  "name_shrimp", {"tank": tank, "extra_names": ["charlie"]}
26
  )
27
  assert len(result.content) == 1
28
+ assert result.content[0].text == '["bob","alice","charlie"]' # type: ignore[attr-defined]
29
 
30
 
31
  async def test_desktop(monkeypatch):
tests/tools/test_tool.py CHANGED
@@ -553,7 +553,7 @@ class TestToolFromFunctionOutputSchema:
553
  # Dict objects automatically become structured content even without schema
554
  assert result.structured_content == {"message": "Hello, world!"}
555
  assert len(result.content) == 1
556
- assert result.content[0].text == '{\n "message": "Hello, world!"\n}' # type: ignore[attr-defined]
557
 
558
  async def test_output_schema_none_disables_structured_content(self):
559
  """Test that output_schema=None explicitly disables structured content."""
@@ -607,7 +607,7 @@ class TestToolFromFunctionOutputSchema:
607
  result = await tool.run({})
608
  # Dict result with object schema is used directly
609
  assert result.structured_content == {"value": 42}
610
- assert result.content[0].text == '{\n "value": 42\n}' # type: ignore[attr-defined]
611
 
612
  async def test_explicit_object_schema_with_non_dict_return_fails(self):
613
  """Test that explicit object schemas fail when function returns non-dict."""
@@ -859,7 +859,7 @@ class TestConvertResultToContent:
859
  assert isinstance(result, list)
860
  assert len(result) == 1
861
  assert isinstance(result[0], TextContent)
862
- assert result[0].text == '{\n "a": 1,\n "b": 2\n}'
863
 
864
  def test_list_of_basic_types(self):
865
  """Test that a list of basic types is converted to a single TextContent."""
@@ -867,7 +867,7 @@ class TestConvertResultToContent:
867
  assert isinstance(result, list)
868
  assert len(result) == 1
869
  assert isinstance(result[0], TextContent)
870
- assert result[0].text == '[\n 1,\n "two",\n {\n "c": 3\n }\n]'
871
 
872
  def test_list_of_mcp_types(self):
873
  """Test that a list of MCP types is returned as a list of those types."""
@@ -898,7 +898,7 @@ class TestConvertResultToContent:
898
  assert image_content_count == 1
899
 
900
  text_item = next(item for item in result if isinstance(item, TextContent))
901
- assert text_item.text == '{\n "a": 1\n}'
902
 
903
  image_item = next(item for item in result if isinstance(item, ImageContent))
904
  assert image_item.data == "ZmFrZWltYWdlZGF0YQ=="
@@ -920,7 +920,7 @@ class TestConvertResultToContent:
920
  assert image_content_count == 1
921
 
922
  text_item = next(item for item in result if isinstance(item, TextContent))
923
- assert text_item.text == '[\n {\n "a": 1\n },\n {\n "b": 2\n }\n]'
924
 
925
  image_item = next(item for item in result if isinstance(item, ImageContent))
926
  assert image_item.data == "ZmFrZWltYWdlZGF0YQ=="
@@ -942,7 +942,7 @@ class TestConvertResultToContent:
942
  assert audio_content_count == 1
943
 
944
  text_item = next(item for item in result if isinstance(item, TextContent))
945
- assert text_item.text == '{\n "a": 1\n}'
946
 
947
  audio_item = next(item for item in result if isinstance(item, AudioContent))
948
  assert audio_item.data == "ZmFrZWF1ZGlvZGF0YQ=="
@@ -967,7 +967,7 @@ class TestConvertResultToContent:
967
  assert embedded_content_count == 1
968
 
969
  text_item = next(item for item in result if isinstance(item, TextContent))
970
- assert text_item.text == '{\n "a": 1\n}'
971
 
972
  embedded_item = next(
973
  item
@@ -1031,7 +1031,7 @@ class TestConvertResultToContent:
1031
  assert isinstance(result, list)
1032
  assert len(result) == 1
1033
  assert isinstance(result[0], TextContent)
1034
- assert result[0].text == '[\n 1,\n "two",\n {\n "c": 3\n }\n]'
1035
 
1036
  content1 = TextContent(type="text", text="hello")
1037
  result = _convert_to_content([1, content1], _process_as_single_item=True)
@@ -1044,6 +1044,30 @@ class TestConvertResultToContent:
1044
  {"type": "text", "text": "hello", "annotations": None, "_meta": None},
1045
  ]
1046
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1047
 
1048
  class TestAutomaticStructuredContent:
1049
  """Tests for automatic structured content generation based on return types."""
 
553
  # Dict objects automatically become structured content even without schema
554
  assert result.structured_content == {"message": "Hello, world!"}
555
  assert len(result.content) == 1
556
+ assert result.content[0].text == '{"message":"Hello, world!"}' # type: ignore[attr-defined]
557
 
558
  async def test_output_schema_none_disables_structured_content(self):
559
  """Test that output_schema=None explicitly disables structured content."""
 
607
  result = await tool.run({})
608
  # Dict result with object schema is used directly
609
  assert result.structured_content == {"value": 42}
610
+ assert result.content[0].text == '{"value":42}' # type: ignore[attr-defined]
611
 
612
  async def test_explicit_object_schema_with_non_dict_return_fails(self):
613
  """Test that explicit object schemas fail when function returns non-dict."""
 
859
  assert isinstance(result, list)
860
  assert len(result) == 1
861
  assert isinstance(result[0], TextContent)
862
+ assert result[0].text == '{"a":1,"b":2}'
863
 
864
  def test_list_of_basic_types(self):
865
  """Test that a list of basic types is converted to a single TextContent."""
 
867
  assert isinstance(result, list)
868
  assert len(result) == 1
869
  assert isinstance(result[0], TextContent)
870
+ assert result[0].text == '[1,"two",{"c":3}]'
871
 
872
  def test_list_of_mcp_types(self):
873
  """Test that a list of MCP types is returned as a list of those types."""
 
898
  assert image_content_count == 1
899
 
900
  text_item = next(item for item in result if isinstance(item, TextContent))
901
+ assert text_item.text == '[{"a":1}]'
902
 
903
  image_item = next(item for item in result if isinstance(item, ImageContent))
904
  assert image_item.data == "ZmFrZWltYWdlZGF0YQ=="
 
920
  assert image_content_count == 1
921
 
922
  text_item = next(item for item in result if isinstance(item, TextContent))
923
+ assert text_item.text == '[[{"a":1},{"b":2}]]'
924
 
925
  image_item = next(item for item in result if isinstance(item, ImageContent))
926
  assert image_item.data == "ZmFrZWltYWdlZGF0YQ=="
 
942
  assert audio_content_count == 1
943
 
944
  text_item = next(item for item in result if isinstance(item, TextContent))
945
+ assert text_item.text == '[{"a":1}]'
946
 
947
  audio_item = next(item for item in result if isinstance(item, AudioContent))
948
  assert audio_item.data == "ZmFrZWF1ZGlvZGF0YQ=="
 
967
  assert embedded_content_count == 1
968
 
969
  text_item = next(item for item in result if isinstance(item, TextContent))
970
+ assert text_item.text == '[{"a":1}]'
971
 
972
  embedded_item = next(
973
  item
 
1031
  assert isinstance(result, list)
1032
  assert len(result) == 1
1033
  assert isinstance(result[0], TextContent)
1034
+ assert result[0].text == '[1,"two",{"c":3}]'
1035
 
1036
  content1 = TextContent(type="text", text="hello")
1037
  result = _convert_to_content([1, content1], _process_as_single_item=True)
 
1044
  {"type": "text", "text": "hello", "annotations": None, "_meta": None},
1045
  ]
1046
 
1047
+ def test_single_element_list_preserves_structure(self):
1048
+ """Test that single-element lists preserve their list structure."""
1049
+
1050
+ # Test with a single integer
1051
+ result = _convert_to_content([1])
1052
+ assert isinstance(result, list)
1053
+ assert len(result) == 1
1054
+ assert isinstance(result[0], TextContent)
1055
+ assert result[0].text == "[1]" # Should be "[1]", not "1"
1056
+
1057
+ # Test with a single string
1058
+ result = _convert_to_content(["hello"])
1059
+ assert isinstance(result, list)
1060
+ assert len(result) == 1
1061
+ assert isinstance(result[0], TextContent)
1062
+ assert result[0].text == '["hello"]' # Should be ["hello"], not "hello"
1063
+
1064
+ # Test with a single dict
1065
+ result = _convert_to_content([{"a": 1}])
1066
+ assert isinstance(result, list)
1067
+ assert len(result) == 1
1068
+ assert isinstance(result[0], TextContent)
1069
+ assert result[0].text == '[{"a":1}]' # Should be wrapped in a list
1070
+
1071
 
1072
  class TestAutomaticStructuredContent:
1073
  """Tests for automatic structured content generation based on return types."""
tests/tools/test_tool_manager.py CHANGED
@@ -489,7 +489,7 @@ class TestCallTools:
489
  },
490
  )
491
 
492
- assert result.content[0].text == '[\n "rex",\n "gertrude"\n]' # type: ignore[attr-defined]
493
  assert result.structured_content == {"result": ["rex", "gertrude"]}
494
 
495
  async def test_call_tool_with_custom_serializer(self):
 
489
  },
490
  )
491
 
492
+ assert result.content[0].text == '["rex","gertrude"]' # type: ignore[attr-defined]
493
  assert result.structured_content == {"result": ["rex", "gertrude"]}
494
 
495
  async def test_call_tool_with_custom_serializer(self):
tests/tools/test_tool_transform.py CHANGED
@@ -1157,7 +1157,7 @@ class TestTransformToolOutputSchema:
1157
 
1158
  result = await new_tool.run({"x": 3})
1159
  # Should wrap string result
1160
- assert result.structured_content == {"result": 'Custom: {\n "value": 3\n}'}
1161
 
1162
  def test_transform_custom_function_fallback_to_parent(self, base_string_tool):
1163
  """Test that custom function without output annotation falls back to parent."""
 
1157
 
1158
  result = await new_tool.run({"x": 3})
1159
  # Should wrap string result
1160
+ assert result.structured_content == {"result": 'Custom: {"value":3}'}
1161
 
1162
  def test_transform_custom_function_fallback_to_parent(self, base_string_tool):
1163
  """Test that custom function without output annotation falls back to parent."""