Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

from pydantic import BaseModel
from typing_extensions import Self
import builtins


from .._cancellation_token import CancellationToken
from .._component_config import Component, ComponentModel
Expand Down Expand Up @@ -55,12 +57,12 @@
try:
result_future = asyncio.ensure_future(tool.run_json(arguments, cancellation_token))
cancellation_token.link_future(result_future)
result = await result_future
actual_tool_output = await result_future
is_error = False
result_str = tool.return_value_as_string(actual_tool_output)
except Exception as e:
result = str(e)
result_str = self._format_errors(e)
is_error = True
result_str = tool.return_value_as_string(result)
return ToolResult(name=tool.name, result=[TextResultContent(content=result_str)], is_error=is_error)

async def start(self) -> None:
Expand Down Expand Up @@ -90,3 +92,16 @@
@classmethod
def _from_config(cls, config: StaticWorkbenchConfig) -> Self:
return cls(tools=[BaseTool.load_component(tool) for tool in config.tools])

def _format_errors(self, error: Exception) -> str:
"""Recursively format errors into a string."""

error_message = ""
if hasattr(builtins, "ExceptionGroup") and isinstance(error, builtins.ExceptionGroup):
# ExceptionGroup is available in Python 3.11+.
# TODO: how to make this compatible with Python 3.10?
for sub_exception in error.exceptions: # type: ignore
error_message += self._format_errors(sub_exception) # type: ignore

Check warning on line 104 in python/packages/autogen-core/src/autogen_core/tools/_static_workbench.py

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L103 - L104 were not covered by tests
else:
error_message += f"{str(error)}\n"
return error_message.strip()
52 changes: 33 additions & 19 deletions python/packages/autogen-ext/src/autogen_ext/tools/mcp/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,21 +74,48 @@
await session.initialize()
return await self._run(args=kwargs, cancellation_token=cancellation_token, session=session)

def _normalize_payload_to_content_list(self, payload: Any) -> list[Any]:
"""
Normalizes a raw tool output payload into a list of content items.
- If payload is already a list of (TextContent, ImageContent, EmbeddedResource), it's returned as is.
- If payload is a single TextContent, ImageContent, or EmbeddedResource, it's wrapped in a list.
- If payload is a string, it's wrapped in [TextContent(text=payload)].
- Otherwise, the payload is stringified and wrapped in [TextContent(text=str(payload))].
"""
if isinstance(payload, list) and all(
isinstance(item, (TextContent, ImageContent, EmbeddedResource)) for item in payload
):
return payload
elif isinstance(payload, (TextContent, ImageContent, EmbeddedResource)):
return [payload]
elif isinstance(payload, str):
return [TextContent(text=payload, type="text")]
else:
return [TextContent(text=str(payload), type="text")]

async def _run(self, args: Dict[str, Any], cancellation_token: CancellationToken, session: ClientSession) -> Any:
_exception_group_or_fallback = (
builtins.ExceptionGroup if hasattr(builtins, "ExceptionGroup") else asyncio.CancelledError
)

try:
if cancellation_token.is_cancelled():
raise Exception("Operation cancelled")
raise asyncio.CancelledError("Operation cancelled")

Check warning on line 103 in python/packages/autogen-ext/src/autogen_ext/tools/mcp/_base.py

View check run for this annotation

Codecov / codecov/patch

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

Added line #L103 was not covered by tests

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

normalized_content_list = self._normalize_payload_to_content_list(result.content)

if result.isError:
raise Exception(f"MCP tool execution failed: {result.content}")
return result.content
except Exception as e:
error_message = self._format_errors(e)
raise Exception(error_message) from e
serialized_error_message = self.return_value_as_string(normalized_content_list)
raise Exception(serialized_error_message)

Check warning on line 113 in python/packages/autogen-ext/src/autogen_ext/tools/mcp/_base.py

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L112 - L113 were not covered by tests
return normalized_content_list

except (asyncio.CancelledError, _exception_group_or_fallback):

Check warning on line 116 in python/packages/autogen-ext/src/autogen_ext/tools/mcp/_base.py

