From 6f49645eb56b38b3230dd5327648cc4f20b92aaa Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 9 Mar 2026 15:51:20 +0530 Subject: [PATCH] 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 --- docker/Dockerfile.non_root | 10 +- .../mcp_server/discoverable_endpoints.py | 107 ++++++++++++++ litellm/proxy/proxy_server.py | 18 +++ .../mcp_server/test_discoverable_endpoints.py | 138 ++++++++++++++---- 4 files changed, 236 insertions(+), 37 deletions(-) diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index fda591df083..0235cbe7b58 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -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 diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index b731bc7bc2f..e9ec8de83dd 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -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""" + + + + + LiteLLM MCP OAuth + + + +
+

LiteLLM MCP OAuth

+

Authorization complete. You may close this window and return to the LiteLLM dashboard.

+

If the window does not close automatically, everything is still saved — you can close it manually.

+
+ + +""" + + +@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 # ------------------------------ diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index bc2728c2203..f2810be27cc 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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: diff --git a/tests/litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index ac2c945baa6..78c703ad4d9 100644 --- a/tests/litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -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 "" 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 "" in body + # null values should be safely embedded as JSON null + assert "null" in body +