Skip to content

Commit 867194a

Browse files
peterjvictordibia
andauthored
fixes the issues where exceptions from MCP server tools aren't serial… (#6482)
…ized properly ## Why are these changes needed? The exceptions thrown by MCP server tools weren't being serialized properly - the user would see `[{}, {}, ... {}]` instead of an actual error/exception message. ## Related issue number Fixes #6481 ## Checks - [ ] I've included any doc changes needed for <https://microsoft.github.io/autogen/>. See <https://github.com/microsoft/autogen/blob/main/CONTRIBUTING.md> to build and test documentation locally. - [x] I've added tests (if relevant) corresponding to the changes introduced in this PR. - [x] I've made sure all auto checks have passed. --------- Signed-off-by: Peter Jausovec <peter.jausovec@solo.io> Co-authored-by: Victor Dibia <victordibia@microsoft.com> Co-authored-by: Victor Dibia <victor.dibia@gmail.com>
1 parent 9118f9b commit 867194a

3 files changed

Lines changed: 173 additions & 22 deletions

File tree

python/packages/autogen-core/src/autogen_core/tools/_static_workbench.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import builtins
23
from typing import Any, Dict, List, Literal, Mapping
34

45
from pydantic import BaseModel
@@ -55,12 +56,12 @@ async def call_tool(
5556
try:
5657
result_future = asyncio.ensure_future(tool.run_json(arguments, cancellation_token))
5758
cancellation_token.link_future(result_future)
58-
result = await result_future
59+
actual_tool_output = await result_future
5960
is_error = False
61+
result_str = tool.return_value_as_string(actual_tool_output)
6062
except Exception as e:
61-
result = str(e)
63+
result_str = self._format_errors(e)
6264
is_error = True
63-
result_str = tool.return_value_as_string(result)
6465
return ToolResult(name=tool.name, result=[TextResultContent(content=result_str)], is_error=is_error)
6566

6667
async def start(self) -> None:
@@ -90,3 +91,16 @@ def _to_config(self) -> StaticWorkbenchConfig:
9091
@classmethod
9192
def _from_config(cls, config: StaticWorkbenchConfig) -> Self:
9293
return cls(tools=[BaseTool.load_component(tool) for tool in config.tools])
94+
95+
def _format_errors(self, error: Exception) -> str:
96+
"""Recursively format errors into a string."""
97+
98+
error_message = ""
99+
if hasattr(builtins, "ExceptionGroup") and isinstance(error, builtins.ExceptionGroup):
100+
# ExceptionGroup is available in Python 3.11+.
101+
# TODO: how to make this compatible with Python 3.10?
102+
for sub_exception in error.exceptions: # type: ignore
103+
error_message += self._format_errors(sub_exception) # type: ignore
104+
else:
105+
error_message += f"{str(error)}\n"
106+
return error_message.strip()

python/packages/autogen-ext/src/autogen_ext/tools/mcp/_base.py

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -74,21 +74,52 @@ async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> A
7474
await session.initialize()
7575
return await self._run(args=kwargs, cancellation_token=cancellation_token, session=session)
7676

77+
def _normalize_payload_to_content_list(
78+
self, payload: list[TextContent | ImageContent | EmbeddedResource]
79+
) -> list[TextContent | ImageContent | EmbeddedResource]:
80+
"""
81+
Normalizes a raw tool output payload into a list of content items.
82+
- If payload is already a list of (TextContent, ImageContent, EmbeddedResource), it's returned as is.
83+
- If payload is a single TextContent, ImageContent, or EmbeddedResource, it's wrapped in a list.
84+
- If payload is a string, it's wrapped in [TextContent(text=payload)].
85+
- Otherwise, the payload is stringified and wrapped in [TextContent(text=str(payload))].
86+
"""
87+
if isinstance(payload, list) and all(
88+
isinstance(item, (TextContent, ImageContent, EmbeddedResource)) for item in payload
89+
):
90+
return payload
91+
elif isinstance(payload, (TextContent, ImageContent, EmbeddedResource)):
92+
return [payload]
93+
elif isinstance(payload, str):
94+
return [TextContent(text=payload, type="text")]
95+
else:
96+
return [TextContent(text=str(payload), type="text")]
97+
7798
async def _run(self, args: Dict[str, Any], cancellation_token: CancellationToken, session: ClientSession) -> Any:
99+
exceptions_to_catch: tuple[Type[BaseException], ...]
100+
if hasattr(builtins, "ExceptionGroup"):
101+
exceptions_to_catch = (asyncio.CancelledError, builtins.ExceptionGroup)
102+
else:
103+
exceptions_to_catch = (asyncio.CancelledError,)
104+
78105
try:
79106
if cancellation_token.is_cancelled():
80-
raise Exception("Operation cancelled")
107+
raise asyncio.CancelledError("Operation cancelled")
81108

82109
result_future = asyncio.ensure_future(session.call_tool(name=self._tool.name, arguments=args))
83110
cancellation_token.link_future(result_future)
84111
result = await result_future
85112

113+
normalized_content_list = self._normalize_payload_to_content_list(result.content)
114+
86115
if result.isError:
87-
raise Exception(f"MCP tool execution failed: {result.content}")
88-
return result.content
89-
except Exception as e:
90-
error_message = self._format_errors(e)
91-
raise Exception(error_message) from e
116+
serialized_error_message = self.return_value_as_string(normalized_content_list)
117+
raise Exception(serialized_error_message)
118+
return normalized_content_list
119+
120+
except exceptions_to_catch:
121+
# Re-raise these specific exception types directly.
122+
raise
92123

93124
@classmethod
94125
async def from_server_params(cls, server_params: TServerParams, tool_name: str) -> "McpToolAdapter[TServerParams]":
@@ -138,16 +169,3 @@ def serialize_item(item: Any) -> dict[str, Any]:
138169
return {}
139170

140171
return json.dumps([serialize_item(item) for item in value])
141-
142-
def _format_errors(self, error: Exception) -> str:
143-
"""Recursively format errors into a string."""
144-
145-
error_message = ""
146-
if hasattr(builtins, "ExceptionGroup") and isinstance(error, builtins.ExceptionGroup):
147-
# ExceptionGroup is available in Python 3.11+.
148-
# TODO: how to make this compatible with Python 3.10?
149-
for sub_exception in error.exceptions: # type: ignore
150-
error_message += self._format_errors(sub_exception) # type: ignore
151-
else:
152-
error_message += f"{str(error)}\n"
153-
return error_message

python/packages/autogen-ext/tests/tools/test_mcp_tools.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
102110
def 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

Comments
 (0)