fix(mcp): MCP OAuth callback 404, scope pollution, and Docker pre-build

- Add backend route /ui/mcp/oauth/callback to serve OAuth callback HTML
  when static files are unavailable (older images, pip install, read-only fs)
- Fix OAuth scope bug: do not auto-append discovered scopes when user
  leaves scope field empty (was causing GitLab 'invalid scope' errors)
- Fix Dockerfile.non_root: restructure nested HTML files recursively
  (mcp/oauth/callback.html was missed by top-level-only glob)
- Add tests for scope handling and callback endpoint

Made-with: Cursor
This commit is contained in:
Sameer Kankute 2026-03-09 15:51:20 +05:30
parent a8cf646850
commit 6f49645eb5
4 changed files with 236 additions and 37 deletions

View file

@ -53,12 +53,10 @@ RUN mkdir -p /var/lib/litellm/ui && \
mkdir -p /var/lib/litellm/assets && \
cp /app/litellm/proxy/logo.jpg /var/lib/litellm/assets/logo.jpg && \
( cd /var/lib/litellm/ui && \
for html_file in *.html; do \
if [ "$html_file" != "index.html" ] && [ -f "$html_file" ]; then \
folder_name="${html_file%.html}" && \
mkdir -p "$folder_name" && \
mv "$html_file" "$folder_name/index.html"; \
fi; \
find . -name "*.html" ! -name "index.html" | while read html_file; do \
folder_name="${html_file%.html}" && \
mkdir -p "$folder_name" && \
mv "$html_file" "$folder_name/index.html"; \
done && \
touch .litellm_ui_ready ) && \
cd /app/ui/litellm-dashboard && rm -rf ./out

View file

