Jeremiah Lowin commited on
Commit
9c9e880
·
unverified ·
2 Parent(s): 211f2c8e332e33

Merge pull request #642 from strawgate/tool_tests

Browse files
src/fastmcp/tools/tool.py CHANGED
@@ -215,9 +215,12 @@ def _convert_to_content(
215
  mcp_types.append(_convert_to_content(item)[0])
216
  else:
217
  other_content.append(item)
 
218
  if other_content:
219
  other_content = _convert_to_content(
220
- other_content, serializer=serializer, _process_as_single_item=True
 
 
221
  )
222
 
223
  return other_content + mcp_types
 
215
  mcp_types.append(_convert_to_content(item)[0])
216
  else:
217
  other_content.append(item)
218
+
219
  if other_content:
220
  other_content = _convert_to_content(
221
+ other_content[0] if len(other_content) == 1 else other_content,
222
+ serializer=serializer,
223
+ _process_as_single_item=True,
224
  )
225
 
226
  return other_content + mcp_types
tests/tools/test_tool.py CHANGED
@@ -1,11 +1,11 @@
1
  import pytest
2
- from mcp.types import ImageContent
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."""
@@ -359,4 +385,206 @@ class TestLegacyToolJsonParsing:
359
 
360
  async with Client(mcp) as client:
361
  result = await client.call_tool("process_tuple", {"items": '["1", "two"]'})
 
362
  assert result[0].text == "4" # type: ignore[attr-dict]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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."""
 
385
 
386
  async with Client(mcp) as client:
387
  result = await client.call_tool("process_tuple", {"items": '["1", "two"]'})
388
+ assert isinstance(result[0], TextContent)
389
  assert result[0].text == "4" # type: ignore[attr-dict]
390
+
391
+
392
+ class TestConvertResultToContent:
393
+ """Tests for the _convert_to_content helper function."""
394
+
395
+ def test_none_result(self):
396
+ """Test that None results in an empty list."""
397
+ result = _convert_to_content(None)
398
+ assert isinstance(result, list)
399
+ assert len(result) == 0
400
+
401
+ def test_text_content_result(self):
402
+ """Test that TextContent is returned as a list containing itself."""
403
+ content = TextContent(type="text", text="hello")
404
+ result = _convert_to_content(content)
405
+ assert isinstance(result, list)
406
+ assert len(result) == 1
407
+ assert result[0] is content
408
+
409
+ def test_image_content_result(self):
410
+ """Test that ImageContent is returned as a list containing itself."""
411
+ content = ImageContent(type="image", data="fakeimagedata", mimeType="image/png")
412
+ result = _convert_to_content(content)
413
+ assert isinstance(result, list)
414
+ assert len(result) == 1
415
+ assert result[0] is content
416
+
417
+ def test_embedded_resource_result(self):
418
+ """Test that EmbeddedResource is returned as a list containing itself."""
419
+ content = EmbeddedResource(
420
+ type="resource",
421
+ resource=TextResourceContents(
422
+ uri=AnyUrl("resource://test"),
423
+ mimeType="text/plain",
424
+ text="resource content",
425
+ ),
426
+ )
427
+ result = _convert_to_content(content)
428
+ assert isinstance(result, list)
429
+ assert len(result) == 1
430
+ assert result[0] is content
431
+
432
+ def test_image_object_result(self):
433
+ """Test that an Image object is converted to ImageContent."""
434
+ image_obj = Image(data=b"fakeimagedata")
435
+
436
+ result = _convert_to_content(image_obj)
437
+
438
+ assert isinstance(result, list)
439
+ assert len(result) == 1
440
+ assert isinstance(result[0], ImageContent)
441
+ assert result[0].data == "ZmFrZWltYWdlZGF0YQ=="
442
+
443
+ def test_basic_type_result(self):
444
+ """Test that a basic type is converted to TextContent."""
445
+ result = _convert_to_content(123)
446
+ assert isinstance(result, list)
447
+ assert len(result) == 1
448
+ assert isinstance(result[0], TextContent)
449
+ assert result[0].text == "123"
450
+
451
+ result = _convert_to_content("hello")
452
+ assert isinstance(result, list)
453
+ assert len(result) == 1
454
+ assert isinstance(result[0], TextContent)
455
+ assert result[0].text == "hello"
456
+
457
+ result = _convert_to_content({"a": 1, "b": 2})
458
+ assert isinstance(result, list)
459
+ assert len(result) == 1
460
+ assert isinstance(result[0], TextContent)
461
+ assert result[0].text == '{\n "a": 1,\n "b": 2\n}'
462
+
463
+ def test_list_of_basic_types(self):
464
+ """Test that a list of basic types is converted to a single TextContent."""
465
+ result = _convert_to_content([1, "two", {"c": 3}])
466
+ assert isinstance(result, list)
467
+ assert len(result) == 1
468
+ assert isinstance(result[0], TextContent)
469
+ assert result[0].text == '[\n 1,\n "two",\n {\n "c": 3\n }\n]'
470
+
471
+ def test_list_of_mcp_types(self):
472
+ """Test that a list of MCP types is returned as a list of those types."""
473
+ content1 = TextContent(type="text", text="hello")
474
+ content2 = ImageContent(
475
+ type="image", data="fakeimagedata2", mimeType="image/png"
476
+ )
477
+ result = _convert_to_content([content1, content2])
478
+ assert isinstance(result, list)
479
+ assert len(result) == 2
480
+ assert result[0] is content1
481
+ assert result[1] is content2
482
+
483
+ def test_list_of_mixed_types(self):
484
+ """Test that a list of mixed types is converted correctly."""
485
+ content1 = TextContent(type="text", text="hello")
486
+ image_obj = Image(data=b"fakeimagedata")
487
+ basic_data = {"a": 1}
488
+ result = _convert_to_content([content1, image_obj, basic_data])
489
+
490
+ assert isinstance(result, list)
491
+ assert len(result) == 3
492
+
493
+ text_content_count = sum(isinstance(item, TextContent) for item in result)
494
+ image_content_count = sum(isinstance(item, ImageContent) for item in result)
495
+
496
+ assert text_content_count == 2
497
+ assert image_content_count == 1
498
+
499
+ text_item = next(item for item in result if isinstance(item, TextContent))
500
+ assert text_item.text == '{\n "a": 1\n}'
501
+
502
+ image_item = next(item for item in result if isinstance(item, ImageContent))
503
+ assert image_item.data == "ZmFrZWltYWdlZGF0YQ=="
504
+
505
+ def test_list_of_mixed_types_list(self):
506
+ """Test that a list of mixed types, including a list as one of the elements, is converted correctly."""
507
+ content1 = TextContent(type="text", text="hello")
508
+ image_obj = Image(data=b"fakeimagedata")
509
+ basic_data = [{"a": 1}, {"b": 2}]
510
+ result = _convert_to_content([content1, image_obj, basic_data])
511
+
512
+ assert isinstance(result, list)
513
+ assert len(result) == 3
514
+
515
+ text_content_count = sum(isinstance(item, TextContent) for item in result)
516
+ image_content_count = sum(isinstance(item, ImageContent) for item in result)
517
+
518
+ assert text_content_count == 2
519
+ assert image_content_count == 1
520
+
521
+ text_item = next(item for item in result if isinstance(item, TextContent))
522
+ assert text_item.text == '[\n {\n "a": 1\n },\n {\n "b": 2\n }\n]'
523
+
524
+ image_item = next(item for item in result if isinstance(item, ImageContent))
525
+ assert image_item.data == "ZmFrZWltYWdlZGF0YQ=="
526
+
527
+ def test_empty_list(self):
528
+ """Test that an empty list results in an empty list."""
529
+ result = _convert_to_content([])
530
+ assert isinstance(result, list)
531
+ assert len(result) == 0
532
+
533
+ def test_empty_dict(self):
534
+ """Test that an empty dictionary is converted to TextContent."""
535
+ result = _convert_to_content({})
536
+ assert isinstance(result, list)
537
+ assert len(result) == 1
538
+ assert isinstance(result[0], TextContent)
539
+ assert result[0].text == "{}"
540
+
541
+ def test_with_custom_serializer(self):
542
+ """Test that a custom serializer is used for non-MCP types."""
543
+
544
+ def custom_serializer(data):
545
+ return f"Serialized: {data}"
546
+
547
+ result = _convert_to_content({"a": 1}, serializer=custom_serializer)
548
+ assert isinstance(result, list)
549
+ assert len(result) == 1
550
+ assert isinstance(result[0], TextContent)
551
+ assert result[0].text == "Serialized: {'a': 1}"
552
+
553
+ def test_custom_serializer_error_fallback(self, caplog):
554
+ """Test that if a custom serializer fails, it falls back to the default."""
555
+ import logging
556
+
557
+ def custom_serializer_that_fails(data):
558
+ raise ValueError("Serialization failed")
559
+
560
+ with caplog.at_level(logging.WARNING):
561
+ result = _convert_to_content(
562
+ {"a": 1}, serializer=custom_serializer_that_fails
563
+ )
564
+
565
+ assert isinstance(result, list)
566
+ assert len(result) == 1
567
+ assert isinstance(result[0], TextContent)
568
+ # Should fall back to default serializer (pydantic_core.to_json)
569
+ assert result[0].text == '{\n "a": 1\n}'
570
+ assert "Error serializing tool result" in caplog.text
571
+
572
+ def test_process_as_single_item_flag(self):
573
+ """Test that _process_as_single_item forces list to be treated as one item."""
574
+
575
+ result = _convert_to_content([1, "two", {"c": 3}], _process_as_single_item=True)
576
+ assert isinstance(result, list)
577
+ assert len(result) == 1
578
+ assert isinstance(result[0], TextContent)
579
+ assert result[0].text == '[\n 1,\n "two",\n {\n "c": 3\n }\n]'
580
+
581
+ content1 = TextContent(type="text", text="hello")
582
+ result = _convert_to_content([1, content1], _process_as_single_item=True)
583
+ assert isinstance(result, list)
584
+ assert len(result) == 1
585
+ assert isinstance(result[0], TextContent)
586
+
587
+ assert (
588
+ result[0].text
589
+ == '[\n 1,\n {\n "type": "text",\n "text": "hello",\n "annotations": null\n }\n]'
590
+ )