William Easton commited on
Commit
e592cc8
·
unverified ·
1 Parent(s): 8e65d31

Add content tests, fix array wrapping single objects

Browse files
src/fastmcp/tools/tool.py CHANGED
@@ -196,7 +196,12 @@ def _convert_to_content(
196
  mcp_types.append(_convert_to_content(item)[0])
197
  else:
198
  other_content.append(item)
199
- if other_content:
 
 
 
 
 
200
  other_content = _convert_to_content(
201
  other_content, serializer=serializer, _process_as_single_item=True
202
  )
 
196
  mcp_types.append(_convert_to_content(item)[0])
197
  else:
198
  other_content.append(item)
199
+
200
+ if len(other_content) == 1:
201
+ other_content = _convert_to_content(
202
+ other_content[0], serializer=serializer, _process_as_single_item=True
203
+ )
204
+ elif len(other_content) > 1:
205
  other_content = _convert_to_content(
206
  other_content, serializer=serializer, _process_as_single_item=True
207
  )
tests/tools/test_tool.py CHANGED
@@ -1,11 +1,11 @@
1
  import pytest
2
- from mcp.types import ImageContent, TextContent
3
- from pydantic import BaseModel
4
 
5
  from fastmcp import FastMCP, Image
6
  from fastmcp.client import Client
7
  from fastmcp.exceptions import ToolError
8
- from fastmcp.tools.tool import Tool
9
  from fastmcp.utilities.tests import temporary_settings
10
 
11
 
@@ -183,6 +183,32 @@ class TestToolFromFunction:
183
  class MyClass:
184
  x: int = 10
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
  class TestLegacyToolJsonParsing:
188
  """Tests for Tool's JSON pre-parsing functionality."""
@@ -378,3 +404,182 @@ class TestLegacyToolJsonParsing:
378
  result = await client.call_tool("process_tuple", {"items": '["1", "two"]'})
379
  assert isinstance(result[0], TextContent)
380
  assert result[0].text == "4"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import pytest
2
+ from mcp.types import EmbeddedResource, ImageContent, TextContent, TextResourceContents
3
+ from pydantic import AnyUrl, BaseModel
4
 
5
  from fastmcp import FastMCP, Image
6
  from fastmcp.client import Client
7
  from fastmcp.exceptions import ToolError
8
+ from fastmcp.tools.tool import Tool, _convert_to_content
9
  from fastmcp.utilities.tests import temporary_settings
10
 
11
 
 
183
  class MyClass:
184
  x: int = 10
185
 
186
+ @classmethod
187
+ def call(cls, x: int, y: int) -> int:
188
+ """Add two numbers."""
189
+ return x + y
190
+
191
+ tool = Tool.from_function(MyClass.call)
192
+ assert tool.name == "call"
193
+ assert tool.description == "Add two numbers."
194
+ assert "x" in tool.parameters["properties"]
195
+ assert "y" in tool.parameters["properties"]
196
+
197
+ async def test_tool_serializer(self):
198
+ """Test that a tool's serializer is used to serialize the result."""
199
+
200
+ def custom_serializer(data) -> str:
201
+ return f"Custom serializer: {data}"
202
+
203
+ def process_list(items: list[int]) -> int:
204
+ return sum(items)
205
+
206
+ tool = Tool.from_function(process_list, serializer=custom_serializer)
207
+
208
+ result = await tool.run(arguments={"items": [1, 2, 3, 4, 5]})
209
+ assert isinstance(result[0], TextContent)
210
+ assert result[0].text == "Custom serializer: 15"
211
+
212
 
213
  class TestLegacyToolJsonParsing:
214
  """Tests for Tool's JSON pre-parsing functionality."""
 
404
  result = await client.call_tool("process_tuple", {"items": '["1", "two"]'})
405
  assert isinstance(result[0], TextContent)
406
  assert result[0].text == "4"
407
+
408
+
409
+ class TestConvertResultToContent:
410
+ """Tests for the _convert_to_content helper function."""
411
+
412
+ def test_none_result(self):
413
+ """Test that None results in an empty list."""
414
+ result = _convert_to_content(None)
415
+ assert isinstance(result, list)
416
+ assert len(result) == 0
417
+
418
+ def test_text_content_result(self):
419
+ """Test that TextContent is returned as a list containing itself."""
420
+ content = TextContent(type="text", text="hello")
421
+ result = _convert_to_content(content)
422
+ assert isinstance(result, list)
423
+ assert len(result) == 1
424
+ assert result[0] is content
425
+
426
+ def test_image_content_result(self):
427
+ """Test that ImageContent is returned as a list containing itself."""
428
+ content = ImageContent(type="image", data="fakeimagedata", mimeType="image/png")
429
+ result = _convert_to_content(content)
430
+ assert isinstance(result, list)
431
+ assert len(result) == 1
432
+ assert result[0] is content
433
+
434
+ def test_embedded_resource_result(self):
435
+ """Test that EmbeddedResource is returned as a list containing itself."""
436
+ content = EmbeddedResource(
437
+ type="resource",
438
+ resource=TextResourceContents(
439
+ uri=AnyUrl("resource://test"),
440
+ mimeType="text/plain",
441
+ text="resource content",
442
+ ),
443
+ )
444
+ result = _convert_to_content(content)
445
+ assert isinstance(result, list)
446
+ assert len(result) == 1
447
+ assert result[0] is content
448
+
449
+ def test_image_object_result(self):
450
+ """Test that an Image object is converted to ImageContent."""
451
+ image_obj = Image(data=b"fakeimagedata")
452
+
453
+ result = _convert_to_content(image_obj)
454
+
455
+ assert isinstance(result, list)
456
+ assert len(result) == 1
457
+ assert isinstance(result[0], ImageContent)
458
+ assert result[0].data == "ZmFrZWltYWdlZGF0YQ=="
459
+
460
+ def test_basic_type_result(self):
461
+ """Test that a basic type is converted to TextContent."""
462
+ result = _convert_to_content(123)
463
+ assert isinstance(result, list)
464
+ assert len(result) == 1
465
+ assert isinstance(result[0], TextContent)
466
+ assert result[0].text == "123"
467
+
468
+ result = _convert_to_content("hello")
469
+ assert isinstance(result, list)
470
+ assert len(result) == 1
471
+ assert isinstance(result[0], TextContent)
472
+ assert result[0].text == "hello"
473
+
474
+ result = _convert_to_content({"a": 1, "b": 2})
475
+ assert isinstance(result, list)
476
+ assert len(result) == 1
477
+ assert isinstance(result[0], TextContent)
478
+ assert result[0].text == '{\n "a": 1,\n "b": 2\n}'
479
+
480
+ def test_list_of_basic_types(self):
481
+ """Test that a list of basic types is converted to a single TextContent."""
482
+ result = _convert_to_content([1, "two", {"c": 3}])
483
+ assert isinstance(result, list)
484
+ assert len(result) == 1
485
+ assert isinstance(result[0], TextContent)
486
+ assert result[0].text == '[\n 1,\n "two",\n {\n "c": 3\n }\n]'
487
+
488
+ def test_list_of_mcp_types(self):
489
+ """Test that a list of MCP types is returned as a list of those types."""
490
+ content1 = TextContent(type="text", text="hello")
491
+ content2 = ImageContent(
492
+ type="image", data="fakeimagedata2", mimeType="image/png"
493
+ )
494
+ result = _convert_to_content([content1, content2])
495
+ assert isinstance(result, list)
496
+ assert len(result) == 2
497
+ assert result[0] is content1
498
+ assert result[1] is content2
499
+
500
+ def test_list_of_mixed_types(self):
501
+ """Test that a list of mixed types is converted correctly."""
502
+ content1 = TextContent(type="text", text="hello")
503
+ image_obj = Image(data=b"fakeimagedata")
504
+ basic_data = {"a": 1}
505
+ result = _convert_to_content([content1, image_obj, basic_data])
506
+
507
+ assert isinstance(result, list)
508
+ assert len(result) == 3
509
+
510
+ text_content_count = sum(isinstance(item, TextContent) for item in result)
511
+ image_content_count = sum(isinstance(item, ImageContent) for item in result)
512
+
513
+ assert text_content_count == 2
514
+ assert image_content_count == 1
515
+
516
+ text_item = next(item for item in result if isinstance(item, TextContent))
517
+ assert text_item.text == '{\n "a": 1\n}'
518
+
519
+ image_item = next(item for item in result if isinstance(item, ImageContent))
520
+ assert image_item.data == "ZmFrZWltYWdlZGF0YQ=="
521
+
522
+ def test_empty_list(self):
523
+ """Test that an empty list results in an empty list."""
524
+ result = _convert_to_content([])
525
+ assert isinstance(result, list)
526
+ assert len(result) == 0
527
+
528
+ def test_empty_dict(self):
529
+ """Test that an empty dictionary is converted to TextContent."""
530
+ result = _convert_to_content({})
531
+ assert isinstance(result, list)
532
+ assert len(result) == 1
533
+ assert isinstance(result[0], TextContent)
534
+ assert result[0].text == "{}"
535
+
536
+ def test_with_custom_serializer(self):
537
+ """Test that a custom serializer is used for non-MCP types."""
538
+
539
+ def custom_serializer(data):
540
+ return f"Serialized: {data}"
541
+
542
+ result = _convert_to_content({"a": 1}, serializer=custom_serializer)
543
+ assert isinstance(result, list)
544
+ assert len(result) == 1
545
+ assert isinstance(result[0], TextContent)
546
+ assert result[0].text == "Serialized: {'a': 1}"
547
+
548
+ def test_custom_serializer_error_fallback(self, caplog):
549
+ """Test that if a custom serializer fails, it falls back to the default."""
550
+ import logging
551
+
552
+ def custom_serializer_that_fails(data):
553
+ raise ValueError("Serialization failed")
554
+
555
+ with caplog.at_level(logging.WARNING):
556
+ result = _convert_to_content(
557
+ {"a": 1}, serializer=custom_serializer_that_fails
558
+ )
559
+
560
+ assert isinstance(result, list)
561
+ assert len(result) == 1
562
+ assert isinstance(result[0], TextContent)
563
+ # Should fall back to default serializer (pydantic_core.to_json)
564
+ assert result[0].text == '{\n "a": 1\n}'
565
+ assert "Error serializing tool result" in caplog.text
566
+
567
+ def test_process_as_single_item_flag(self):
568
+ """Test that _process_as_single_item forces list to be treated as one item."""
569
+
570
+ result = _convert_to_content([1, "two", {"c": 3}], _process_as_single_item=True)
571
+ assert isinstance(result, list)
572
+ assert len(result) == 1
573
+ assert isinstance(result[0], TextContent)
574
+ assert result[0].text == '[\n 1,\n "two",\n {\n "c": 3\n }\n]'
575
+
576
+ content1 = TextContent(type="text", text="hello")
577
+ result = _convert_to_content([1, content1], _process_as_single_item=True)
578
+ assert isinstance(result, list)
579
+ assert len(result) == 1
580
+ assert isinstance(result[0], TextContent)
581
+
582
+ assert result[0].text == '[\n 1,\n {\n "type": "text",\n "text": "hello",\n "annotations": null\n }\n]'
583
+
584
+
585
+