diff --git a/letta/server/rest_api/routers/v1/tools.py b/letta/server/rest_api/routers/v1/tools.py index 16b5245a..0336ae29 100644 --- a/letta/server/rest_api/routers/v1/tools.py +++ b/letta/server/rest_api/routers/v1/tools.py @@ -689,7 +689,6 @@ async def connect_mcp_server( yield oauth_stream_event(OauthStreamEvent.CONNECTION_ATTEMPT, server_name=request.server_name) actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id) - # Create MCP client with respective transport type try: request.resolve_environment_variables() @@ -704,8 +703,18 @@ 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 as e: + # Only trigger OAuth flow on explicit unauthorized failures + unauthorized = False + if isinstance(e.__cause__, HTTPStatusError): + unauthorized = e.__cause__.response.status_code == 401 + elif "401" in str(e) or "Unauthorized" in str(e): + unauthorized = True + + if not unauthorized: + yield oauth_stream_event(OauthStreamEvent.ERROR, message=f"Connection failed: {str(e)}") + return + 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/server.py b/letta/server/server.py index db089c8c..82527bf2 100644 --- a/letta/server/server.py +++ b/letta/server/server.py @@ -98,7 +98,8 @@ from letta.services.identity_manager import IdentityManager from letta.services.job_manager import JobManager from letta.services.llm_batch_manager import LLMBatchManager from letta.services.mcp.base_client import AsyncBaseMCPClient -from letta.services.mcp.sse_client import MCP_CONFIG_TOPLEVEL_KEY, AsyncSSEMCPClient +from letta.services.mcp.fastmcp_client import AsyncFastMCPSSEClient +from letta.services.mcp.sse_client import MCP_CONFIG_TOPLEVEL_KEY from letta.services.mcp.stdio_client import AsyncStdioMCPClient from letta.services.mcp_manager import MCPManager from letta.services.mcp_server_manager import MCPServerManager @@ -452,7 +453,7 @@ class SyncServer(object): for server_name, server_config in mcp_server_configs.items(): if server_config.type == MCPServerType.SSE: - self.mcp_clients[server_name] = AsyncSSEMCPClient(server_config) + self.mcp_clients[server_name] = AsyncFastMCPSSEClient(server_config) elif server_config.type == MCPServerType.STDIO: self.mcp_clients[server_name] = AsyncStdioMCPClient(server_config) else: @@ -1573,7 +1574,7 @@ class SyncServer(object): # Attempt to initialize the connection to the server if server_config.type == MCPServerType.SSE: - new_mcp_client = AsyncSSEMCPClient(server_config) + new_mcp_client = AsyncFastMCPSSEClient(server_config) elif server_config.type == MCPServerType.STDIO: new_mcp_client = AsyncStdioMCPClient(server_config) else: diff --git a/letta/services/mcp/fastmcp_client.py b/letta/services/mcp/fastmcp_client.py new file mode 100644 index 00000000..d6766803 --- /dev/null +++ b/letta/services/mcp/fastmcp_client.py @@ -0,0 +1,307 @@ +"""FastMCP-based MCP clients with server-side OAuth support. + +This module provides MCP client implementations using the FastMCP library, +with support for server-side OAuth flows where authorization URLs are +forwarded to web clients instead of opening a browser. + +These clients replace the existing AsyncSSEMCPClient and AsyncStreamableHTTPMCPClient +implementations that used the lower-level MCP SDK directly. +""" + +from contextlib import AsyncExitStack +from typing import List, Optional, Tuple + +import httpx +from fastmcp import Client +from fastmcp.client.transports import SSETransport, StreamableHttpTransport +from mcp import Tool as MCPTool + +from letta.functions.mcp_client.types import SSEServerConfig, StreamableHTTPServerConfig +from letta.log import get_logger +from letta.services.mcp.server_side_oauth import ServerSideOAuth + +logger = get_logger(__name__) + + +class AsyncFastMCPSSEClient: + """SSE MCP client using FastMCP with server-side OAuth support. + + This client connects to MCP servers using Server-Sent Events (SSE) transport. + It supports both authenticated and unauthenticated connections, with OAuth + handled via the ServerSideOAuth class for server-side flows. + + Args: + server_config: SSE server configuration including URL, headers, and auth settings + oauth: Optional ServerSideOAuth instance for OAuth authentication + agent_id: Optional agent ID to include in request headers + """ + + AGENT_ID_HEADER = "X-Agent-Id" + + def __init__( + self, + server_config: SSEServerConfig, + oauth: Optional[ServerSideOAuth] = None, + agent_id: Optional[str] = None, + ): + self.server_config = server_config + self.oauth = oauth + self.agent_id = agent_id + self.client: Optional[Client] = None + self.initialized = False + self.exit_stack = AsyncExitStack() + + async def connect_to_server(self): + """Establish connection to the MCP server. + + Raises: + ConnectionError: If connection to the server fails + """ + try: + headers = {} + if self.server_config.custom_headers: + headers.update(self.server_config.custom_headers) + if self.server_config.auth_header and self.server_config.auth_token: + headers[self.server_config.auth_header] = self.server_config.auth_token + if self.agent_id: + headers[self.AGENT_ID_HEADER] = self.agent_id + + transport = SSETransport( + url=self.server_config.server_url, + headers=headers if headers else None, + auth=self.oauth, # Pass ServerSideOAuth instance (or None) + ) + + self.client = Client(transport) + 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") + raise e + except Exception as e: + raise e + + async def list_tools(self, serialize: bool = False) -> List[MCPTool]: + """List available tools from the MCP server. + + Args: + serialize: If True, return tools as dictionaries instead of MCPTool objects + + Returns: + List of tools available on the server + + Raises: + RuntimeError: If client has not been initialized + """ + self._check_initialized() + tools = await self.client.list_tools() + if serialize: + serializable_tools = [] + for tool in tools: + if hasattr(tool, "model_dump"): + serializable_tools.append(tool.model_dump()) + elif hasattr(tool, "dict"): + serializable_tools.append(tool.dict()) + elif hasattr(tool, "__dict__"): + serializable_tools.append(tool.__dict__) + else: + serializable_tools.append(str(tool)) + return serializable_tools + return tools + + async def execute_tool(self, tool_name: str, tool_args: dict) -> Tuple[str, bool]: + """Execute a tool on the MCP server. + + Args: + tool_name: Name of the tool to execute + tool_args: Arguments to pass to the tool + + Returns: + Tuple of (result_content, success_flag) + + Raises: + RuntimeError: If client has not been initialized + """ + self._check_initialized() + try: + result = await self.client.call_tool(tool_name, tool_args) + except Exception as e: + if e.__class__.__name__ == "McpError": + logger.warning(f"MCP tool '{tool_name}' execution failed: {str(e)}") + raise + + # Parse content from result + parsed_content = [] + for content_piece in result.content: + if hasattr(content_piece, "text"): + 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 parsed_content: + final_content = " ".join(parsed_content) + else: + final_content = "Empty response from tool" + + return final_content, not result.is_error + + def _check_initialized(self): + """Check if the client has been initialized.""" + if not self.initialized: + logger.error("MCPClient has not been initialized") + raise RuntimeError("MCPClient has not been initialized") + + async def cleanup(self): + """Clean up client resources.""" + if self.client: + try: + await self.client.close() + except Exception as e: + logger.warning(f"Error during FastMCP client cleanup: {e}") + self.initialized = False + + +class AsyncFastMCPStreamableHTTPClient: + """Streamable HTTP MCP client using FastMCP with server-side OAuth support. + + This client connects to MCP servers using Streamable HTTP transport. + It supports both authenticated and unauthenticated connections, with OAuth + handled via the ServerSideOAuth class for server-side flows. + + Args: + server_config: Streamable HTTP server configuration + oauth: Optional ServerSideOAuth instance for OAuth authentication + agent_id: Optional agent ID to include in request headers + """ + + AGENT_ID_HEADER = "X-Agent-Id" + + def __init__( + self, + server_config: StreamableHTTPServerConfig, + oauth: Optional[ServerSideOAuth] = None, + agent_id: Optional[str] = None, + ): + self.server_config = server_config + self.oauth = oauth + self.agent_id = agent_id + self.client: Optional[Client] = None + self.initialized = False + self.exit_stack = AsyncExitStack() + + async def connect_to_server(self): + """Establish connection to the MCP server. + + Raises: + ConnectionError: If connection to the server fails + """ + try: + headers = {} + if self.server_config.custom_headers: + headers.update(self.server_config.custom_headers) + if self.server_config.auth_header and self.server_config.auth_token: + headers[self.server_config.auth_header] = self.server_config.auth_token + if self.agent_id: + headers[self.AGENT_ID_HEADER] = self.agent_id + + transport = StreamableHttpTransport( + url=self.server_config.server_url, + headers=headers if headers else None, + auth=self.oauth, # Pass ServerSideOAuth instance (or None) + ) + + self.client = Client(transport) + 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") + raise e + except Exception as e: + raise e + + async def list_tools(self, serialize: bool = False) -> List[MCPTool]: + """List available tools from the MCP server. + + Args: + serialize: If True, return tools as dictionaries instead of MCPTool objects + + Returns: + List of tools available on the server + + Raises: + RuntimeError: If client has not been initialized + """ + self._check_initialized() + tools = await self.client.list_tools() + if serialize: + serializable_tools = [] + for tool in tools: + if hasattr(tool, "model_dump"): + serializable_tools.append(tool.model_dump()) + elif hasattr(tool, "dict"): + serializable_tools.append(tool.dict()) + elif hasattr(tool, "__dict__"): + serializable_tools.append(tool.__dict__) + else: + serializable_tools.append(str(tool)) + return serializable_tools + return tools + + async def execute_tool(self, tool_name: str, tool_args: dict) -> Tuple[str, bool]: + """Execute a tool on the MCP server. + + Args: + tool_name: Name of the tool to execute + tool_args: Arguments to pass to the tool + + Returns: + Tuple of (result_content, success_flag) + + Raises: + RuntimeError: If client has not been initialized + """ + self._check_initialized() + try: + result = await self.client.call_tool(tool_name, tool_args) + except Exception as e: + if e.__class__.__name__ == "McpError": + logger.warning(f"MCP tool '{tool_name}' execution failed: {str(e)}") + raise + + # Parse content from result + parsed_content = [] + for content_piece in result.content: + if hasattr(content_piece, "text"): + 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 parsed_content: + final_content = " ".join(parsed_content) + else: + final_content = "Empty response from tool" + + return final_content, not result.is_error + + def _check_initialized(self): + """Check if the client has been initialized.""" + if not self.initialized: + logger.error("MCPClient has not been initialized") + raise RuntimeError("MCPClient has not been initialized") + + async def cleanup(self): + """Clean up client resources.""" + if self.client: + try: + await self.client.close() + except Exception as e: + logger.warning(f"Error during FastMCP client cleanup: {e}") + self.initialized = False diff --git a/letta/services/mcp/oauth_utils.py b/letta/services/mcp/oauth_utils.py index b2c90d5c..52599008 100644 --- a/letta/services/mcp/oauth_utils.py +++ b/letta/services/mcp/oauth_utils.py @@ -6,7 +6,7 @@ import secrets import time import uuid from datetime import datetime, timedelta -from typing import Callable, Optional, Tuple +from typing import TYPE_CHECKING, Callable, Optional, Tuple from mcp.client.auth import OAuthClientProvider, TokenStorage from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken @@ -18,7 +18,9 @@ from letta.schemas.mcp import MCPOAuthSessionUpdate from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry from letta.services.mcp.types import OauthStreamEvent -from letta.services.mcp_manager import MCPManager + +if TYPE_CHECKING: + from letta.services.mcp_manager import MCPManager logger = get_logger(__name__) @@ -26,7 +28,7 @@ logger = get_logger(__name__) class DatabaseTokenStorage(TokenStorage): """Database-backed token storage using MCPOAuth table via mcp_manager.""" - def __init__(self, session_id: str, mcp_manager: MCPManager, actor: PydanticUser): + def __init__(self, session_id: str, mcp_manager: "MCPManager", actor: PydanticUser): self.session_id = session_id self.mcp_manager = mcp_manager self.actor = actor @@ -187,12 +189,17 @@ async def create_oauth_provider( session_id: str, server_url: str, redirect_uri: str, - mcp_manager: MCPManager, + mcp_manager: "MCPManager", actor: PydanticUser, logo_uri: Optional[str] = None, url_callback: Optional[Callable[[str], None]] = None, ) -> OAuthClientProvider: - """Create an OAuth provider for MCP server authentication.""" + """Create an OAuth provider for MCP server authentication. + + DEPRECATED: Use ServerSideOAuth from letta.services.mcp.server_side_oauth instead. + This function is kept for backwards compatibility but will be removed in a future version. + """ + logger.warning("create_oauth_provider is deprecated. Use ServerSideOAuth from letta.services.mcp.server_side_oauth instead.") client_metadata_dict = { "client_name": "Letta", diff --git a/letta/services/mcp/server_side_oauth.py b/letta/services/mcp/server_side_oauth.py new file mode 100644 index 00000000..0876e817 --- /dev/null +++ b/letta/services/mcp/server_side_oauth.py @@ -0,0 +1,167 @@ +"""Server-side OAuth for FastMCP client that works with web app flows. + +This module provides a custom OAuth implementation that: +1. Forwards authorization URLs via callback instead of opening a browser +2. Receives auth codes from an external source (web app callback) instead of running a local server + +This is designed for server-side applications where the OAuth flow must be handled +by a web frontend rather than opening a local browser. +""" + +import asyncio +import time +from typing import Callable, Optional, Tuple +from urllib.parse import urlparse + +from mcp.client.auth import OAuthClientProvider +from mcp.shared.auth import OAuthClientMetadata +from pydantic import AnyHttpUrl + +from letta.log import get_logger +from letta.orm.mcp_oauth import OAuthSessionStatus +from letta.schemas.mcp import MCPOAuthSessionUpdate +from letta.schemas.user import User as PydanticUser +from letta.services.mcp.oauth_utils import DatabaseTokenStorage + +logger = get_logger(__name__) + +# Type alias for the MCPServerManager to avoid circular imports +# The actual type is letta.services.mcp_server_manager.MCPServerManager +MCPManagerType = "MCPServerManager" + + +class ServerSideOAuth(OAuthClientProvider): + """ + OAuth client that forwards authorization URL via callback instead of opening browser, + and receives auth code from external source instead of running local callback server. + + This class subclasses MCP's OAuthClientProvider directly (bypassing FastMCP's OAuth class) + to use DatabaseTokenStorage for persistent token storage instead of file-based storage. + + This class works in a server-side context where: + - The authorization URL should be returned to a web client instead of opening a browser + - The authorization code is received via a webhook/callback endpoint instead of a local server + - Tokens are stored in the database for persistence across server restarts and instances + + Args: + mcp_url: The MCP server URL to authenticate against + session_id: The OAuth session ID for tracking this flow in the database + mcp_manager: The MCP manager instance for database operations + actor: The user making the OAuth request + redirect_uri: The redirect URI for the OAuth callback (web app endpoint) + url_callback: Optional callback function called with the authorization URL + logo_uri: Optional logo URI to include in OAuth client metadata + scopes: OAuth scopes to request + """ + + def __init__( + self, + mcp_url: str, + session_id: str, + mcp_manager: MCPManagerType, + actor: PydanticUser, + redirect_uri: str, + url_callback: Optional[Callable[[str], None]] = None, + logo_uri: Optional[str] = None, + scopes: Optional[str | list[str]] = None, + ): + self.session_id = session_id + self.mcp_manager = mcp_manager + self.actor = actor + self._redirect_uri = redirect_uri + self._url_callback = url_callback + + # Parse URL to get server base URL + parsed_url = urlparse(mcp_url) + server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" + self.server_base_url = server_base_url + + # Build scopes string + scopes_str: str + if isinstance(scopes, list): + scopes_str = " ".join(scopes) + elif scopes is not None: + scopes_str = str(scopes) + else: + scopes_str = "" + + # Create client metadata with the web app's redirect URI + client_metadata = OAuthClientMetadata( + client_name="Letta", + redirect_uris=[AnyHttpUrl(redirect_uri)], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scope=scopes_str, + ) + if logo_uri: + client_metadata.logo_uri = logo_uri + + # Use DatabaseTokenStorage for persistent storage in the database + storage = DatabaseTokenStorage(session_id, mcp_manager, actor) + + # Initialize parent OAuthClientProvider directly (bypassing FastMCP's OAuth class) + # This allows us to use DatabaseTokenStorage instead of FileTokenStorage + super().__init__( + server_url=server_base_url, + client_metadata=client_metadata, + storage=storage, + redirect_handler=self.redirect_handler, + callback_handler=self.callback_handler, + ) + + async def redirect_handler(self, authorization_url: str) -> None: + """Store authorization URL in database and call optional callback. + + This overrides the parent's redirect_handler which would open a browser. + Instead, we: + 1. Store the URL in the database for the API to return + 2. Call an optional callback (e.g., to yield to an SSE stream) + + Args: + authorization_url: The OAuth authorization URL to redirect the user to + """ + logger.info(f"OAuth redirect handler called with URL: {authorization_url}") + + # Store URL in database for API response + session_update = MCPOAuthSessionUpdate(authorization_url=authorization_url) + await self.mcp_manager.update_oauth_session(self.session_id, session_update, self.actor) + + logger.info(f"OAuth authorization URL stored for session {self.session_id}") + + # Call the callback if provided (e.g., to yield URL to SSE stream) + if self._url_callback: + self._url_callback(authorization_url) + + async def callback_handler(self) -> Tuple[str, Optional[str]]: + """Poll database for authorization code set by web app callback. + + This overrides the parent's callback_handler which would run a local server. + Instead, we poll the database waiting for the authorization code to be set + by the web app's callback endpoint. + + Returns: + Tuple of (authorization_code, state) + + Raises: + Exception: If OAuth authorization failed or timed out + """ + timeout = 300 # 5 minutes + start_time = time.time() + + logger.info(f"Waiting for authorization code for session {self.session_id}") + + while time.time() - start_time < timeout: + oauth_session = await self.mcp_manager.get_oauth_session_by_id(self.session_id, self.actor) + + if oauth_session and oauth_session.authorization_code_enc: + # Read authorization code directly from _enc column + auth_code = await oauth_session.authorization_code_enc.get_plaintext_async() + logger.info(f"Authorization code received for session {self.session_id}") + return auth_code, oauth_session.state + + if oauth_session and oauth_session.status == OAuthSessionStatus.ERROR: + raise Exception("OAuth authorization failed") + + await asyncio.sleep(1) + + raise Exception(f"Timeout waiting for OAuth callback after {timeout} seconds") diff --git a/letta/services/mcp_manager.py b/letta/services/mcp_manager.py index cfcf1e33..f8f2fb97 100644 --- a/letta/services/mcp_manager.py +++ b/letta/services/mcp_manager.py @@ -43,9 +43,10 @@ from letta.schemas.tool import Tool as PydanticTool, ToolCreate, ToolUpdate from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry from letta.services.mcp.base_client import AsyncBaseMCPClient -from letta.services.mcp.sse_client import MCP_CONFIG_TOPLEVEL_KEY, AsyncSSEMCPClient +from letta.services.mcp.fastmcp_client import AsyncFastMCPSSEClient, AsyncFastMCPStreamableHTTPClient +from letta.services.mcp.server_side_oauth import ServerSideOAuth +from letta.services.mcp.sse_client import MCP_CONFIG_TOPLEVEL_KEY from letta.services.mcp.stdio_client import AsyncStdioMCPClient -from letta.services.mcp.streamable_http_client import AsyncStreamableHTTPMCPClient from letta.services.tool_manager import ToolManager from letta.settings import settings, tool_settings from letta.utils import enforce_types, printd, safe_create_task_with_return @@ -776,16 +777,17 @@ class MCPManager: self, server_config: Union[SSEServerConfig, StdioServerConfig, StreamableHTTPServerConfig], actor: PydanticUser, - oauth_provider: Optional[Any] = None, + oauth: Optional[ServerSideOAuth] = None, agent_id: Optional[str] = None, - ) -> Union[AsyncSSEMCPClient, AsyncStdioMCPClient, AsyncStreamableHTTPMCPClient]: + ) -> Union[AsyncFastMCPSSEClient, AsyncStdioMCPClient, AsyncFastMCPStreamableHTTPClient]: """ Helper function to create the appropriate MCP client based on server configuration. Args: server_config: The server configuration object actor: The user making the request - oauth_provider: Optional OAuth provider for authentication + oauth: Optional ServerSideOAuth instance for authentication + agent_id: Optional agent ID for request headers Returns: The appropriate MCP client instance @@ -793,31 +795,29 @@ class MCPManager: Raises: ValueError: If server config type is not supported """ - # If no OAuth provider is provided, check if we have stored OAuth credentials - if oauth_provider is None and hasattr(server_config, "server_url"): + # If no OAuth is provided, check if we have stored OAuth credentials + if oauth is None and hasattr(server_config, "server_url"): oauth_session = await self.get_oauth_session_by_server(server_config.server_url, actor) # Check if access token exists by attempting to decrypt it if oauth_session and oauth_session.access_token_enc and await oauth_session.access_token_enc.get_plaintext_async(): - # Create OAuth provider from stored credentials - from letta.services.mcp.oauth_utils import create_oauth_provider - - oauth_provider = await create_oauth_provider( + # Create ServerSideOAuth from stored credentials + oauth = ServerSideOAuth( + mcp_url=oauth_session.server_url, session_id=oauth_session.id, - server_url=oauth_session.server_url, - redirect_uri=oauth_session.redirect_uri, mcp_manager=self, actor=actor, + redirect_uri=oauth_session.redirect_uri, ) if server_config.type == MCPServerType.SSE: server_config = SSEServerConfig(**server_config.model_dump()) - return AsyncSSEMCPClient(server_config=server_config, oauth_provider=oauth_provider, agent_id=agent_id) + return AsyncFastMCPSSEClient(server_config=server_config, oauth=oauth, agent_id=agent_id) elif server_config.type == MCPServerType.STDIO: server_config = StdioServerConfig(**server_config.model_dump()) - return AsyncStdioMCPClient(server_config=server_config, oauth_provider=oauth_provider, agent_id=agent_id) + return AsyncStdioMCPClient(server_config=server_config, oauth_provider=None, agent_id=agent_id) elif server_config.type == MCPServerType.STREAMABLE_HTTP: server_config = StreamableHTTPServerConfig(**server_config.model_dump()) - return AsyncStreamableHTTPMCPClient(server_config=server_config, oauth_provider=oauth_provider, agent_id=agent_id) + return AsyncFastMCPStreamableHTTPClient(server_config=server_config, oauth=oauth, agent_id=agent_id) else: raise ValueError(f"Unsupported server config type: {type(server_config)}") @@ -1057,7 +1057,7 @@ class MCPManager: """ import asyncio - from letta.services.mcp.oauth_utils import create_oauth_provider, oauth_stream_event + from letta.services.mcp.oauth_utils import oauth_stream_event from letta.services.mcp.types import OauthStreamEvent # OAuth required, yield state to client to prepare to handle authorization URL @@ -1098,8 +1098,16 @@ class MCPManager: ) raise HTTPException(status_code=400, detail="No redirect URI found") - # Create OAuth provider for the instance of the stream connection - oauth_provider = await create_oauth_provider(session_id, request.server_url, redirect_uri, self, actor, logo_uri=logo_uri) + # Create ServerSideOAuth for FastMCP client + oauth = ServerSideOAuth( + mcp_url=request.server_url, + session_id=session_id, + mcp_manager=self, + actor=actor, + redirect_uri=redirect_uri, + url_callback=None, # URL is stored by redirect_handler + logo_uri=logo_uri, + ) # Get authorization URL by triggering OAuth flow temp_client = None @@ -1118,7 +1126,7 @@ class MCPManager: try: ready_queue = asyncio.Queue() - temp_client = await self.get_mcp_client(request, actor, oauth_provider) + temp_client = await self.get_mcp_client(request, actor, oauth) temp_client._cleanup_event = asyncio.Event() # Run connect_to_server in background to avoid blocking diff --git a/letta/services/mcp_server_manager.py b/letta/services/mcp_server_manager.py index f05caf80..a8e7bce2 100644 --- a/letta/services/mcp_server_manager.py +++ b/letta/services/mcp_server_manager.py @@ -41,9 +41,10 @@ from letta.schemas.secret import Secret from letta.schemas.tool import Tool as PydanticTool, ToolCreate, ToolUpdate from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry -from letta.services.mcp.sse_client import MCP_CONFIG_TOPLEVEL_KEY, AsyncSSEMCPClient +from letta.services.mcp.fastmcp_client import AsyncFastMCPSSEClient, AsyncFastMCPStreamableHTTPClient +from letta.services.mcp.server_side_oauth import ServerSideOAuth +from letta.services.mcp.sse_client import MCP_CONFIG_TOPLEVEL_KEY from letta.services.mcp.stdio_client import AsyncStdioMCPClient -from letta.services.mcp.streamable_http_client import AsyncStreamableHTTPMCPClient from letta.services.tool_manager import ToolManager from letta.settings import settings, tool_settings from letta.utils import enforce_types, printd, safe_create_task @@ -946,16 +947,17 @@ class MCPServerManager: self, server_config: Union[SSEServerConfig, StdioServerConfig, StreamableHTTPServerConfig], actor: PydanticUser, - oauth_provider: Optional[Any] = None, + oauth: Optional[ServerSideOAuth] = None, agent_id: Optional[str] = None, - ) -> Union[AsyncSSEMCPClient, AsyncStdioMCPClient, AsyncStreamableHTTPMCPClient]: + ) -> Union[AsyncFastMCPSSEClient, AsyncStdioMCPClient, AsyncFastMCPStreamableHTTPClient]: """ Helper function to create the appropriate MCP client based on server configuration. Args: server_config: The server configuration object actor: The user making the request - oauth_provider: Optional OAuth provider for authentication + oauth: Optional ServerSideOAuth instance for authentication + agent_id: Optional agent ID for request headers Returns: The appropriate MCP client instance @@ -964,30 +966,28 @@ class MCPServerManager: ValueError: If server config type is not supported """ # If no OAuth provider is provided, check if we have stored OAuth credentials - if oauth_provider is None and hasattr(server_config, "server_url"): + if oauth is None and hasattr(server_config, "server_url"): oauth_session = await self.get_oauth_session_by_server(server_config.server_url, actor) # Check if access token exists by attempting to decrypt it if oauth_session and await oauth_session.get_access_token_secret().get_plaintext_async(): - # Create OAuth provider from stored credentials - from letta.services.mcp.oauth_utils import create_oauth_provider - - oauth_provider = await create_oauth_provider( + # Create ServerSideOAuth from stored credentials + oauth = ServerSideOAuth( + mcp_url=oauth_session.server_url, session_id=oauth_session.id, - server_url=oauth_session.server_url, - redirect_uri=oauth_session.redirect_uri, mcp_manager=self, actor=actor, + redirect_uri=oauth_session.redirect_uri, ) if server_config.type == MCPServerType.SSE: server_config = SSEServerConfig(**server_config.model_dump()) - return AsyncSSEMCPClient(server_config=server_config, oauth_provider=oauth_provider, agent_id=agent_id) + return AsyncFastMCPSSEClient(server_config=server_config, oauth=oauth, agent_id=agent_id) elif server_config.type == MCPServerType.STDIO: server_config = StdioServerConfig(**server_config.model_dump()) - return AsyncStdioMCPClient(server_config=server_config, oauth_provider=oauth_provider, agent_id=agent_id) + return AsyncStdioMCPClient(server_config=server_config, oauth_provider=None, agent_id=agent_id) elif server_config.type == MCPServerType.STREAMABLE_HTTP: server_config = StreamableHTTPServerConfig(**server_config.model_dump()) - return AsyncStreamableHTTPMCPClient(server_config=server_config, oauth_provider=oauth_provider, agent_id=agent_id) + return AsyncFastMCPStreamableHTTPClient(server_config=server_config, oauth=oauth, agent_id=agent_id) else: raise ValueError(f"Unsupported server config type: {type(server_config)}") @@ -1253,7 +1253,7 @@ class MCPServerManager: """ import asyncio - from letta.services.mcp.oauth_utils import create_oauth_provider, oauth_stream_event + from letta.services.mcp.oauth_utils import oauth_stream_event from letta.services.mcp.types import OauthStreamEvent # OAuth required, yield state to client to prepare to handle authorization URL @@ -1294,14 +1294,22 @@ class MCPServerManager: ) raise HTTPException(status_code=400, detail="No redirect URI found") - # Create OAuth provider for the instance of the stream connection - oauth_provider = await create_oauth_provider(session_id, request.server_url, redirect_uri, self, actor, logo_uri=logo_uri) + # Create ServerSideOAuth for FastMCP client + oauth = ServerSideOAuth( + mcp_url=request.server_url, + session_id=session_id, + mcp_manager=self, + actor=actor, + redirect_uri=redirect_uri, + url_callback=None, # URL is stored by redirect_handler + logo_uri=logo_uri, + ) # Get authorization URL by triggering OAuth flow temp_client = None connect_task = None try: - temp_client = await self.get_mcp_client(request, actor, oauth_provider) + temp_client = await self.get_mcp_client(request, actor, oauth) # Run connect_to_server in background to avoid blocking # This will trigger the OAuth flow and the redirect_handler will save the authorization URL to database diff --git a/pyproject.toml b/pyproject.toml index d251d6c2..125cf55f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ dependencies = [ "google-genai>=1.52.0", "datadog>=0.49.1", "psutil>=5.9.0", + "fastmcp>=2.12.5", "ddtrace>=4.0.1", ] diff --git a/uv.lock b/uv.lock index f84b6671..9b0de174 100644 --- a/uv.lock +++ b/uv.lock @@ -351,6 +351,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] +[[package]] +name = "authlib" +version = "1.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/9b/b1661026ff24bc641b76b78c5222d614776b0c085bcfdac9bd15a1cb4b35/authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e", size = 164894, upload-time = "2025-12-12T08:01:41.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, +] + [[package]] name = "babel" version = "2.17.0" @@ -848,6 +860,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "cyclopts" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/3a/fd746469c7000ccaa75787e8ebd60dc77e4541576ca4ed241cd8b9e7e9ad/cyclopts-4.4.0.tar.gz", hash = "sha256:16764f5a807696b61da7d19626f34d261cdffe33345e87a194cf3286db2bd9cc", size = 158378, upload-time = "2025-12-16T14:03:09.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/18/5ca04dfda3e53b5d07b072033cc9f7bf10f93f78019366bff411433690d1/cyclopts-4.4.0-py3-none-any.whl", hash = "sha256:78ff95a5e52e738a1d0f01e5a3af48049c47748fa2c255f2629a4cef54dcf2b3", size = 195801, upload-time = "2025-12-16T14:03:07.916Z" }, +] + [[package]] name = "dataclasses-json" version = "0.6.7" @@ -1039,6 +1066,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + [[package]] name = "docker" version = "7.1.0" @@ -1062,6 +1098,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/7c/e9fcff7623954d86bdc17782036cbf715ecab1bec4847c008557affe1ca8/docstring_parser-0.16-py3-none-any.whl", hash = "sha256:bf0a1387354d3691d102edef7ec124f219ef639982d096e26e3b60aeffa90637", size = 36533, upload-time = "2024-03-15T10:39:41.527Z" }, ] +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + [[package]] name = "e2b" version = "2.0.0" @@ -1094,6 +1139,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/f1/135acbaffe4b2e63addecfc2a6c2ecf9ea3e5394aa2a9a829e3eb6f2098d/e2b_code_interpreter-2.0.0-py3-none-any.whl", hash = "sha256:273642d4dd78f09327fb1553fe4f7ddcf17892b78f98236e038d29985e42dca5", size = 12939, upload-time = "2025-08-22T10:16:55.698Z" }, ] +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "envier" version = "0.6.1" @@ -1128,6 +1186,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bb/9a/3503696c939aee273a55a20f434d1f61ea623ff12f09a612f0efed1087c7/exa_py-1.15.4-py3-none-any.whl", hash = "sha256:2c29e74f130a086e061bab10cb042f5c7894beb471eddb58dad8b2ed5e916cba", size = 55176, upload-time = "2025-08-29T02:40:49.695Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "executing" version = "2.2.0" @@ -1163,6 +1233,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" }, ] +[[package]] +name = "fastmcp" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "openapi-core" }, + { name = "openapi-pydantic" }, + { name = "pydantic", extra = ["email"] }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/a6/e3b46cd3e228635e0064c2648788b6f66a53bf0d0ddbf5fb44cca951f908/fastmcp-2.12.5.tar.gz", hash = "sha256:2dfd02e255705a4afe43d26caddbc864563036e233dbc6870f389ee523b39a6a", size = 7190263, upload-time = "2025-10-17T13:24:58.896Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/c1/9fb98c9649e15ea8cc691b4b09558b61dafb3dc0345f7322f8c4a8991ade/fastmcp-2.12.5-py3-none-any.whl", hash = "sha256:b1e542f9b83dbae7cecfdc9c73b062f77074785abda9f2306799116121344133", size = 329099, upload-time = "2025-10-17T13:24:57.518Z" }, +] + [[package]] name = "filelock" version = "3.19.1" @@ -1970,6 +2062,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + [[package]] name = "isort" version = "6.0.1" @@ -2113,6 +2214,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, ] +[[package]] +name = "jsonschema-path" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" }, +] + [[package]] name = "jsonschema-specifications" version = "2025.4.1" @@ -2320,6 +2436,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/73/91a506e17bb1bc6d20c2c04cf7b459dc58951bfbfe7f97f2c952646b4500/langsmith-0.4.18-py3-none-any.whl", hash = "sha256:ad63154f503678356aadf5b999f40393b4bbd332aee2d04cde3e431c61f2e1c2", size = 376444, upload-time = "2025-08-26T17:00:03.564Z" }, ] +[[package]] +name = "lazy-object-proxy" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681, upload-time = "2025-08-22T13:50:06.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/b3/4684b1e128a87821e485f5a901b179790e6b5bc02f89b7ee19c23be36ef3/lazy_object_proxy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1cf69cd1a6c7fe2dbcc3edaa017cf010f4192e53796538cc7d5e1fedbfa4bcff", size = 26656, upload-time = "2025-08-22T13:42:30.605Z" }, + { url = "https://files.pythonhosted.org/packages/3a/03/1bdc21d9a6df9ff72d70b2ff17d8609321bea4b0d3cffd2cea92fb2ef738/lazy_object_proxy-1.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:efff4375a8c52f55a145dc8487a2108c2140f0bec4151ab4e1843e52eb9987ad", size = 68832, upload-time = "2025-08-22T13:42:31.675Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4b/5788e5e8bd01d19af71e50077ab020bc5cce67e935066cd65e1215a09ff9/lazy_object_proxy-1.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1192e8c2f1031a6ff453ee40213afa01ba765b3dc861302cd91dbdb2e2660b00", size = 69148, upload-time = "2025-08-22T13:42:32.876Z" }, + { url = "https://files.pythonhosted.org/packages/79/0e/090bf070f7a0de44c61659cb7f74c2fe02309a77ca8c4b43adfe0b695f66/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3605b632e82a1cbc32a1e5034278a64db555b3496e0795723ee697006b980508", size = 67800, upload-time = "2025-08-22T13:42:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d2/b320325adbb2d119156f7c506a5fbfa37fcab15c26d13cf789a90a6de04e/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a61095f5d9d1a743e1e20ec6d6db6c2ca511961777257ebd9b288951b23b44fa", size = 68085, upload-time = "2025-08-22T13:42:35.197Z" }, + { url = "https://files.pythonhosted.org/packages/6a/48/4b718c937004bf71cd82af3713874656bcb8d0cc78600bf33bb9619adc6c/lazy_object_proxy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:997b1d6e10ecc6fb6fe0f2c959791ae59599f41da61d652f6c903d1ee58b7370", size = 26535, upload-time = "2025-08-22T13:42:36.521Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1b/b5f5bd6bda26f1e15cd3232b223892e4498e34ec70a7f4f11c401ac969f1/lazy_object_proxy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ee0d6027b760a11cc18281e702c0309dd92da458a74b4c15025d7fc490deede", size = 26746, upload-time = "2025-08-22T13:42:37.572Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/314889b618075c2bfc19293ffa9153ce880ac6153aacfd0a52fcabf21a66/lazy_object_proxy-1.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ab2c584e3cc8be0dfca422e05ad30a9abe3555ce63e9ab7a559f62f8dbc6ff9", size = 71457, upload-time = "2025-08-22T13:42:38.743Z" }, + { url = "https://files.pythonhosted.org/packages/11/53/857fc2827fc1e13fbdfc0ba2629a7d2579645a06192d5461809540b78913/lazy_object_proxy-1.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e348185adbd03ec17d051e169ec45686dcd840a3779c9d4c10aabe2ca6e1c0", size = 71036, upload-time = "2025-08-22T13:42:40.184Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/e581ffed864cd33c1b445b5763d617448ebb880f48675fc9de0471a95cbc/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4fcbe74fb85df8ba7825fa05eddca764138da752904b378f0ae5ab33a36c308", size = 69329, upload-time = "2025-08-22T13:42:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/15f8f5a0b0b2e668e756a152257d26370132c97f2f1943329b08f057eff0/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:563d2ec8e4d4b68ee7848c5ab4d6057a6d703cb7963b342968bb8758dda33a23", size = 70690, upload-time = "2025-08-22T13:42:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/5d/aa/f02be9bbfb270e13ee608c2b28b8771f20a5f64356c6d9317b20043c6129/lazy_object_proxy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:53c7fd99eb156bbb82cbc5d5188891d8fdd805ba6c1e3b92b90092da2a837073", size = 26563, upload-time = "2025-08-22T13:42:43.685Z" }, + { url = "https://files.pythonhosted.org/packages/f4/26/b74c791008841f8ad896c7f293415136c66cc27e7c7577de4ee68040c110/lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e", size = 26745, upload-time = "2025-08-22T13:42:44.982Z" }, + { url = "https://files.pythonhosted.org/packages/9b/52/641870d309e5d1fb1ea7d462a818ca727e43bfa431d8c34b173eb090348c/lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e", size = 71537, upload-time = "2025-08-22T13:42:46.141Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/919118e99d51c5e76e8bf5a27df406884921c0acf2c7b8a3b38d847ab3e9/lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655", size = 71141, upload-time = "2025-08-22T13:42:47.375Z" }, + { url = "https://files.pythonhosted.org/packages/e5/47/1d20e626567b41de085cf4d4fb3661a56c159feaa73c825917b3b4d4f806/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff", size = 69449, upload-time = "2025-08-22T13:42:48.49Z" }, + { url = "https://files.pythonhosted.org/packages/58/8d/25c20ff1a1a8426d9af2d0b6f29f6388005fc8cd10d6ee71f48bff86fdd0/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be", size = 70744, upload-time = "2025-08-22T13:42:49.608Z" }, + { url = "https://files.pythonhosted.org/packages/c0/67/8ec9abe15c4f8a4bcc6e65160a2c667240d025cbb6591b879bea55625263/lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1", size = 26568, upload-time = "2025-08-22T13:42:57.719Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/cd2235463f3469fd6c62d41d92b7f120e8134f76e52421413a0ad16d493e/lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65", size = 27391, upload-time = "2025-08-22T13:42:50.62Z" }, + { url = "https://files.pythonhosted.org/packages/60/9e/f1c53e39bbebad2e8609c67d0830cc275f694d0ea23d78e8f6db526c12d3/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9", size = 80552, upload-time = "2025-08-22T13:42:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/6c513693448dcb317d9d8c91d91f47addc09553613379e504435b4cc8b3e/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66", size = 82857, upload-time = "2025-08-22T13:42:53.225Z" }, + { url = "https://files.pythonhosted.org/packages/12/1c/d9c4aaa4c75da11eb7c22c43d7c90a53b4fca0e27784a5ab207768debea7/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847", size = 80833, upload-time = "2025-08-22T13:42:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ae/29117275aac7d7d78ae4f5a4787f36ff33262499d486ac0bf3e0b97889f6/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac", size = 79516, upload-time = "2025-08-22T13:42:55.812Z" }, + { url = "https://files.pythonhosted.org/packages/19/40/b4e48b2c38c69392ae702ae7afa7b6551e0ca5d38263198b7c79de8b3bdf/lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f", size = 27656, upload-time = "2025-08-22T13:42:56.793Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/b91504515c1f9a299fc157967ffbd2f0321bce0516a3d5b89f6f4cad0355/lazy_object_proxy-1.12.0-pp39.pp310.pp311.graalpy311-none-any.whl", hash = "sha256:c3b2e0af1f7f77c4263759c4824316ce458fabe0fceadcd24ef8ca08b2d1e402", size = 15072, upload-time = "2025-08-22T13:50:05.498Z" }, +] + [[package]] name = "letta" version = "0.16.1" @@ -2340,6 +2489,7 @@ dependencies = [ { name = "docstring-parser" }, { name = "exa-py" }, { name = "faker" }, + { name = "fastmcp" }, { name = "google-genai" }, { name = "grpcio" }, { name = "grpcio-tools" }, @@ -2498,6 +2648,7 @@ requires-dist = [ { name = "faker", specifier = ">=36.1.0" }, { name = "fastapi", marker = "extra == 'desktop'", specifier = ">=0.115.6" }, { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.115.6" }, + { name = "fastmcp", specifier = ">=2.12.5" }, { name = "google-genai", specifier = ">=1.52.0" }, { name = "granian", extras = ["uvloop", "reload"], marker = "extra == 'experimental'", specifier = ">=2.3.2" }, { name = "grpcio", specifier = ">=1.68.1" }, @@ -3550,6 +3701,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/f1/d9251b565fce9f8daeb45611e3e0d2f7f248429e40908dcee3b6fe1b5944/openai-2.11.0-py3-none-any.whl", hash = "sha256:21189da44d2e3d027b08c7a920ba4454b8b7d6d30ae7e64d9de11dbe946d4faa", size = 1064131, upload-time = "2025-12-11T19:11:56.816Z" }, ] +[[package]] +name = "openapi-core" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "isodate" }, + { name = "jsonschema" }, + { name = "jsonschema-path" }, + { name = "more-itertools" }, + { name = "openapi-schema-validator" }, + { name = "openapi-spec-validator" }, + { name = "typing-extensions" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/f7/fe803810dbf6fd9064b35957ef26a711480cbb40e2fa5289bac6ad93dc1d/openapi_core-0.21.0.tar.gz", hash = "sha256:a9e378fafbf3708a2ca827608388bb3d46d7ba07f9758b78244ffee6503a772e", size = 105730, upload-time = "2025-12-16T15:34:52.709Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/7e/e3f066af76486c5e9d1b59b25693495bb48b66847d694a03fedce445dbbc/openapi_core-0.21.0-py3-none-any.whl", hash = "sha256:ef1605ed661fdd548f0c037b92e8b1dfb6052b3ad3e93247f2896c8bdb7d4974", size = 107473, upload-time = "2025-12-16T15:34:49.921Z" }, +] + +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "openapi-schema-validator" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-specifications" }, + { name = "rfc3339-validator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/f3/5507ad3325169347cd8ced61c232ff3df70e2b250c49f0fe140edb4973c6/openapi_schema_validator-0.6.3.tar.gz", hash = "sha256:f37bace4fc2a5d96692f4f8b31dc0f8d7400fd04f3a937798eaf880d425de6ee", size = 11550, upload-time = "2025-01-10T18:08:22.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/c6/ad0fba32775ae749016829dace42ed80f4407b171da41313d1a3a5f102e4/openapi_schema_validator-0.6.3-py3-none-any.whl", hash = "sha256:f3b9870f4e556b5a62a1c39da72a6b4b16f3ad9c73dc80084b1b11e74ba148a3", size = 8755, upload-time = "2025-01-10T18:08:19.758Z" }, +] + +[[package]] +name = "openapi-spec-validator" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-path" }, + { name = "lazy-object-proxy" }, + { name = "openapi-schema-validator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/af/fe2d7618d6eae6fb3a82766a44ed87cd8d6d82b4564ed1c7cfb0f6378e91/openapi_spec_validator-0.7.2.tar.gz", hash = "sha256:cc029309b5c5dbc7859df0372d55e9d1ff43e96d678b9ba087f7c56fc586f734", size = 36855, upload-time = "2025-06-07T14:48:56.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/dd/b3fd642260cb17532f66cc1e8250f3507d1e580483e209dc1e9d13bd980d/openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60", size = 39713, upload-time = "2025-06-07T14:48:54.077Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.30.0" @@ -3830,6 +4041,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, ] +[[package]] +name = "pathable" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, +] + [[package]] name = "pathspec" version = "0.12.1" @@ -4363,6 +4583,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.33.2" @@ -4478,6 +4703,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/ef/68c0f473d8b8764b23f199450dfa035e6f2206e67e9bde5dd695bab9bdf0/pypdf-6.4.1-py3-none-any.whl", hash = "sha256:1782ee0766f0b77defc305f1eb2bafe738a2ef6313f3f3d2ee85b4542ba7e535", size = 328325, upload-time = "2025-12-07T14:19:26.286Z" }, ] +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + [[package]] name = "pyreadline3" version = "3.5.4" @@ -4908,6 +5142,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + [[package]] name = "rich" version = "13.9.4" @@ -4921,6 +5167,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, ] +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, +] + [[package]] name = "rpds-py" version = "0.27.0"