From 47aedfa1a77716affcdcf4f84e363ae6a42fc7f9 Mon Sep 17 00:00:00 2001 From: Kian Jones <11655409+kianjones9@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:02:42 -0800 Subject: [PATCH] fix(core): convert MCP ConnectionError to LettaMCPConnectionError for proper HTTP 502 responses (#9364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../server/rest_api/routers/v1/mcp_servers.py | 4 +- letta/server/rest_api/routers/v1/tools.py | 7 +-- letta/server/server.py | 9 +++- letta/services/mcp/base_client.py | 14 +++--- letta/services/mcp/fastmcp_client.py | 45 ++++++++++--------- 5 files changed, 45 insertions(+), 34 deletions(-) diff --git a/letta/server/rest_api/routers/v1/mcp_servers.py b/letta/server/rest_api/routers/v1/mcp_servers.py index 8037966e..0b037777 100644 --- a/letta/server/rest_api/routers/v1/mcp_servers.py +++ b/letta/server/rest_api/routers/v1/mcp_servers.py @@ -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") diff --git a/letta/server/rest_api/routers/v1/tools.py b/letta/server/rest_api/routers/v1/tools.py index 1884b425..0960ca46 100644 --- a/letta/server/rest_api/routers/v1/tools.py +++ b/letta/server/rest_api/routers/v1/tools.py @@ -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 diff --git a/letta/server/server.py b/letta/server/server.py index 023694ff..0d7584bb 100644 --- a/letta/server/server.py +++ b/letta/server/server.py @@ -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() diff --git a/letta/services/mcp/base_client.py b/letta/services/mcp/base_client.py index a5e76ce2..31b25e29 100644 --- a/letta/services/mcp/base_client.py +++ b/letta/services/mcp/base_client.py @@ -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: diff --git a/letta/services/mcp/fastmcp_client.py b/letta/services/mcp/fastmcp_client.py index cc20f0a1..01190e56 100644 --- a/letta/services/mcp/fastmcp_client.py +++ b/letta/services/mcp/fastmcp_client.py @@ -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]: