fix(core): convert MCP ConnectionError to LettaMCPConnectionError for proper HTTP 502 responses (#9364)

MCP server connection failures were raising Python's builtin ConnectionError,
which bypassed the LettaMCPConnectionError FastAPI exception handler and hit
Datadog as unhandled 500 errors. Now all MCP client classes convert
ConnectionError to LettaMCPConnectionError at the source, which the existing
exception handler returns as a user-friendly 502.

Datadog: https://us5.datadoghq.com/error-tracking/issue/93db4a82-fe5a-11f0-85f0-da7ad0900000

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

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Kian Jones
2026-02-06 17:02:42 -08:00
committed by Caren Thomas
parent 31d221b47e
commit 47aedfa1a7
5 changed files with 45 additions and 34 deletions

View File

@@ -4,6 +4,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Request
from httpx import HTTPStatusError
from starlette.responses import StreamingResponse
from letta.errors import LettaMCPConnectionError
from letta.functions.mcp_client.types import SSEServerConfig, StdioServerConfig, StreamableHTTPServerConfig
from letta.log import get_logger
from letta.schemas.letta_message import ToolReturnMessage
@@ -268,8 +269,7 @@ async def connect_mcp_server(
tools = await client.list_tools(serialize=True)
yield oauth_stream_event(OauthStreamEvent.SUCCESS, tools=tools)
return
except ConnectionError:
# TODO: jnjpng make this connection error check more specific to the 401 unauthorized error
except (ConnectionError, LettaMCPConnectionError):
if isinstance(client, AsyncStdioMCPClient):
logger.warning("OAuth not supported for stdio")
yield oauth_stream_event(OauthStreamEvent.ERROR, message="OAuth not supported for stdio")

View File

@@ -643,7 +643,9 @@ async def test_mcp_server(
tools = await client.list_tools()
return {"status": "success", "tools": tools}
except ConnectionError as e:
except (ConnectionError, LettaMCPConnectionError) as e:
if isinstance(e, LettaMCPConnectionError):
raise
raise LettaMCPConnectionError(str(e), server_name=request.server_name)
except MCPTimeoutError as e:
raise LettaMCPTimeoutError(f"MCP server connection timed out: {str(e)}", server_name=request.server_name)
@@ -705,8 +707,7 @@ async def connect_mcp_server(
tools = await client.list_tools(serialize=True)
yield oauth_stream_event(OauthStreamEvent.SUCCESS, tools=tools)
return
except ConnectionError as e:
# Only trigger OAuth flow on explicit unauthorized failures
except (ConnectionError, LettaMCPConnectionError) as e:
unauthorized = False
if isinstance(e.__cause__, HTTPStatusError):
unauthorized = e.__cause__.response.status_code == 401

View File

@@ -1799,9 +1799,14 @@ class SyncServer(object):
raise LettaInvalidArgumentError(f"Invalid MCP server config: {server_config}", argument_name="server_config")
try:
await new_mcp_client.connect_to_server()
except:
except LettaMCPConnectionError:
raise
except Exception:
logger.exception(f"Failed to connect to MCP server: {server_config.server_name}")
raise RuntimeError(f"Failed to connect to MCP server: {server_config.server_name}")
raise LettaMCPConnectionError(
message=f"Failed to connect to MCP server: {server_config.server_name}",
server_name=server_config.server_name,
)
# Print out the tools that are connected
logger.info(f"Attempting to fetch tools from MCP server: {server_config.server_name}")
new_mcp_tools = await new_mcp_client.list_tools()

View File

@@ -5,6 +5,7 @@ from mcp import ClientSession, Tool as MCPTool
from mcp.client.auth import OAuthClientProvider
from mcp.types import TextContent
from letta.errors import LettaMCPConnectionError
from letta.functions.mcp_client.types import BaseServerConfig
from letta.log import get_logger
@@ -31,14 +32,12 @@ class AsyncBaseMCPClient:
await self._initialize_connection(self.server_config)
await self.session.initialize()
self.initialized = True
except LettaMCPConnectionError:
raise
except ConnectionError as e:
# MCP connection failures are often due to user misconfiguration, not system errors
# Log at debug level to avoid triggering Sentry alerts for expected configuration issues
logger.debug(f"MCP connection failed: {str(e)}")
raise e
raise LettaMCPConnectionError(message=str(e), server_name=getattr(self.server_config, "server_name", None)) from e
except Exception as e:
# MCP connection failures are often due to user misconfiguration, not system errors
# Log as warning for visibility in monitoring
logger.warning(
f"Connecting to MCP server failed. Please review your server config: {self.server_config.model_dump_json(indent=4)}. Error: {str(e)}"
)
@@ -48,8 +47,9 @@ class AsyncBaseMCPClient:
server_info = f"command '{self.server_config.command}'"
else:
server_info = f"server '{self.server_config.server_name}'"
raise ConnectionError(
f"Failed to connect to MCP {server_info}. Please check your configuration and ensure the server is accessible."
raise LettaMCPConnectionError(
message=f"Failed to connect to MCP {server_info}. Please check your configuration and ensure the server is accessible.",
server_name=getattr(self.server_config, "server_name", None),
) from e
async def _initialize_connection(self, server_config: BaseServerConfig) -> None:

View File

@@ -16,6 +16,7 @@ from fastmcp import Client
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
from mcp import Tool as MCPTool
from letta.errors import LettaMCPConnectionError
from letta.functions.mcp_client.types import SSEServerConfig, StreamableHTTPServerConfig
from letta.log import get_logger
from letta.services.mcp.server_side_oauth import ServerSideOAuth
@@ -76,22 +77,24 @@ class AsyncFastMCPSSEClient:
await self.client._connect()
self.initialized = True
except httpx.HTTPStatusError as e:
# Re-raise HTTP status errors for OAuth flow handling
if e.response.status_code == 401:
raise ConnectionError("401 Unauthorized") from e
raise ConnectionError(f"HTTP error connecting to MCP server at {self.server_config.server_url}: {e}") from e
except ConnectionError:
# Re-raise ConnectionError as-is
raise LettaMCPConnectionError(message="401 Unauthorized", server_name=self.server_config.server_name) from e
raise LettaMCPConnectionError(
message=f"HTTP error connecting to MCP server at {self.server_config.server_url}: {e}",
server_name=self.server_config.server_name,
) from e
except LettaMCPConnectionError:
raise
except ConnectionError as e:
raise LettaMCPConnectionError(message=str(e), server_name=self.server_config.server_name) from e
except Exception as e:
# MCP connection failures are often due to user misconfiguration, not system errors
# Log as warning for visibility in monitoring
logger.warning(
f"Connecting to MCP server failed. Please review your server config: {self.server_config.model_dump_json(indent=4)}. Error: {str(e)}"
)
raise ConnectionError(
f"Failed to connect to MCP server at '{self.server_config.server_url}'. "
f"Please check your configuration and ensure the server is accessible. Error: {str(e)}"
raise LettaMCPConnectionError(
message=f"Failed to connect to MCP server at '{self.server_config.server_url}'. "
f"Please check your configuration and ensure the server is accessible. Error: {str(e)}",
server_name=self.server_config.server_name,
) from e
async def list_tools(self, serialize: bool = False) -> List[MCPTool]:
@@ -241,22 +244,24 @@ class AsyncFastMCPStreamableHTTPClient:
await self.client._connect()
self.initialized = True
except httpx.HTTPStatusError as e:
# Re-raise HTTP status errors for OAuth flow handling
if e.response.status_code == 401:
raise ConnectionError("401 Unauthorized") from e
raise ConnectionError(f"HTTP error connecting to MCP server at {self.server_config.server_url}: {e}") from e
except ConnectionError:
# Re-raise ConnectionError as-is
raise LettaMCPConnectionError(message="401 Unauthorized", server_name=self.server_config.server_name) from e
raise LettaMCPConnectionError(
message=f"HTTP error connecting to MCP server at {self.server_config.server_url}: {e}",
server_name=self.server_config.server_name,
) from e
except LettaMCPConnectionError:
raise
except ConnectionError as e:
raise LettaMCPConnectionError(message=str(e), server_name=self.server_config.server_name) from e
except Exception as e:
# MCP connection failures are often due to user misconfiguration, not system errors
# Log as warning for visibility in monitoring
logger.warning(
f"Connecting to MCP server failed. Please review your server config: {self.server_config.model_dump_json(indent=4)}. Error: {str(e)}"
)
raise ConnectionError(
f"Failed to connect to MCP server at '{self.server_config.server_url}'. "
f"Please check your configuration and ensure the server is accessible. Error: {str(e)}"
raise LettaMCPConnectionError(
message=f"Failed to connect to MCP server at '{self.server_config.server_url}'. "
f"Please check your configuration and ensure the server is accessible. Error: {str(e)}",
server_name=self.server_config.server_name,
) from e
async def list_tools(self, serialize: bool = False) -> List[MCPTool]: