diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 6afed97fe9..4e7745392b 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple, Dict, Set +from typing import Dict, List, Optional, Set, Tuple from starlette.datastructures import Headers from starlette.requests import Request @@ -96,9 +96,12 @@ class MCPRequestHandler: return b"{}" request.body = mock_body # type: ignore - validated_user_api_key_auth = await user_api_key_auth( - api_key=litellm_api_key, request=request - ) + if ".well-known" in str(request.url): # public routes + validated_user_api_key_auth = UserAPIKeyAuth() + else: + validated_user_api_key_auth = await user_api_key_auth( + api_key=litellm_api_key, request=request + ) return ( validated_user_api_key_auth, mcp_auth_header, @@ -359,10 +362,10 @@ class MCPRequestHandler: return [] try: - team_obj: Optional[ - LiteLLM_TeamTable - ] = await prisma_client.db.litellm_teamtable.find_unique( - where={"team_id": user_api_key_auth.team_id}, + team_obj: Optional[LiteLLM_TeamTable] = ( + await prisma_client.db.litellm_teamtable.find_unique( + where={"team_id": user_api_key_auth.team_id}, + ) ) if team_obj is None: verbose_logger.debug("team_obj is None") @@ -535,10 +538,10 @@ class MCPRequestHandler: verbose_logger.debug("prisma_client is None") return [] - team_obj: Optional[ - LiteLLM_TeamTable - ] = await prisma_client.db.litellm_teamtable.find_unique( - where={"team_id": user_api_key_auth.team_id}, + team_obj: Optional[LiteLLM_TeamTable] = ( + await prisma_client.db.litellm_teamtable.find_unique( + where={"team_id": user_api_key_auth.team_id}, + ) ) if team_obj is None: verbose_logger.debug("team_obj is None") diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py new file mode 100644 index 0000000000..d38edf5dae --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -0,0 +1,204 @@ +from typing import Optional +from urllib.parse import urlencode, urlparse, urlunparse + +import httpx +from fastapi import APIRouter, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse + +STATE_MAP = {} + +router = APIRouter( + tags=["mcp"], +) + + +@router.get("/authorize") +async def authorize( + request: Request, client_id: str, redirect_uri: str, state: str = "" +): + # Redirect to real GitHub OAuth + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + mcp_server = global_mcp_server_manager.get_mcp_server_by_name(client_id) + if mcp_server is None: + raise HTTPException(status_code=404, detail="MCP server not found") + if mcp_server.auth_type != "oauth2": + raise HTTPException(status_code=400, detail="MCP server is not OAuth2") + if mcp_server.client_id is None: + raise HTTPException(status_code=400, detail="MCP server client id is not set") + if mcp_server.authorization_url is None: + raise HTTPException( + status_code=400, detail="MCP server authorization url is not set" + ) + if mcp_server.scopes is None: + raise HTTPException(status_code=400, detail="MCP server scopes is not set") + + # Parse it to remove any existing query + parsed = urlparse(redirect_uri) + base_url = urlunparse(parsed._replace(query="")) + request_base_url = str(request.base_url).rstrip("/") + STATE_MAP[state] = base_url + params = { + "client_id": mcp_server.client_id, + "redirect_uri": f"{request_base_url}/callback", + "scope": " ".join(mcp_server.scopes), + "state": state, + } + return RedirectResponse(f"{mcp_server.authorization_url}?{urlencode(params)}") + + +@router.post("/token") +async def token_endpoint( + request: Request, + grant_type: str = Form(...), + code: str = Form(None), + redirect_uri: str = Form(None), + client_id: str = Form(...), + client_secret: str = Form(...), +): + """ + Accept the authorization code from Claude and exchange it for GitHub token. + Forward the GitHub token back to Claude in standard OAuth format. + + 1. Call the token endpoint + 2. Store the user's PAT in the db - and generate a LiteLLM virtual key + 2. Return the token + 3. Return a virtual key in this response + """ + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + mcp_server = global_mcp_server_manager.get_mcp_server_by_name(client_id) + if mcp_server is None: + raise HTTPException(status_code=404, detail="MCP server not found") + + if grant_type != "authorization_code": + raise HTTPException(status_code=400, detail="Unsupported grant_type") + + if mcp_server.token_url is None: + raise HTTPException(status_code=400, detail="MCP server token url is not set") + + proxy_base_url = str(request.base_url).rstrip("/") + + # Exchange code for real GitHub token + async with httpx.AsyncClient() as client: + resp = await client.post( + mcp_server.token_url, + headers={"Accept": "application/json"}, + data={ + "client_id": mcp_server.client_id, + "client_secret": mcp_server.client_secret, + "code": code, + "redirect_uri": f"{proxy_base_url}/callback", + }, + ) + resp.raise_for_status() + github_token = resp.json()["access_token"] + + # Return to Claude in expected OAuth 2 format + + ### return a virtual key in this response + + return JSONResponse( + {"access_token": github_token, "token_type": "Bearer", "expires_in": 3600} + ) + + +@router.get("/callback") +async def callback(code: str, state: str): + # Exchange code for token with GitHub + params = {"code": code, "state": state} + + # Forward token to Claude ephemeral endpoint + redirect_uri = STATE_MAP.pop(state, None) + + complete_returned_url = f"{redirect_uri}?{urlencode(params)}" + if redirect_uri: + async with httpx.AsyncClient() as client: + await client.post( + complete_returned_url, + json={}, + ) + # Return simple page so the browser doesn't hang + return HTMLResponse( + "
Authentication complete. You can close this window." + ) + + # fallback if redirect_uri not found + return HTMLResponse( + "Authentication incomplete. You can close this window." + ) + + +# ------------------------------ +# Optional .well-known endpoints for MCP + OAuth discovery +# ------------------------------ +@router.get("/.well-known/oauth-protected-resource/{mcp_server_name}/mcp") +@router.get("/.well-known/oauth-protected-resource") +async def oauth_protected_resource_mcp( + request: Request, mcp_server_name: Optional[str] = None +): + request_base_url = str(request.base_url).rstrip("/") + return { + "authorization_servers": [ + ( + f"{request_base_url}/{mcp_server_name}" + if mcp_server_name + else f"{request_base_url}" + ) + ], + "resource": ( + f"{request_base_url}/{mcp_server_name}/mcp" + if mcp_server_name + else f"{request_base_url}/mcp" + ), # this is what Claude will call + } + + +@router.get("/.well-known/oauth-authorization-server/{mcp_server_name}") +@router.get("/.well-known/oauth-authorization-server") +async def oauth_authorization_server_mcp( + request: Request, mcp_server_name: Optional[str] = None +): + request_base_url = str(request.base_url).rstrip("/") + return { + "issuer": request_base_url, # point to your proxy + "authorization_endpoint": f"{request_base_url}/authorize", + "token_endpoint": f"{request_base_url}/token", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["client_secret_post"], + # Claude expects a registration endpoint, even if we just fake it + "registration_endpoint": f"{request_base_url}/{mcp_server_name}/register", + } + + +# Alias for standard OpenID discovery +@router.get("/.well-known/openid-configuration") +async def openid_configuration(request: Request): + return await oauth_authorization_server_mcp(request) + + +@router.get("/.well-known/oauth-authorization-server/{mcp_server_name}/mcp") +@router.get("/.well-known/oauth-authorization-server") +async def oauth_authorization_server_root( + request: Request, mcp_server_name: Optional[str] = None +): + return await oauth_authorization_server_mcp(request, mcp_server_name) + + +@router.post("/{mcp_server_name}/register") +@router.post("/register") +async def register_client(request: Request, mcp_server_name: Optional[str] = None): + request_base_url = str(request.base_url).rstrip("/") + + # return fixed GitHub client credentials + return { + "client_id": mcp_server_name or "dummy_client", + "client_secret": "dummy", + "redirect_uris": [f"{request_base_url}/mcp/callback"], + } diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index ab5d1b10bf..254f036df0 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -199,6 +199,12 @@ class MCPServerManager: command=server_config.get("command", None) or "", args=server_config.get("args", None) or [], env=server_config.get("env", None) or {}, + # oauth specific fields + client_id=server_config.get("client_id", None), + client_secret=server_config.get("client_secret", None), + scopes=server_config.get("scopes", None), + authorization_url=server_config.get("authorization_url", None), + token_url=server_config.get("token_url", None), # TODO: utility fn the default values transport=server_config.get("transport", MCPTransport.http), auth_type=server_config.get("auth_type", None), @@ -607,20 +613,26 @@ class MCPServerManager: "arguments": arguments, "server_name": server_name_from_prefix, "user_api_key_auth": user_api_key_auth, - "user_api_key_user_id": getattr(user_api_key_auth, "user_id", None) - if user_api_key_auth - else None, - "user_api_key_team_id": getattr(user_api_key_auth, "team_id", None) - if user_api_key_auth - else None, - "user_api_key_end_user_id": getattr( - user_api_key_auth, "end_user_id", None - ) - if user_api_key_auth - else None, - "user_api_key_hash": getattr(user_api_key_auth, "api_key_hash", None) - if user_api_key_auth - else None, + "user_api_key_user_id": ( + getattr(user_api_key_auth, "user_id", None) + if user_api_key_auth + else None + ), + "user_api_key_team_id": ( + getattr(user_api_key_auth, "team_id", None) + if user_api_key_auth + else None + ), + "user_api_key_end_user_id": ( + getattr(user_api_key_auth, "end_user_id", None) + if user_api_key_auth + else None + ), + "user_api_key_hash": ( + getattr(user_api_key_auth, "api_key_hash", None) + if user_api_key_auth + else None + ), } # Create MCP request object for processing @@ -834,6 +846,16 @@ class MCPServerManager: return server return None + def get_mcp_server_by_name(self, server_name: str) -> Optional[MCPServer]: + """ + Get the MCP Server from the server name + """ + registry = self.get_registry() + for server in registry.values(): + if server.server_name == server_name: + return server + return None + def _generate_stable_server_id( self, server_name: str, @@ -1023,9 +1045,11 @@ class MCPServerManager: auth_type=_server_config.auth_type, created_at=datetime.datetime.now(), updated_at=datetime.datetime.now(), - description=_server_config.mcp_info.get("description") - if _server_config.mcp_info - else None, + description=( + _server_config.mcp_info.get("description") + if _server_config.mcp_info + else None + ), mcp_info=_server_config.mcp_info, mcp_access_groups=_server_config.access_groups or [], # Stdio-specific fields diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index 2a9174717d..332d1d4e05 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -1,12 +1,17 @@ import importlib from typing import Dict, List, Optional +from urllib.parse import urlencode, urlparse, urlunparse -from fastapi import APIRouter, Depends, Query, Request +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.params import Form +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from litellm._logging import verbose_logger from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +STATE_MAP = {} MCP_AVAILABLE: bool = True try: importlib.import_module("mcp") @@ -177,9 +182,9 @@ if MCP_AVAILABLE: return { "tools": list_tools_result, "error": "partial_failure" if error_message else None, - "message": error_message - if error_message - else "Successfully retrieved tools", + "message": ( + error_message if error_message else "Successfully retrieved tools" + ), } except Exception as e: @@ -333,3 +338,109 @@ if MCP_AVAILABLE: } return await _execute_with_mcp_client(request, _list_tools_operation) + + @router.get("/authorize") + async def authorize( + request: Request, client_id: str, redirect_uri: str, state: str = "" + ): + # Redirect to real GitHub OAuth + from mcp_server_manager import global_mcp_server_manager + + mcp_server = global_mcp_server_manager.get_mcp_server_by_id(client_id) + if mcp_server is None: + raise HTTPException(status_code=404, detail="MCP server not found") + + if mcp_server.auth_type != "oauth2": + raise HTTPException(status_code=400, detail="MCP server is not OAuth2") + + if mcp_server.client_id is None: + raise HTTPException( + status_code=400, detail="MCP server client id is not set" + ) + + if mcp_server.authorization_url is None: + raise HTTPException( + status_code=400, detail="MCP server authorization url is not set" + ) + + if mcp_server.scopes is None: + raise HTTPException(status_code=400, detail="MCP server scopes is not set") + + request_base_url = str(request.base_url).rstrip("/") + # Parse it to remove any existing query + parsed = urlparse(redirect_uri) + base_url = urlunparse(parsed._replace(query="")) + STATE_MAP[state] = base_url + params = { + "client_id": mcp_server.client_id, + "redirect_uri": f"{request_base_url}/callback", + "scope": " ".join(mcp_server.scopes), + "state": state, + } + return RedirectResponse(f"{mcp_server.authorization_url}?{urlencode(params)}") + + @router.post("/token") + async def token_endpoint( + request: Request, + grant_type=Form(...), + code=Form(None), + client_id=Form(...), + ): + """ + Accept the authorization code from Claude and exchange it for GitHub token. + Forward the GitHub token back to Claude in standard OAuth format. + """ + from mcp_server_manager import global_mcp_server_manager + + mcp_server = global_mcp_server_manager.get_mcp_server_by_id(str(client_id)) + if mcp_server is None: + raise HTTPException(status_code=404, detail="MCP server not found") + + if mcp_server.token_url is None: + raise HTTPException( + status_code=400, detail="MCP server token url is not set" + ) + + if grant_type != "authorization_code": + raise HTTPException(status_code=400, detail="Unsupported grant_type") + + request_base_url = str(request.base_url).rstrip("/") + # Exchange code for real GitHub token + async with httpx.AsyncClient() as client: + resp = await client.post( + mcp_server.token_url, + headers={"Accept": "application/json"}, + data={ + "client_id": mcp_server.client_id, + "client_secret": mcp_server.client_secret, + "code": code, + "redirect_uri": f"{request_base_url}/callback", + }, + ) + resp.raise_for_status() + return JSONResponse(resp.json()) + + @router.get("/callback") + async def callback(code: str, state: str): + # Exchange code for token with GitHub + params = {"code": code, "state": state} + + # Forward token to Claude ephemeral endpoint + redirect_uri = STATE_MAP.pop(state, None) + + complete_returned_url = f"{redirect_uri}?{urlencode(params)}" + if redirect_uri: + async with httpx.AsyncClient() as client: + await client.post( + complete_returned_url, + json={}, + ) + # Return simple page so the browser doesn't hang + return HTMLResponse( + "Authentication complete. You can close this window." + ) + + # fallback if redirect_uri not found + return HTMLResponse( + "Authentication incomplete. You can close this window." + ) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 2cf91c84dc..f02b079196 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -519,16 +519,16 @@ if MCP_AVAILABLE: "litellm_logging_obj", None ) if litellm_logging_obj: - litellm_logging_obj.model_call_details[ - "mcp_tool_call_metadata" - ] = standard_logging_mcp_tool_call + litellm_logging_obj.model_call_details["mcp_tool_call_metadata"] = ( + standard_logging_mcp_tool_call + ) litellm_logging_obj.model = f"MCP: {name}" # Try managed server tool first (pass the full prefixed name) # Primary and recommended way to use MCP servers ######################################################### - mcp_server: Optional[ - MCPServer - ] = global_mcp_server_manager._get_mcp_server_from_tool_name(name) + mcp_server: Optional[MCPServer] = ( + global_mcp_server_manager._get_mcp_server_from_tool_name(name) + ) if mcp_server: standard_logging_mcp_tool_call["mcp_server_cost_info"] = ( mcp_server.mcp_info or {} @@ -638,26 +638,32 @@ if MCP_AVAILABLE: mcp_path_match = re.match(r"^/mcp/([^?#]+)(?:\?.*)?(?:#.*)?$", path) if mcp_path_match: servers_and_path = mcp_path_match.group(1) - + if servers_and_path: # Check if it contains commas (comma-separated servers) - if ',' in servers_and_path: + if "," in servers_and_path: # For comma-separated, look for a path at the end # Common patterns: /tools, /chat/completions, etc. - path_match = re.search(r'/([^/,]+(?:/[^/,]+)*)$', servers_and_path) + path_match = re.search(r"/([^/,]+(?:/[^/,]+)*)$", servers_and_path) if path_match: # Path found at the end, remove it from servers - path_part = '/' + path_match.group(1) - servers_part = servers_and_path[:-len(path_part)] - mcp_servers_from_path = [s.strip() for s in servers_part.split(',') if s.strip()] + path_part = "/" + path_match.group(1) + servers_part = servers_and_path[: -len(path_part)] + mcp_servers_from_path = [ + s.strip() for s in servers_part.split(",") if s.strip() + ] else: # No path, just comma-separated servers - mcp_servers_from_path = [s.strip() for s in servers_and_path.split(',') if s.strip()] + mcp_servers_from_path = [ + s.strip() for s in servers_and_path.split(",") if s.strip() + ] else: # Single server case - use regex approach for server/path separation # This handles cases like "custom_solutions/user_123/chat/completions" # where we want to extract "custom_solutions/user_123" as the server name - single_server_match = re.match(r"^([^/]+(?:/[^/]+)?)(?:/.*)?$", servers_and_path) + single_server_match = re.match( + r"^([^/]+(?:/[^/]+)?)(?:/.*)?$", servers_and_path + ) if single_server_match: server_name = single_server_match.group(1) mcp_servers_from_path = [server_name] @@ -809,6 +815,8 @@ if MCP_AVAILABLE: # Mount the MCP handlers app.mount("/", handle_streamable_http_mcp) + app.mount("/mcp", handle_streamable_http_mcp) + app.mount("/{mcp_server_name}/mcp", handle_streamable_http_mcp) app.mount("/sse", handle_sse_mcp) app.add_middleware(AuthContextMiddleware) @@ -839,14 +847,12 @@ if MCP_AVAILABLE: ) auth_context_var.set(auth_user) - def get_auth_context() -> ( - Tuple[ - Optional[UserAPIKeyAuth], - Optional[str], - Optional[List[str]], - Optional[Dict[str, str]], - ] - ): + def get_auth_context() -> Tuple[ + Optional[UserAPIKeyAuth], + Optional[str], + Optional[List[str]], + Optional[Dict[str, str]], + ]: """ Get the UserAPIKeyAuth from the auth context variable. diff --git a/litellm/proxy/_experimental/out/model_hub_table.html b/litellm/proxy/_experimental/out/model_hub_table/index.html similarity index 100% rename from litellm/proxy/_experimental/out/model_hub_table.html rename to litellm/proxy/_experimental/out/model_hub_table/index.html diff --git a/litellm/proxy/_experimental/out/onboarding.html b/litellm/proxy/_experimental/out/onboarding.html deleted file mode 100644 index 1dc5fdb1f8..0000000000 --- a/litellm/proxy/_experimental/out/onboarding.html +++ /dev/null @@ -1 +0,0 @@ -