mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-18 02:17:35 +00:00
feat: initial commit for v2 oauth flow
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
"<html><body>Authentication complete. You can close this window.</body></html>"
|
||||
)
|
||||
|
||||
# fallback if redirect_uri not found
|
||||
return HTMLResponse(
|
||||
"<html><body>Authentication incomplete. You can close this window.</body></html>"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# 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"],
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
"<html><body>Authentication complete. You can close this window.</body></html>"
|
||||
)
|
||||
|
||||
# fallback if redirect_uri not found
|
||||
return HTMLResponse(
|
||||
"<html><body>Authentication incomplete. You can close this window.</body></html>"
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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"]
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
+16
-4
@@ -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
|
||||
|
||||
mcp_tool_call_response: List[
|
||||
Union[MCPTextContent, MCPImageContent, MCPEmbeddedResource]
|
||||
]
|
||||
hidden_params: HiddenParams
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user