@ -420,6 +420,113 @@ async def callback(code: str, state: str):
)
def _build_mcp_oauth_callback_html(code: Optional[str], state: Optional[str]) -> str:
"""
Build the inline HTML page for the MCP OAuth callback.
This page mirrors the logic of the Next.js page at
ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx:
it stores the OAuth result in browser storage and redirects the
user back to the LiteLLM UI.
Served as a backend route so it works even when the static-files
mount for /ui is unavailable (e.g. read-only container filesystems).
"""
code_json = json.dumps(code)
state_json = json.dumps(state)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LiteLLM MCP OAuth</title>
<style>
body {{
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
font-family: system-ui, -apple-system, sans-serif;
}}
.card {{
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
padding: 2.5rem 2rem;
max-width: 480px;
width: 100%;
text-align: center;
}}
h1 {{ font-size: 1.25rem; color: #0f172a; margin-bottom: 0.75rem; }}
p {{ font-size: 0.9rem; color: #475569; line-height: 1.6; margin: 0.25rem 0; }}
.note {{ font-size: 0.75rem; color: #94a3b8; margin-top: 0.5rem; }}
</style>
</head>
<body>
<div class="card">
<h1>LiteLLM MCP OAuth</h1>
<p>Authorization complete. You may close this window and return to the LiteLLM dashboard.</p>
<p class="note">If the window does not close automatically, everything is still saved you can close it manually.</p>
</div>
<script>
(function () {{
var RESULT_KEY = "litellm-mcp-oauth-result";
var RETURN_URL_KEY = "litellm-mcp-oauth-return-url";
var payload = {{
type: "litellm-mcp-oauth",
code: {code_json},
state: {state_json}
}};
try {{
var payloadStr = JSON.stringify(payload);
window.sessionStorage.setItem(RESULT_KEY, payloadStr);
window.localStorage.setItem(RESULT_KEY, payloadStr);
}} catch (e) {{}}
var returnUrl = null;
try {{
returnUrl = window.sessionStorage.getItem(RETURN_URL_KEY) ||
window.localStorage.getItem(RETURN_URL_KEY);
}} catch (e) {{}}
if (!returnUrl) {{
var path = window.location.pathname || "";
var uiIndex = path.indexOf("/ui");
returnUrl = uiIndex >= 0 ? path.slice(0, uiIndex + 3) : "/";
}}
window.location.replace(returnUrl);
}})();
</script>
</body>
</html>"""
@router.get("/ui/mcp/oauth/callback", include_in_schema=False)
async def mcp_oauth_ui_callback(
code: Optional[str] = None,
state: Optional[str] = None,
) -> HTMLResponse:
"""
OAuth callback landing page for the MCP UI flow.
After the external OAuth provider redirects the browser to /callback,
LiteLLM decodes the state and performs a second redirect to this page
(the original redirect_uri supplied by the UI).
This backend route serves an inline HTML page that stores the OAuth
result in browser storage and redirects the user back to the LiteLLM
dashboard. It acts as a reliable fallback for environments where the
static-file mount for /ui is unavailable (read-only container
filesystems, Kubernetes deployments, etc.).
"""
return HTMLResponse(content=_build_mcp_oauth_callback_html(code, state))
# ------------------------------
# Optional .well-known endpoints for MCP + OAuth discovery
# ------------------------------

View file

@ -353,6 +353,9 @@ from litellm.proxy.management_endpoints.common_utils import (
from litellm.proxy.management_endpoints.compliance_endpoints import (
router as compliance_router,
)
from litellm.proxy.management_endpoints.config_override_endpoints import (
router as config_override_router,
)
from litellm.proxy.management_endpoints.cost_tracking_settings import (
router as cost_tracking_settings_router,
)
@ -368,6 +371,9 @@ from litellm.proxy.management_endpoints.internal_user_endpoints import (
from litellm.proxy.management_endpoints.internal_user_endpoints import (
user_update,
)
from litellm.proxy.management_endpoints.jwt_key_mapping_endpoints import (
router as jwt_key_mapping_router,
)
from litellm.proxy.management_endpoints.key_management_endpoints import (
delete_verification_tokens,
duration_in_seconds,
@ -584,6 +590,7 @@ from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.openapi.utils import get_openapi
from fastapi.responses import (
FileResponse,
HTMLResponse,
JSONResponse,
ORJSONResponse,
RedirectResponse,
@ -1331,6 +1338,17 @@ try:
)
# print(f"mounted _next at {server_root_path}/ui/_next")
# Register the MCP OAuth callback route BEFORE mounting the /ui StaticFiles
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
_build_mcp_oauth_callback_html,
)
@app.get("/ui/mcp/oauth/callback", include_in_schema=False)
async def _mcp_oauth_ui_callback(
code: Optional[str] = None, state: Optional[str] = None
) -> HTMLResponse:
return HTMLResponse(content=_build_mcp_oauth_callback_html(code, state))
app.mount("/ui", StaticFiles(directory=ui_path, html=True), name="ui")
def _restructure_ui_html_files(ui_root: str) -> None:

View file

@ -1,22 +1,24 @@
"""Tests for MCP OAuth discoverable endpoints"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.mark.asyncio
async def test_authorize_endpoint_includes_response_type():
"""Test that authorize endpoint includes response_type=code parameter (fixes #15684)"""
try:
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
authorize,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
from litellm.proxy._types import MCPTransport
from fastapi import Request
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
@ -73,16 +75,17 @@ async def test_authorize_endpoint_includes_response_type():
async def test_authorize_endpoint_forwards_pkce_parameters():
"""Test that authorize endpoint forwards PKCE parameters (code_challenge and code_challenge_method)"""
try:
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
authorize,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
from litellm.proxy._types import MCPTransport
from fastapi import Request
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
@ -143,17 +146,18 @@ async def test_authorize_endpoint_forwards_pkce_parameters():
async def test_token_endpoint_forwards_code_verifier():
"""Test that token endpoint forwards code_verifier for PKCE flow"""
try:
import httpx
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
token_endpoint,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
from litellm.proxy._types import MCPTransport
from fastapi import Request
import httpx
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
@ -240,10 +244,11 @@ async def test_token_endpoint_forwards_code_verifier():
@pytest.mark.asyncio
async def test_register_client_without_mcp_server_name_returns_dummy():
try:
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
register_client,
)
from fastapi import Request
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
@ -266,16 +271,17 @@ async def test_register_client_without_mcp_server_name_returns_dummy():
@pytest.mark.asyncio
async def test_register_client_returns_existing_server_credentials():
try:
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
register_client,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
from litellm.proxy._types import MCPTransport
from fastapi import Request
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
@ -319,16 +325,17 @@ async def test_register_client_returns_existing_server_credentials():
@pytest.mark.asyncio
async def test_register_client_remote_registration_success():
try:
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
register_client,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
from litellm.proxy._types import MCPTransport
from fastapi import Request
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
@ -409,16 +416,17 @@ async def test_register_client_remote_registration_success():
async def test_authorize_endpoint_respects_x_forwarded_proto():
"""Test that authorize endpoint uses X-Forwarded-Proto header to construct correct redirect_uri"""
try:
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
authorize,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
from litellm.proxy._types import MCPTransport
from fastapi import Request
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
@ -475,16 +483,17 @@ async def test_authorize_endpoint_respects_x_forwarded_proto():
async def test_token_endpoint_respects_x_forwarded_proto():
"""Test that token endpoint uses X-Forwarded-Proto header for redirect_uri"""
try:
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
token_endpoint,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
from litellm.proxy._types import MCPTransport
from fastapi import Request
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
@ -553,16 +562,17 @@ async def test_token_endpoint_respects_x_forwarded_proto():
async def test_oauth_protected_resource_standard_pattern():
"""Test that oauth_protected_resource_mcp_standard returns standard MCP URL pattern (/mcp/{server_name})"""
try:
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
oauth_protected_resource_mcp_standard,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
from litellm.proxy._types import MCPTransport
from fastapi import Request
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
@ -606,16 +616,17 @@ async def test_oauth_protected_resource_standard_pattern():
async def test_oauth_protected_resource_legacy_pattern():
"""Test that oauth_protected_resource_mcp returns legacy URL pattern (/{server_name}/mcp)"""
try:
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
oauth_protected_resource_mcp,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
from litellm.proxy._types import MCPTransport
from fastapi import Request
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
@ -659,16 +670,17 @@ async def test_oauth_protected_resource_legacy_pattern():
async def test_oauth_protected_resource_respects_x_forwarded_proto():
"""Test that oauth_protected_resource_mcp uses X-Forwarded-Proto for URLs"""
try:
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
oauth_protected_resource_mcp,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
from litellm.proxy._types import MCPTransport
from fastapi import Request
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Clear registry
@ -712,16 +724,17 @@ async def test_oauth_protected_resource_respects_x_forwarded_proto():
async def test_oauth_authorization_server_respects_x_forwarded_proto():
"""Test that oauth_authorization_server_mcp uses X-Forwarded-Proto for URLs"""
try:
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
oauth_authorization_server_mcp,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
from litellm.proxy._types import MCPTransport
from fastapi import Request
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Clear registry
@ -766,10 +779,11 @@ async def test_oauth_authorization_server_respects_x_forwarded_proto():
async def test_register_client_respects_x_forwarded_proto():
"""Test that register_client uses X-Forwarded-Proto for redirect_uris"""
try:
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
register_client,
)
from fastapi import Request
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
@ -796,16 +810,17 @@ async def test_register_client_respects_x_forwarded_proto():
async def test_authorize_endpoint_respects_x_forwarded_host():
"""Test that authorize endpoint uses X-Forwarded-Host and X-Forwarded-Proto to construct correct redirect_uri"""
try:
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
authorize,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
from litellm.proxy._types import MCPTransport
from fastapi import Request
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
@ -868,16 +883,17 @@ async def test_authorize_endpoint_respects_x_forwarded_host():
async def test_token_endpoint_respects_x_forwarded_host():
"""Test that token endpoint uses X-Forwarded-Host and X-Forwarded-Proto for redirect_uri"""
try:
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
token_endpoint,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
from litellm.proxy._types import MCPTransport
from fastapi import Request
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
@ -1069,10 +1085,11 @@ def test_get_request_base_url_comprehensive(
):
"""Comprehensive test for get_request_base_url with various header combinations"""
try:
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
get_request_base_url,
)
from fastapi import Request
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
@ -1106,3 +1123,62 @@ def test_get_request_base_url_comprehensive(
f"X-Forwarded-Host={x_forwarded_host}, "
f"X-Forwarded-Port={x_forwarded_port}"
)
# ---------------------------------------------------------------------------
# Tests for /ui/mcp/oauth/callback backend endpoint
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_mcp_oauth_ui_callback_returns_html():
"""
The /ui/mcp/oauth/callback endpoint must return an HTML page regardless
of whether the static files mount is available. This is the fallback
that prevents a 404 in Kubernetes/read-only deployments.
"""
try:
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
mcp_oauth_ui_callback,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
response = await mcp_oauth_ui_callback(
code="test_auth_code_123",
state="test_state_456",
)
assert response.status_code == 200
body = response.body.decode()
assert "<!DOCTYPE html>" in body
# Payload values are embedded as JSON literals in the JS
assert "test_auth_code_123" in body
assert "test_state_456" in body
# Verify the storage key constants match the Next.js page
assert "litellm-mcp-oauth-result" in body
assert "litellm-mcp-oauth-return-url" in body
@pytest.mark.asyncio
async def test_mcp_oauth_ui_callback_handles_missing_params():
"""
The /ui/mcp/oauth/callback endpoint must handle missing code/state
gracefully (e.g. direct navigation or error redirect from provider).
"""
try:
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
mcp_oauth_ui_callback,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
response = await mcp_oauth_ui_callback(code=None, state=None)
assert response.status_code == 200
body = response.body.decode()
assert "<!DOCTYPE html>" in body
# null values should be safely embedded as JSON null
assert "null" in body