* fix: strip whitespace from API keys in LLM client headers Fixes httpx.LocalProtocolError when API keys contain leading/trailing whitespace. Strips whitespace from API keys before using them in HTTP headers across: - OpenAI client (openai.py) - Mistral client (mistral.py) - Anthropic client (anthropic_client.py) - Anthropic schema provider (schemas/providers/anthropic.py) - Google AI client (google_ai_client.py) - Proxy helpers (proxy_helpers.py) 🐾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix: handle McpError gracefully in MCP client execute_tool Return error as failed result instead of re-raising to avoid Datadog alerts for expected user-facing errors like missing tool arguments. * fix: strip whitespace from API keys before passing to httpx client Fixes httpx.LocalProtocolError by stripping leading/trailing whitespace from API keys before passing them to OpenAI/AsyncOpenAI clients. The OpenAI client library constructs Authorization headers internally, and invalid header values (like keys with leading spaces) cause protocol errors. Applied fix to: - azure_client.py (AzureOpenAI/AsyncAzureOpenAI) - deepseek_client.py (OpenAI/AsyncOpenAI) - openai_client.py (OpenAI/AsyncOpenAI via kwargs) - xai_client.py (OpenAI/AsyncOpenAI) 🐾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix: handle JSONDecodeError in OpenAI client requests Catches json.JSONDecodeError from OpenAI SDK when API returns invalid JSON (typically HTML error pages from 500-series errors) and converts to LLMServerError with helpful details. 🐾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix(core): strip API key whitespace at schema level on write/create Add field_validator to ProviderCreate, ProviderUpdate, and ProviderCheck schemas to strip whitespace from api_key and access_key fields before persistence. This ensures keys are clean at the point of entry, preventing whitespace from being encrypted and stored in the database. Co-authored-by: Kian Jones <kianjones9@users.noreply.github.com> * refactor: remove api_key.strip() calls across all LLM clients Remove redundant .strip() calls on api_key parameters since pydantic models now handle whitespace trimming at the validation layer. This centralizes the validation logic and follows DRY principles. - Updated 13 files across multiple LLM client implementations - Removed 34 occurrences of api_key.strip() - Includes: OpenAI, Anthropic, Azure, Google AI, Groq, XAI, DeepSeek, ZAI, Together, Mistral - Also updated proxy helpers and provider schemas 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * refactor: remove redundant ternary operators from api_key parameters Remove `if api_key else None` ternaries since pydantic validation ensures api_key is either a valid string or None. The ternary was defensive programming that's now unnecessary with proper model-level validation. - Simplified 23 occurrences across 7 files - Cleaner, more concise client initialization code - No behavioral change since pydantic already handles this 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> --------- Co-authored-by: Letta <noreply@letta.com> Co-authored-by: letta-code <248085862+letta-code@users.noreply.github.com> Co-authored-by: Kian Jones <kianjones9@users.noreply.github.com>
134 lines
6.2 KiB
Python
134 lines
6.2 KiB
Python
from contextlib import AsyncExitStack
|
|
from typing import Optional, Tuple
|
|
|
|
from mcp import ClientSession, Tool as MCPTool
|
|
from mcp.client.auth import OAuthClientProvider
|
|
from mcp.types import TextContent
|
|
|
|
from letta.functions.mcp_client.types import BaseServerConfig
|
|
from letta.log import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
# TODO: Get rid of Async prefix on this class name once we deprecate old sync code
|
|
class AsyncBaseMCPClient:
|
|
# HTTP headers
|
|
AGENT_ID_HEADER = "X-Agent-Id"
|
|
|
|
def __init__(
|
|
self, server_config: BaseServerConfig, oauth_provider: Optional[OAuthClientProvider] = None, agent_id: Optional[str] = None
|
|
):
|
|
self.server_config = server_config
|
|
self.oauth_provider = oauth_provider
|
|
self.agent_id = agent_id
|
|
self.exit_stack = AsyncExitStack()
|
|
self.session: Optional[ClientSession] = None
|
|
self.initialized = False
|
|
|
|
async def connect_to_server(self):
|
|
try:
|
|
await self._initialize_connection(self.server_config)
|
|
await self.session.initialize()
|
|
self.initialized = True
|
|
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
|
|
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)}"
|
|
)
|
|
if hasattr(self.server_config, "server_url") and self.server_config.server_url:
|
|
server_info = f"server URL '{self.server_config.server_url}'"
|
|
elif hasattr(self.server_config, "command") and self.server_config.command:
|
|
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."
|
|
) from e
|
|
|
|
async def _initialize_connection(self, server_config: BaseServerConfig) -> None:
|
|
raise NotImplementedError("Subclasses must implement _initialize_connection")
|
|
|
|
async def list_tools(self, serialize: bool = False) -> list[MCPTool]:
|
|
self._check_initialized()
|
|
response = await self.session.list_tools()
|
|
if serialize:
|
|
serializable_tools = []
|
|
for tool in response.tools:
|
|
if hasattr(tool, "model_dump"):
|
|
# Pydantic model - use model_dump
|
|
serializable_tools.append(tool.model_dump())
|
|
elif hasattr(tool, "dict"):
|
|
# Older Pydantic model - use dict()
|
|
serializable_tools.append(tool.dict())
|
|
elif hasattr(tool, "__dict__"):
|
|
# Regular object - use __dict__
|
|
serializable_tools.append(tool.__dict__)
|
|
else:
|
|
# Fallback - convert to string
|
|
serializable_tools.append(str(tool))
|
|
return serializable_tools
|
|
return response.tools
|
|
|
|
async def execute_tool(self, tool_name: str, tool_args: dict) -> Tuple[str, bool]:
|
|
self._check_initialized()
|
|
try:
|
|
result = await self.session.call_tool(tool_name, tool_args)
|
|
except Exception as e:
|
|
# ToolError is raised by fastmcp for input validation errors (e.g., missing required properties)
|
|
# 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)}")
|
|
# Return error message with failure status instead of raising to avoid Datadog alerts
|
|
return str(e), False
|
|
# Re-raise unexpected errors
|
|
raise
|
|
|
|
parsed_content = []
|
|
for content_piece in result.content:
|
|
if isinstance(content_piece, TextContent):
|
|
parsed_content.append(content_piece.text)
|
|
logger.debug(f"MCP tool result parsed content (text): {parsed_content}")
|
|
else:
|
|
parsed_content.append(str(content_piece))
|
|
logger.debug(f"MCP tool result parsed content (other): {parsed_content}")
|
|
if len(parsed_content) > 0:
|
|
final_content = " ".join(parsed_content)
|
|
else:
|
|
# TODO move hardcoding to constants
|
|
final_content = "Empty response from tool"
|
|
|
|
return final_content, not result.isError
|
|
|
|
def _check_initialized(self):
|
|
if not self.initialized:
|
|
logger.error("MCPClient has not been initialized")
|
|
raise RuntimeError("MCPClient has not been initialized")
|
|
|
|
async def cleanup(self):
|
|
"""Clean up resources used by the MCP client.
|
|
|
|
This method handles ExceptionGroup errors that can occur when closing async context managers
|
|
(e.g., from the MCP library's internal TaskGroup usage). Cleanup is a best-effort operation
|
|
and errors are logged but not re-raised to prevent masking the original exception.
|
|
"""
|
|
try:
|
|
await self.exit_stack.aclose()
|
|
except* Exception as eg:
|
|
# ExceptionGroup can be raised when closing async context managers that use TaskGroup
|
|
# Log each sub-exception at debug level since cleanup errors are expected in some cases
|
|
# (e.g., connection already closed, server unavailable)
|
|
for exc in eg.exceptions:
|
|
logger.debug(f"MCP client cleanup error (suppressed): {type(exc).__name__}: {exc}")
|
|
|
|
def to_sync_client(self):
|
|
raise NotImplementedError("Subclasses must implement to_sync_client")
|