@@ -99,6 +99,14 @@ def cancellation_token() -> CancellationToken:
9999 return CancellationToken ()
100100
101101
102+ @pytest .fixture
103+ def mock_error_tool_response () -> MagicMock :
104+ response = MagicMock ()
105+ response .isError = True
106+ response .content = [TextContent (text = "error output" , type = "text" )]
107+ return response
108+
109+
102110def test_adapter_config_serialization (sample_tool : Tool , sample_server_params : StdioServerParams ) -> None :
103111 """Test that adapter can be saved to and loaded from config."""
104112 original_adapter = StdioMcpToolAdapter (server_params = sample_server_params , tool = sample_tool )
@@ -650,3 +658,114 @@ def test_del_raises_when_loop_closed() -> None:
650658
651659 with pytest .warns (RuntimeWarning , match = "loop is closed or not running" ):
652660 del workbench
661+
662+
663+ def test_mcp_tool_adapter_normalize_payload (sample_tool : Tool , sample_server_params : StdioServerParams ) -> None :
664+ """Test the _normalize_payload_to_content_list method of McpToolAdapter."""
665+ adapter = StdioMcpToolAdapter (server_params = sample_server_params , tool = sample_tool )
666+
667+ # Case 1: Payload is already a list of valid content items
668+ valid_content_list : list [TextContent | ImageContent | EmbeddedResource ] = [
669+ TextContent (text = "hello" , type = "text" ),
670+ ImageContent (data = "base64data" , mimeType = "image/png" , type = "image" ),
671+ EmbeddedResource (
672+ type = "resource" ,
673+ resource = TextResourceContents (text = "embedded text" , uri = AnyUrl (url = "http://example.com/resource" )),
674+ ),
675+ ]
676+ assert adapter ._normalize_payload_to_content_list (valid_content_list ) == valid_content_list # type: ignore[reportPrivateUsage]
677+
678+ # Case 2: Payload is a single TextContent
679+ single_text_content = TextContent (text = "single text" , type = "text" )
680+ assert adapter ._normalize_payload_to_content_list (single_text_content ) == [single_text_content ] # type: ignore[reportPrivateUsage, arg-type]
681+
682+ # Case 3: Payload is a single ImageContent
683+ single_image_content = ImageContent (data = "imagedata" , mimeType = "image/jpeg" , type = "image" )
684+ assert adapter ._normalize_payload_to_content_list (single_image_content ) == [single_image_content ] # type: ignore[reportPrivateUsage, arg-type]
685+
686+ # Case 4: Payload is a single EmbeddedResource
687+ single_embedded_resource = EmbeddedResource (
688+ type = "resource" ,
689+ resource = TextResourceContents (text = "other embedded" , uri = AnyUrl (url = "http://example.com/other" )),
690+ )
691+ assert adapter ._normalize_payload_to_content_list (single_embedded_resource ) == [single_embedded_resource ] # type: ignore[reportPrivateUsage, arg-type]
692+
693+ # Case 5: Payload is a string
694+ string_payload = "This is a string payload."
695+ expected_from_string = [TextContent (text = string_payload , type = "text" )]
696+ assert adapter ._normalize_payload_to_content_list (string_payload ) == expected_from_string # type: ignore[reportPrivateUsage, arg-type]
697+
698+ # Case 6: Payload is an integer
699+ int_payload = 12345
700+ expected_from_int = [TextContent (text = str (int_payload ), type = "text" )]
701+ assert adapter ._normalize_payload_to_content_list (int_payload ) == expected_from_int # type: ignore[reportPrivateUsage, arg-type]
702+
703+ # Case 7: Payload is a dictionary
704+ dict_payload = {"key" : "value" , "number" : 42 }
705+ expected_from_dict = [TextContent (text = str (dict_payload ), type = "text" )]
706+ assert adapter ._normalize_payload_to_content_list (dict_payload ) == expected_from_dict # type: ignore[reportPrivateUsage, arg-type]
707+
708+ # Case 8: Payload is an empty list (should still be a list of valid items, so returns as is)
709+ empty_list_payload : list [TextContent | ImageContent | EmbeddedResource ] = []
710+ assert adapter ._normalize_payload_to_content_list (empty_list_payload ) == empty_list_payload # type: ignore[reportPrivateUsage]
711+
712+ # Case 9: Payload is None (should be stringified)
713+ none_payload = None
714+ expected_from_none = [TextContent (text = str (none_payload ), type = "text" )]
715+ assert adapter ._normalize_payload_to_content_list (none_payload ) == expected_from_none # type: ignore[reportPrivateUsage, arg-type]
716+
717+
718+ @pytest .mark .asyncio
719+ async def test_mcp_tool_adapter_run_error (
720+ sample_tool : Tool ,
721+ sample_server_params : StdioServerParams ,
722+ mock_session : AsyncMock ,
723+ mock_error_tool_response : MagicMock ,
724+ cancellation_token : CancellationToken ,
725+ ) -> None :
726+ """Test McpToolAdapter._run when tool returns an error."""
727+ adapter = StdioMcpToolAdapter (server_params = sample_server_params , tool = sample_tool , session = mock_session )
728+ mock_session .call_tool .return_value = mock_error_tool_response
729+
730+ args = {"test_param" : "test_value" }
731+ with pytest .raises (Exception ) as excinfo :
732+ await adapter ._run (args = args , cancellation_token = cancellation_token , session = mock_session ) # type: ignore[reportPrivateUsage]
733+
734+ mock_session .call_tool .assert_called_once_with (name = sample_tool .name , arguments = args )
735+ assert adapter .return_value_as_string ([TextContent (text = "error output" , type = "text" )]) in str (excinfo .value )
736+
737+
738+ @pytest .mark .asyncio
739+ async def test_mcp_tool_adapter_run_cancelled_before_call (
740+ sample_tool : Tool ,
741+ sample_server_params : StdioServerParams ,
742+ mock_session : AsyncMock ,
743+ cancellation_token : CancellationToken ,
744+ ) -> None :
745+ """Test McpToolAdapter._run when operation is cancelled before tool call."""
746+ adapter = StdioMcpToolAdapter (server_params = sample_server_params , tool = sample_tool , session = mock_session )
747+ cancellation_token .cancel () # Cancel before the call
748+
749+ args = {"test_param" : "test_value" }
750+ with pytest .raises (asyncio .CancelledError ):
751+ await adapter ._run (args = args , cancellation_token = cancellation_token , session = mock_session ) # type: ignore[reportPrivateUsage]
752+
753+ mock_session .call_tool .assert_not_called ()
754+
755+
756+ @pytest .mark .asyncio
757+ async def test_mcp_tool_adapter_run_cancelled_during_call (
758+ sample_tool : Tool ,
759+ sample_server_params : StdioServerParams ,
760+ mock_session : AsyncMock ,
761+ cancellation_token : CancellationToken ,
762+ ) -> None :
763+ """Test McpToolAdapter._run when operation is cancelled during tool call."""
764+ adapter = StdioMcpToolAdapter (server_params = sample_server_params , tool = sample_tool , session = mock_session )
765+ mock_session .call_tool .side_effect = asyncio .CancelledError ("Tool call cancelled" )
766+
767+ args = {"test_param" : "test_value" }
768+ with pytest .raises (asyncio .CancelledError ):
769+ await adapter ._run (args = args , cancellation_token = cancellation_token , session = mock_session ) # type: ignore[reportPrivateUsage]
770+
771+ mock_session .call_tool .assert_called_once_with (name = sample_tool .name , arguments = args )
0 commit comments