feat: redirect ouath callback through web for cloud

Co-authored-by: Jin Peng <jinjpeng@Jins-MacBook-Pro.local>
This commit is contained in:
jnjpng
2025-07-29 17:36:30 -07:00
committed by GitHub
parent 2224dc5f23
commit bd87f62b89
3 changed files with 43 additions and 168 deletions

View File

@@ -12,8 +12,7 @@ from composio.exceptions import (
EnumMetadataNotFound,
EnumStringNotFound,
)
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query
from fastapi.responses import HTMLResponse
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Request
from pydantic import BaseModel, Field
from starlette.responses import StreamingResponse
@@ -38,16 +37,10 @@ from letta.schemas.tool import Tool, ToolCreate, ToolRunFromSource, ToolUpdate
from letta.server.rest_api.streaming_response import StreamingResponseWithStatusCode
from letta.server.rest_api.utils import get_letta_server
from letta.server.server import SyncServer
from letta.services.mcp.oauth_utils import (
MCPOAuthSession,
create_oauth_provider,
drill_down_exception,
get_oauth_success_html,
oauth_stream_event,
)
from letta.services.mcp.oauth_utils import MCPOAuthSession, create_oauth_provider, drill_down_exception, oauth_stream_event
from letta.services.mcp.stdio_client import AsyncStdioMCPClient
from letta.services.mcp.types import OauthStreamEvent
from letta.settings import tool_settings
from letta.settings import settings, tool_settings
router = APIRouter(prefix="/tools", tags=["tools"])
@@ -700,6 +693,7 @@ async def connect_mcp_server(
request: Union[StdioServerConfig, SSEServerConfig, StreamableHTTPServerConfig] = Body(...),
server: SyncServer = Depends(get_letta_server),
actor_id: Optional[str] = Header(None, alias="user_id"),
http_request: Request = None,
) -> StreamingResponse:
"""
Connect to an MCP server with support for OAuth via SSE.
@@ -708,6 +702,7 @@ async def connect_mcp_server(
async def oauth_stream_generator(
request: Union[StdioServerConfig, SSEServerConfig, StreamableHTTPServerConfig],
http_request: Request,
) -> AsyncGenerator[str, None]:
client = None
oauth_provider = None
@@ -759,11 +754,32 @@ async def connect_mcp_server(
oauth_session = await server.mcp_manager.create_oauth_session(session_create, actor)
session_id = oauth_session.id
# TODO: @jnjpng make this check more robust
# Check if request is from web frontend to determine redirect URI
is_web_request = (
http_request
and http_request.headers
and http_request.headers.get("user-agent", "") == "Next.js Middleware"
and http_request.headers.__contains__("x-organization-id")
)
logo_uri = None
NEXT_PUBLIC_CURRENT_HOST = settings.next_public_current_host
LETTA_AGENTS_ENDPOINT = settings.letta_agents_endpoint
if is_web_request and NEXT_PUBLIC_CURRENT_HOST:
redirect_uri = f"{NEXT_PUBLIC_CURRENT_HOST}/oauth/callback/{session_id}"
logo_uri = f"{NEXT_PUBLIC_CURRENT_HOST}/seo/favicon.svg"
elif LETTA_AGENTS_ENDPOINT:
# API and SDK usage should call core server directly
redirect_uri = f"{LETTA_AGENTS_ENDPOINT}/v1/tools/mcp/oauth/callback/{session_id}"
else:
raise HTTPException(status_code=400, detail="No redirect URI found")
# Create OAuth provider for the instance of the stream connection
# Note: Using the correct API path for the callback
# do not edit this this is the correct url
redirect_uri = f"http://localhost:8283/v1/tools/mcp/oauth/callback/{session_id}"
oauth_provider = await create_oauth_provider(session_id, request.server_url, redirect_uri, server.mcp_manager, actor)
oauth_provider = await create_oauth_provider(
session_id, request.server_url, redirect_uri, server.mcp_manager, actor, logo_uri=logo_uri
)
# Get authorization URL by triggering OAuth flow
temp_client = None
@@ -835,7 +851,7 @@ async def connect_mcp_server(
# detailed_error = drill_down_exception(cleanup_error)
logger.warning(f"Aysnc cleanup confict during temp MCP client cleanup: {cleanup_error}")
return StreamingResponseWithStatusCode(oauth_stream_generator(request), media_type="text/event-stream")
return StreamingResponseWithStatusCode(oauth_stream_generator(request, http_request), media_type="text/event-stream")
class CodeInput(BaseModel):
@@ -860,7 +876,7 @@ async def generate_json_schema(
# TODO: @jnjpng need to route this through cloud API for production
@router.get("/mcp/oauth/callback/{session_id}", operation_id="mcp_oauth_callback", response_class=HTMLResponse)
@router.get("/mcp/oauth/callback/{session_id}", operation_id="mcp_oauth_callback")
async def mcp_oauth_callback(
session_id: str,
code: Optional[str] = Query(None, description="OAuth authorization code"),
@@ -873,7 +889,6 @@ async def mcp_oauth_callback(
"""
try:
oauth_session = MCPOAuthSession(session_id)
if error:
error_msg = f"OAuth error: {error}"
if error_description:
@@ -891,7 +906,7 @@ async def mcp_oauth_callback(
await oauth_session.update_session_status(OAuthSessionStatus.ERROR)
return {"status": "error", "message": "Invalid state parameter"}
return HTMLResponse(content=get_oauth_success_html(), status_code=200)
return {"status": "success", "message": "Authorization successful", "server_url": success.server_url}
except Exception as e:
logger.error(f"OAuth callback error: {e}")

View File

@@ -132,23 +132,18 @@ class MCPOAuthSession:
except Exception:
pass
async def store_authorization_code(self, code: str, state: str) -> bool:
async def store_authorization_code(self, code: str, state: str) -> Optional[MCPOAuth]:
"""Store the authorization code from OAuth callback."""
async with db_registry.async_session() as session:
try:
oauth_record = await MCPOAuth.read_async(db_session=session, identifier=self.session_id, actor=None)
# if oauth_record.state != state:
# return False
oauth_record.authorization_code = code
oauth_record.state = state
oauth_record.status = OAuthSessionStatus.AUTHORIZED
oauth_record.updated_at = datetime.now()
await oauth_record.update_async(db_session=session, actor=None)
return True
return await oauth_record.update_async(db_session=session, actor=None)
except Exception:
return False
return None
async def get_authorization_url(self) -> Optional[str]:
"""Get the authorization URL for this session."""
@@ -177,16 +172,18 @@ async def create_oauth_provider(
redirect_uri: str,
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."""
client_metadata_dict = {
"client_name": "Letta MCP Client",
"client_name": "Letta",
"redirect_uris": [redirect_uri],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_post",
"logo_uri": logo_uri,
}
# Use manager-based storage
@@ -290,144 +287,3 @@ def drill_down_exception(exception, depth=0, max_depth=5):
error_info = "".join(error_details)
return error_info
def get_oauth_success_html() -> str:
"""Generate HTML for successful OAuth authorization."""
return """
<!DOCTYPE html>
<html>
<head>
<title>Authorization Successful - Letta</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background-color: #f5f5f5;
background-image: url("data:image/svg+xml,%3Csvg width='1440' height='860' viewBox='0 0 1440 860' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_14823_146864)'%3E%3Cpath d='M720.001 1003.14C1080.62 1003.14 1372.96 824.028 1372.96 603.083C1372.96 382.138 1080.62 203.026 720.001 203.026C359.384 203.026 67.046 382.138 67.046 603.083C67.046 824.028 359.384 1003.14 720.001 1003.14Z' stroke='%23E1E2E3' stroke-width='1.5' stroke-miterlimit='10'/%3E%3Cpath d='M719.999 978.04C910.334 978.04 1064.63 883.505 1064.63 766.891C1064.63 650.276 910.334 555.741 719.999 555.741C529.665 555.741 375.368 650.276 375.368 766.891C375.368 883.505 529.665 978.04 719.999 978.04Z' stroke='%23E1E2E3' stroke-width='1.5' stroke-miterlimit='10'/%3E%3Cpath d='M720 1020.95C1262.17 1020.95 1701.68 756.371 1701.68 430C1701.68 103.629 1262.17 -160.946 720 -160.946C177.834 -160.946 -261.678 103.629 -261.678 430C-261.678 756.371 177.834 1020.95 720 1020.95Z' stroke='%23E1E2E3' stroke-width='1.5' stroke-miterlimit='10'/%3E%3Cpath d='M719.999 323.658C910.334 323.658 1064.63 223.814 1064.63 100.649C1064.63 -22.5157 910.334 -122.36 719.999 -122.36C529.665 -122.36 375.368 -22.5157 375.368 100.649C375.368 223.814 529.665 323.658 719.999 323.658Z' stroke='%23E1E2E3' stroke-width='1.5' stroke-miterlimit='10'/%3E%3Cpath d='M720.001 706.676C1080.62 706.676 1372.96 517.507 1372.96 284.155C1372.96 50.8029 1080.62 -138.366 720.001 -138.366C359.384 -138.366 67.046 50.8029 67.046 284.155C67.046 517.507 359.384 706.676 720.001 706.676Z' stroke='%23E1E2E3' stroke-width='1.5' stroke-miterlimit='10'/%3E%3Cpath d='M719.999 874.604C1180.69 874.604 1554.15 645.789 1554.15 363.531C1554.15 81.2725 1180.69 -147.543 719.999 -147.543C259.311 -147.543 -114.15 81.2725 -114.15 363.531C-114.15 645.789 259.311 874.604 719.999 874.604Z' stroke='%23E1E2E3' stroke-width='1.5' stroke-miterlimit='10'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_14823_146864'%3E%3Crect width='1440' height='860' fill='white'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E");
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.card {
text-align: center;
padding: 48px;
background: white;
border-radius: 8px;
border: 1px solid #E1E2E3;
max-width: 400px;
width: 90%;
position: relative;
z-index: 1;
}
.logo {
width: 48px;
height: 48px;
margin: 0 auto 24px;
display: block;
}
.logo svg {
width: 100%;
height: 100%;
}
h1 {
font-size: 20px;
font-weight: 600;
color: #101010;
margin-bottom: 12px;
line-height: 1.2;
}
.subtitle {
color: #666;
font-size: 12px;
margin-top: 10px;
margin-bottom: 24px;
line-height: 1.5;
}
.close-info {
font-size: 12px;
color: #999;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid #E1E2E3;
border-top: 2px solid #333;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Dark mode styles */
@media (prefers-color-scheme: dark) {
body {
background-color: #101010;
background-image: url("data:image/svg+xml,%3Csvg width='1440' height='860' viewBox='0 0 1440 860' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_14833_149362)'%3E%3Cpath d='M720.001 1003.14C1080.62 1003.14 1372.96 824.028 1372.96 603.083C1372.96 382.138 1080.62 203.026 720.001 203.026C359.384 203.026 67.046 382.138 67.046 603.083C67.046 824.028 359.384 1003.14 720.001 1003.14Z' stroke='%2346484A' stroke-width='1.5' stroke-miterlimit='10'/%3E%3Cpath d='M719.999 978.04C910.334 978.04 1064.63 883.505 1064.63 766.891C1064.63 650.276 910.334 555.741 719.999 555.741C529.665 555.741 375.368 650.276 375.368 766.891C375.368 883.505 529.665 978.04 719.999 978.04Z' stroke='%2346484A' stroke-width='1.5' stroke-miterlimit='10'/%3E%3Cpath d='M720 1020.95C1262.17 1020.95 1701.68 756.371 1701.68 430C1701.68 103.629 1262.17 -160.946 720 -160.946C177.834 -160.946 -261.678 103.629 -261.678 430C-261.678 756.371 177.834 1020.95 720 1020.95Z' stroke='%2346484A' stroke-width='1.5' stroke-miterlimit='10'/%3E%3Cpath d='M719.999 323.658C910.334 323.658 1064.63 223.814 1064.63 100.649C1064.63 -22.5157 910.334 -122.36 719.999 -122.36C529.665 -122.36 375.368 -22.5157 375.368 100.649C375.368 223.814 529.665 323.658 719.999 323.658Z' stroke='%2346484A' stroke-width='1.5' stroke-miterlimit='10'/%3E%3Cpath d='M720.001 706.676C1080.62 706.676 1372.96 517.507 1372.96 284.155C1372.96 50.8029 1080.62 -138.366 720.001 -138.366C359.384 -138.366 67.046 50.8029 67.046 284.155C67.046 517.507 359.384 706.676 720.001 706.676Z' stroke='%2346484A' stroke-width='1.5' stroke-miterlimit='10'/%3E%3Cpath d='M719.999 874.604C1180.69 874.604 1554.15 645.789 1554.15 363.531C1554.15 81.2725 1180.69 -147.543 719.999 -147.543C259.311 -147.543 -114.15 81.2725 -114.15 363.531C-114.15 645.789 259.311 874.604 719.999 874.604Z' stroke='%2346484A' stroke-width='1.5' stroke-miterlimit='10'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_14833_149362'%3E%3Crect width='1440' height='860' fill='white'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E");
}
.card {
background-color: #141414;
border-color: #202020;
}
h1 {
color: #E1E2E3;
}
.subtitle {
color: #999;
}
.logo svg path {
fill: #E1E2E3;
}
.spinner {
border-color: #46484A;
border-top-color: #E1E2E3;
}
}
</style>
</head>
<body>
<div class="card">
<div class="logo">
<svg width="48" height="48" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.7134 7.30028H7.28759V10.7002H10.7134V7.30028Z" fill="#333"/>
<path d="M14.1391 2.81618V0.5H3.86131V2.81618C3.86131 3.41495 3.37266 3.89991 2.76935 3.89991H0.435547V14.1001H2.76935C3.37266 14.1001 3.86131 14.5851 3.86131 15.1838V17.5H14.1391V15.1838C14.1391 14.5851 14.6277 14.1001 15.231 14.1001H17.5648V3.89991H15.231C14.6277 3.89991 14.1391 3.41495 14.1391 2.81618ZM14.1391 13.0159C14.1391 13.6147 13.6504 14.0996 13.0471 14.0996H4.95375C4.35043 14.0996 3.86179 13.6147 3.86179 13.0159V4.98363C3.86179 4.38486 4.35043 3.89991 4.95375 3.89991H13.0471C13.6504 3.89991 14.1391 4.38486 14.1391 4.98363V13.0159Z" fill="#333"/>
</svg>
</div>
<h3>Authorization Successful</h3>
<p class="subtitle">You have successfully connected your MCP server.</p>
<div class="close-info">
<span>You can now close this window.</span>
</div>
</div>
</body>
</html>
"""

View File

@@ -267,6 +267,10 @@ class Settings(BaseSettings):
# for OCR
mistral_api_key: Optional[str] = None
# OAuth redirect URLs
letta_agents_endpoint: Optional[str] = os.getenv("LETTA_AGENTS_ENDPOINT")
next_public_current_host: Optional[str] = os.getenv("NEXT_PUBLIC_CURRENT_HOST")
# LLM request timeout settings (model + embedding model)
llm_request_timeout_seconds: float = Field(default=60.0, ge=10.0, le=1800.0, description="Timeout for LLM requests in seconds")
llm_stream_timeout_seconds: float = Field(default=60.0, ge=10.0, le=1800.0, description="Timeout for LLM streaming requests in seconds")