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 @@ -LiteLLM Dashboard \ No newline at end of file diff --git a/litellm/proxy/_new_secret_config.yaml b/litellm/proxy/_new_secret_config.yaml index 8ffe5e8aef..c50d391e05 100644 --- a/litellm/proxy/_new_secret_config.yaml +++ b/litellm/proxy/_new_secret_config.yaml @@ -15,3 +15,16 @@ model_list: model: hosted_vllm/whisper-v3 api_base: "https://webhook.site/2f385e05-00aa-402b-86d1-efc9261471a5" api_key: dummy + +mcp_servers: + github_mcp: + url: "https://api.githubcopilot.com/mcp" + auth_type: oauth2 + authorization_url: https://github.com/login/oauth/authorize + token_url: https://github.com/login/oauth/access_token + client_id: os.environ/GITHUB_OAUTH_CLIENT_ID + client_secret: os.environ/GITHUB_OAUTH_CLIENT_SECRET + scopes: ["public_repo", "user:email"] + # allowed_tools: ["list_tools"] + # disallowed_tools: ["repo_delete"] + diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 6dea634a80..9edfe677ca 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -390,19 +390,18 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 pass_through_endpoints: Optional[List[dict]] = general_settings.get( "pass_through_endpoints", None ) - passed_in_key: Optional[str] = None ## CHECK IF X-LITELM-API-KEY IS PASSED IN - supercedes Authorization header - api_key, passed_in_key = get_api_key( - custom_litellm_key_header=custom_litellm_key_header, - api_key=api_key, - azure_api_key_header=azure_api_key_header, - anthropic_api_key_header=anthropic_api_key_header, - google_ai_studio_api_key_header=google_ai_studio_api_key_header, - azure_apim_header=azure_apim_header, - pass_through_endpoints=pass_through_endpoints, - route=route, - request=request, - ) + # api_key, passed_in_key = get_api_key( + # custom_litellm_key_header=custom_litellm_key_header, + # api_key=api_key, + # azure_api_key_header=azure_api_key_header, + # anthropic_api_key_header=anthropic_api_key_header, + # google_ai_studio_api_key_header=google_ai_studio_api_key_header, + # azure_apim_header=azure_apim_header, + # pass_through_endpoints=pass_through_endpoints, + # route=route, + # request=request, + # ) # if user wants to pass LiteLLM_Master_Key as a custom header, example pass litellm keys as X-LiteLLM-Key: Bearer sk-1234 custom_litellm_key_header_name = general_settings.get("litellm_key_header_name") if custom_litellm_key_header_name is not None: @@ -502,7 +501,9 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 end_user_object = result["end_user_object"] org_id = result["org_id"] token = result["token"] - team_membership: Optional[LiteLLM_TeamMembership] = result.get("team_membership", None) + team_membership: Optional[LiteLLM_TeamMembership] = result.get( + "team_membership", None + ) global_proxy_spend = await get_global_proxy_spend( litellm_proxy_admin_name=litellm_proxy_admin_name, @@ -537,10 +538,22 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 org_id=org_id, parent_otel_span=parent_otel_span, end_user_id=end_user_id, - user_tpm_limit=user_object.tpm_limit if user_object is not None else None, - user_rpm_limit=user_object.rpm_limit if user_object is not None else None, - team_member_rpm_limit=team_membership.safe_get_team_member_rpm_limit() if team_membership is not None else None, - team_member_tpm_limit=team_membership.safe_get_team_member_tpm_limit() if team_membership is not None else None, + user_tpm_limit=( + user_object.tpm_limit if user_object is not None else None + ), + user_rpm_limit=( + user_object.rpm_limit if user_object is not None else None + ), + team_member_rpm_limit=( + team_membership.safe_get_team_member_rpm_limit() + if team_membership is not None + else None + ), + team_member_tpm_limit=( + team_membership.safe_get_team_member_tpm_limit() + if team_membership is not None + else None + ), ) # run through common checks _ = await common_checks( diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 472aeb140c..628af159ed 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -35,13 +35,13 @@ from litellm.constants import ( LITELLM_SETTINGS_SAFE_DB_OVERRIDES, ) from litellm.litellm_core_utils.safe_json_dumps import safe_dumps -from litellm.utils import load_credentials_from_list from litellm.types.utils import ( ModelResponse, ModelResponseStream, TextCompletionResponse, TokenCountResponse, ) +from litellm.utils import load_credentials_from_list if TYPE_CHECKING: from opentelemetry.trace import Span as _Span @@ -151,6 +151,9 @@ from litellm.litellm_core_utils.credential_accessor import CredentialAccessor from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + router as mcp_discoverable_endpoints_router, +) from litellm.proxy._experimental.mcp_server.rest_endpoints import ( router as mcp_rest_endpoints_router, ) @@ -249,9 +252,7 @@ from litellm.proxy.management_endpoints.customer_endpoints import ( from litellm.proxy.management_endpoints.internal_user_endpoints import ( router as internal_user_router, ) -from litellm.proxy.management_endpoints.internal_user_endpoints import ( - user_update, -) +from litellm.proxy.management_endpoints.internal_user_endpoints import user_update from litellm.proxy.management_endpoints.key_management_endpoints import ( delete_verification_tokens, duration_in_seconds, @@ -298,9 +299,7 @@ from litellm.proxy.middleware.prometheus_auth_middleware import PrometheusAuthMi from litellm.proxy.openai_files_endpoints.files_endpoints import ( router as openai_files_router, ) -from litellm.proxy.openai_files_endpoints.files_endpoints import ( - set_files_config, -) +from litellm.proxy.openai_files_endpoints.files_endpoints import set_files_config from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( passthrough_endpoint_router, ) @@ -9470,5 +9469,75 @@ app.include_router(ui_discovery_endpoints_router) ######################################################## # MCP Server ######################################################## + + +# Dynamic MCP server routes - handle /{mcp_server_name}/mcp +@app.api_route( + "/{mcp_server_name}/mcp", + methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"], +) +async def dynamic_mcp_route(mcp_server_name: str, request: Request): + """Handle dynamic MCP server routes like /github_mcp/mcp""" + try: + # Validate that the MCP server exists + 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(mcp_server_name) + if mcp_server is None: + raise HTTPException( + status_code=404, detail=f"MCP server '{mcp_server_name}' not found" + ) + + # Create a new scope with the correct path format that the MCP handler expects + # Transform /{mcp_server_name}/mcp to /mcp/{mcp_server_name} + scope = dict(request.scope) + scope["path"] = f"/mcp/{mcp_server_name}" + + # Import the MCP handler + from litellm.proxy._experimental.mcp_server.server import ( + handle_streamable_http_mcp, + ) + + # Create a custom send function to capture the response + response_started = False + response_body = b"" + response_status = 200 + response_headers = [] + + async def custom_send(message): + nonlocal response_started, response_body, response_status, response_headers + if message["type"] == "http.response.start": + response_started = True + response_status = message["status"] + response_headers = message.get("headers", []) + elif message["type"] == "http.response.body": + response_body += message.get("body", b"") + + # Call the existing MCP handler + await handle_streamable_http_mcp( + scope, receive=request.receive, send=custom_send + ) + + # Return the response + from starlette.responses import Response + + headers_dict = {k.decode(): v.decode() for k, v in response_headers} + return Response( + content=response_body, + status_code=response_status, + headers=headers_dict, + media_type=headers_dict.get("content-type", "application/json"), + ) + + except Exception as e: + verbose_proxy_logger.error( + f"Error handling dynamic MCP route for {mcp_server_name}: {str(e)}" + ) + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + app.mount(path=BASE_MCP_ROUTE, app=mcp_app) app.include_router(mcp_rest_endpoints_router) +app.include_router(mcp_discoverable_endpoints_router) diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py index 4cd32915ce..bbb9e7d7b4 100644 --- a/litellm/types/mcp.py +++ b/litellm/types/mcp.py @@ -15,6 +15,7 @@ else: MCPImageContent = Any MCPTextContent = Any + class MCPTransport(str, enum.Enum): sse = "sse" http = "http" @@ -26,17 +27,21 @@ class MCPSpecVersion(str, enum.Enum): mar_2025 = "2025-03-26" jun_2025 = "2025-06-18" + class MCPAuth(str, enum.Enum): none = "none" api_key = "api_key" bearer_token = "bearer_token" basic = "basic" authorization = "authorization" + oauth2 = "oauth2" # MCP Literals MCPTransportType = Literal[MCPTransport.sse, MCPTransport.http, MCPTransport.stdio] -MCPSpecVersionType = Literal[MCPSpecVersion.nov_2024, MCPSpecVersion.mar_2025, MCPSpecVersion.jun_2025] +MCPSpecVersionType = Literal[ + MCPSpecVersion.nov_2024, MCPSpecVersion.mar_2025, MCPSpecVersion.jun_2025 +] MCPAuthType = Optional[ Literal[ MCPAuth.none, @@ -44,11 +49,11 @@ MCPAuthType = Optional[ MCPAuth.bearer_token, MCPAuth.basic, MCPAuth.authorization, + MCPAuth.oauth2, ] ] - class MCPServerCostInfo(TypedDict, total=False): default_cost_per_query: Optional[float] """ @@ -82,6 +87,7 @@ class MCPPreCallRequestObject(BaseModel): """ Pydantic object used for MCP pre_call_hook request validation and modification """ + tool_name: str arguments: Dict[str, Any] server_name: Optional[str] = None @@ -93,6 +99,7 @@ class MCPPreCallResponseObject(BaseModel): """ Pydantic object used for MCP pre_call_hook response """ + should_proceed: bool = True modified_arguments: Optional[Dict[str, Any]] = None error_message: Optional[str] = None @@ -103,6 +110,7 @@ class MCPDuringCallRequestObject(BaseModel): """ Pydantic object used for MCP during_call_hook request """ + tool_name: str arguments: Dict[str, Any] server_name: Optional[str] = None @@ -114,6 +122,7 @@ class MCPDuringCallResponseObject(BaseModel): """ Pydantic object used for MCP during_call_hook response """ + should_continue: bool = True error_message: Optional[str] = None hidden_params: HiddenParams = HiddenParams() @@ -123,5 +132,8 @@ class MCPPostCallResponseObject(BaseModel): """ Pydantic object used for MCP post_call_hook response """ - mcp_tool_call_response: List[Union[MCPTextContent, MCPImageContent, MCPEmbeddedResource]] - hidden_params: HiddenParams \ No newline at end of file + + mcp_tool_call_response: List[ + Union[MCPTextContent, MCPImageContent, MCPEmbeddedResource] + ] + hidden_params: HiddenParams diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index bd09ef9c19..0dec1b23c6 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -6,7 +6,6 @@ from typing_extensions import TypedDict from litellm.proxy._types import MCPAuthType, MCPTransportType from litellm.types.mcp import MCPServerCostInfo - # MCPInfo now allows arbitrary additional fields for custom metadata MCPInfo = Dict[str, Any] @@ -21,6 +20,12 @@ class MCPServer(BaseModel): auth_type: Optional[MCPAuthType] = None authentication_token: Optional[str] = None mcp_info: Optional[MCPInfo] = None + # OAuth-specific fields + client_id: Optional[str] = None + client_secret: Optional[str] = None + scopes: Optional[List[str]] = None + authorization_url: Optional[str] = None + token_url: Optional[str] = None # Stdio-specific fields command: Optional[str] = None args: Optional[List[str]] = None