Files
letta-server/letta/services/tool_executor/mcp_tool_executor.py
Kian Jones be60697a62 fix(core): handle protocol errors and foreign key violations (#9308)
* fix(core): handle PermissionDeniedError in provider API key validation

Fixed OpenAI PermissionDeniedError being raised as unknown error when
validating provider API keys. The check_api_key methods in OpenAI-based
providers (OpenAI, OpenRouter, Azure, Together) now properly catch and
re-raise PermissionDeniedError as LLMPermissionDeniedError.

🐛 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>

* fix(core): handle Unicode surrogates in OpenAI requests

Sanitize invalid UTF-16 surrogates before sending requests to OpenAI API.
Fixes UnicodeEncodeError when message content contains unpaired surrogates
from corrupted emoji data or malformed Unicode sequences.

🐾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>

* fix(core): handle MCP tool schema validation errors gracefully

Catch fastmcp.exceptions.ToolError in execute_mcp_tool endpoint and
convert to LettaInvalidArgumentError (400) instead of letting it
propagate as 500 error. This is an expected user error when tool
arguments don't match the MCP tool's schema.

Fixes Datadog issue 8f2d874a-f8e5-11f0-9b25-da7ad0900000

🐾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>

* fix(core): handle ExceptionGroup-wrapped ToolError in MCP executor

When MCP tools fail with validation errors (e.g., missing required parameters),
fastmcp raises ToolError exceptions that may be wrapped in ExceptionGroup by
Python's async TaskGroup. The exception handler now unwraps single-exception
groups before checking if the error should be handled gracefully.

Fixes Calendly API "organization parameter missing" errors being logged to
Datadog instead of returning friendly error messages to users.

🐾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>

* fix: handle missing agent in create_conversation to prevent foreign key violation

* Update .gitignore

---------

Co-authored-by: Letta <noreply@letta.com>
2026-02-24 10:52:06 -08:00

89 lines
3.6 KiB
Python

from typing import Any, Dict, Optional
from letta.constants import MCP_TOOL_TAG_NAME_PREFIX
from letta.log import get_logger
from letta.otel.tracing import trace_method
from letta.schemas.agent import AgentState
from letta.schemas.sandbox_config import SandboxConfig
from letta.schemas.tool import Tool
from letta.schemas.tool_execution_result import ToolExecutionResult
from letta.schemas.user import User
from letta.services.mcp_manager import MCPManager
from letta.services.tool_executor.tool_executor_base import ToolExecutor
from letta.utils import get_friendly_error_msg
logger = get_logger(__name__)
# MCP error class names that represent expected user-facing errors
# These are checked by class name to avoid import dependencies on fastmcp/mcp packages
MCP_EXPECTED_ERROR_CLASSES = {"McpError", "ToolError"}
class ExternalMCPToolExecutor(ToolExecutor):
"""Executor for external MCP tools."""
@trace_method
async def execute(
self,
function_name: str,
function_args: dict,
tool: Tool,
actor: User,
agent_state: Optional[AgentState] = None,
sandbox_config: Optional[SandboxConfig] = None,
sandbox_env_vars: Optional[Dict[str, Any]] = None,
) -> ToolExecutionResult:
mcp_server_tag = [tag for tag in tool.tags if tag.startswith(f"{MCP_TOOL_TAG_NAME_PREFIX}:")]
if not mcp_server_tag:
raise ValueError(f"Tool {tool.name} does not have a valid MCP server tag")
mcp_server_name = mcp_server_tag[0].split(":")[1]
mcp_manager = MCPManager()
# TODO: may need to have better client connection management
environment_variables = {}
agent_id = None
if agent_state:
environment_variables = agent_state.get_agent_env_vars_as_dict()
agent_id = agent_state.id
try:
function_response, success = await mcp_manager.execute_mcp_server_tool(
mcp_server_name=mcp_server_name,
tool_name=function_name,
tool_args=function_args,
environment_variables=environment_variables,
actor=actor,
agent_id=agent_id,
)
return ToolExecutionResult(
status="success" if success else "error",
func_return=function_response,
)
except Exception as e:
# Check if this is an expected MCP error (ToolError, McpError)
# These are user-facing errors from the external MCP server (e.g., "No connected account found")
# We handle them gracefully instead of letting them propagate as exceptions
# 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 MCP_EXPECTED_ERROR_CLASSES:
logger.info(f"MCP tool '{function_name}' returned expected error: {str(exception_to_check)}")
error_message = get_friendly_error_msg(
function_name=function_name,
exception_name=exception_to_check.__class__.__name__,
exception_message=str(exception_to_check),
)
return ToolExecutionResult(
status="error",
func_return=error_message,
)
# Re-raise unexpected errors
raise