From 98fa16899bb0c8705b71bafd34af3c2a2e410bc7 Mon Sep 17 00:00:00 2001 From: Kian Jones <11655409+kianjones9@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:44:51 -0800 Subject: [PATCH] fix(core): handle ExceptionGroup-wrapped ToolError in MCP clients (#9329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the ExceptionGroup unwrapping fix from mcp_tool_executor to the base MCP client implementations (AsyncBaseMCPClient, AsyncFastMCPSSEClient, AsyncFastMCPStreamableHTTPClient). When ToolError exceptions are wrapped in ExceptionGroup by Python's async TaskGroup, the exception handler now unwraps single-exception groups before checking class names. This prevents wrapped ToolError exceptions from being logged to Datadog as unexpected errors instead of being handled as expected validation failures. Related to commit 1cbf1b231 which fixed the same issue in mcp_tool_executor. 🐾 Generated with [Letta Code](https://letta.com) Co-authored-by: Letta --- letta/services/mcp/base_client.py | 12 ++++++++++-- letta/services/mcp/fastmcp_client.py | 24 ++++++++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/letta/services/mcp/base_client.py b/letta/services/mcp/base_client.py index 6b4a0536..a5e76ce2 100644 --- a/letta/services/mcp/base_client.py +++ b/letta/services/mcp/base_client.py @@ -85,8 +85,16 @@ class AsyncBaseMCPClient: # McpError is raised for other MCP-related errors # Both are expected user-facing issues from external MCP servers # Log at debug level to avoid triggering production alerts for expected failures - if e.__class__.__name__ in ("McpError", "ToolError"): - logger.debug(f"MCP tool '{tool_name}' execution failed: {str(e)}") + + # Handle ExceptionGroup wrapping (Python 3.11+ async TaskGroup can wrap exceptions) + exception_to_check = e + if hasattr(e, "exceptions") and e.exceptions: + # If it's an ExceptionGroup with a single wrapped exception, unwrap it + if len(e.exceptions) == 1: + exception_to_check = e.exceptions[0] + + if exception_to_check.__class__.__name__ in ("McpError", "ToolError"): + logger.debug(f"MCP tool '{tool_name}' execution failed: {str(exception_to_check)}") # Return error message with failure status instead of raising to avoid Datadog alerts return str(e), False # Re-raise unexpected errors diff --git a/letta/services/mcp/fastmcp_client.py b/letta/services/mcp/fastmcp_client.py index e3c901de..cc20f0a1 100644 --- a/letta/services/mcp/fastmcp_client.py +++ b/letta/services/mcp/fastmcp_client.py @@ -143,8 +143,16 @@ class AsyncFastMCPSSEClient: # McpError is raised for other MCP-related errors # Both are expected user-facing issues from external MCP servers # Log at debug level to avoid triggering production alerts for expected failures - if e.__class__.__name__ in ("McpError", "ToolError"): - logger.debug(f"MCP tool '{tool_name}' execution failed: {str(e)}") + + # Handle ExceptionGroup wrapping (Python 3.11+ async TaskGroup can wrap exceptions) + exception_to_check = e + if hasattr(e, "exceptions") and e.exceptions: + # If it's an ExceptionGroup with a single wrapped exception, unwrap it + if len(e.exceptions) == 1: + exception_to_check = e.exceptions[0] + + if exception_to_check.__class__.__name__ in ("McpError", "ToolError"): + logger.debug(f"MCP tool '{tool_name}' execution failed: {str(exception_to_check)}") raise # Parse content from result @@ -300,8 +308,16 @@ class AsyncFastMCPStreamableHTTPClient: # McpError is raised for other MCP-related errors # Both are expected user-facing issues from external MCP servers # Log at debug level to avoid triggering production alerts for expected failures - if e.__class__.__name__ in ("McpError", "ToolError"): - logger.debug(f"MCP tool '{tool_name}' execution failed: {str(e)}") + + # Handle ExceptionGroup wrapping (Python 3.11+ async TaskGroup can wrap exceptions) + exception_to_check = e + if hasattr(e, "exceptions") and e.exceptions: + # If it's an ExceptionGroup with a single wrapped exception, unwrap it + if len(e.exceptions) == 1: + exception_to_check = e.exceptions[0] + + if exception_to_check.__class__.__name__ in ("McpError", "ToolError"): + logger.debug(f"MCP tool '{tool_name}' execution failed: {str(exception_to_check)}") raise # Parse content from result