View check run for this annotation

Codecov / codecov/patch

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

Added line #L116 was not covered by tests
# Re-raise these specific exception types directly.
raise

Check warning on line 118 in python/packages/autogen-ext/src/autogen_ext/tools/mcp/_base.py

View check run for this annotation

Codecov / codecov/patch

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

Added line #L118 was not covered by tests

@classmethod
async def from_server_params(cls, server_params: TServerParams, tool_name: str) -> "McpToolAdapter[TServerParams]":
Expand Down Expand Up @@ -138,16 +165,3 @@
return {}

return json.dumps([serialize_item(item) for item in value])

def _format_errors(self, error: Exception) -> str:
"""Recursively format errors into a string."""

error_message = ""
if hasattr(builtins, "ExceptionGroup") and isinstance(error, builtins.ExceptionGroup):
# ExceptionGroup is available in Python 3.11+.
# TODO: how to make this compatible with Python 3.10?
for sub_exception in error.exceptions: # type: ignore
error_message += self._format_errors(sub_exception) # type: ignore
else:
error_message += f"{str(error)}\n"
return error_message
1 change: 1 addition & 0 deletions python/packages/autogen-ext/tests/tools/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# This is a dummy README.md file for testing purposes.
55 changes: 55 additions & 0 deletions python/packages/autogen-ext/tests/tools/test_mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,3 +595,58 @@ async def test_lazy_init_and_finalize_cleanup() -> None:
assert workbench._actor._active is True # type: ignore[reportPrivateUsage]

del workbench


def test_mcp_tool_adapter_normalize_payload(sample_tool: Tool, sample_server_params: StdioServerParams) -> None:
"""Test the _normalize_payload_to_content_list method of McpToolAdapter."""
adapter = StdioMcpToolAdapter(server_params=sample_server_params, tool=sample_tool)

# Case 1: Payload is already a list of valid content items
valid_content_list = [
TextContent(text="hello", type="text"),
ImageContent(data="base64data", mimeType="image/png", type="image"),
EmbeddedResource(
type="resource",
resource=TextResourceContents(text="embedded text", uri=AnyUrl(url="http://example.com/resource")),
),
]
assert adapter._normalize_payload_to_content_list(valid_content_list) == valid_content_list

# Case 2: Payload is a single TextContent
single_text_content = TextContent(text="single text", type="text")
assert adapter._normalize_payload_to_content_list(single_text_content) == [single_text_content]

# Case 3: Payload is a single ImageContent
single_image_content = ImageContent(data="imagedata", mimeType="image/jpeg", type="image")
assert adapter._normalize_payload_to_content_list(single_image_content) == [single_image_content]

# Case 4: Payload is a single EmbeddedResource
single_embedded_resource = EmbeddedResource(
type="resource",
resource=TextResourceContents(text="other embedded", uri=AnyUrl(url="http://example.com/other")),
)
assert adapter._normalize_payload_to_content_list(single_embedded_resource) == [single_embedded_resource]

# Case 5: Payload is a string
string_payload = "This is a string payload."
expected_from_string = [TextContent(text=string_payload, type="text")]
assert adapter._normalize_payload_to_content_list(string_payload) == expected_from_string

# Case 6: Payload is an integer
int_payload = 12345
expected_from_int = [TextContent(text=str(int_payload), type="text")]
assert adapter._normalize_payload_to_content_list(int_payload) == expected_from_int

# Case 7: Payload is a dictionary
dict_payload = {"key": "value", "number": 42}
expected_from_dict = [TextContent(text=str(dict_payload), type="text")]
assert adapter._normalize_payload_to_content_list(dict_payload) == expected_from_dict

# Case 8: Payload is an empty list (should still be a list of valid items, so returns as is)
empty_list_payload: list = []
assert adapter._normalize_payload_to_content_list(empty_list_payload) == empty_list_payload

# Case 9: Payload is None (should be stringified)
none_payload = None
expected_from_none = [TextContent(text=str(none_payload), type="text")]
assert adapter._normalize_payload_to_content_list(none_payload) == expected_from_none
Loading