From b2848a94395e944b29fe7998f64b76756fe1f5e3 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Thu, 11 Dec 2025 06:32:19 +0900 Subject: [PATCH 1/2] fix: MCP OAuth callback routing and URL handling --- litellm/proxy/proxy_server.py | 34 +++++++++++++------ .../src/app/mcp/oauth/callback/page.tsx | 22 +++++++++--- .../src/hooks/useMcpOAuthFlow.tsx | 18 +++++++--- 3 files changed, 54 insertions(+), 20 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 8c8f3b3ddf2f..e46350f5a386 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1022,21 +1022,33 @@ async def openai_exception_handler(request: Request, exc: ProxyException): app.mount("/ui", StaticFiles(directory=ui_path, html=True), name="ui") + def _restructure_ui_html_files(ui_root: str) -> None: + """Ensure each exported HTML route is available as /index.html.""" + + for current_root, _, files in os.walk(ui_root): + rel_root = os.path.relpath(current_root, ui_root) + first_segment = "" if rel_root == "." else rel_root.split(os.sep)[0] + + # Ignore Next.js asset directories + if first_segment in {"_next", "litellm-asset-prefix"}: + continue + + for filename in files: + if not filename.endswith(".html") or filename == "index.html": + continue + + file_path = os.path.join(current_root, filename) + target_dir = os.path.splitext(file_path)[0] + target_path = os.path.join(target_dir, "index.html") + + os.makedirs(target_dir, exist_ok=True) + os.replace(file_path, target_path) + # Handle HTML file restructuring # Skip this for non-root Docker since it's done at build time # Support both "true" and "True" for case-insensitive comparison if os.getenv("LITELLM_NON_ROOT", "").lower() != "true": - for filename in os.listdir(ui_path): - if filename.endswith(".html") and filename != "index.html": - # Create a folder with the same name as the HTML file - folder_name = os.path.splitext(filename)[0] - folder_path = os.path.join(ui_path, folder_name) - os.makedirs(folder_path, exist_ok=True) - - # Move the HTML file into the folder and rename it to 'index.html' - src = os.path.join(ui_path, filename) - dst = os.path.join(folder_path, "index.html") - os.rename(src, dst) + _restructure_ui_html_files(ui_path) else: verbose_proxy_logger.info( "Skipping runtime HTML restructuring for non-root Docker (already done at build time)" diff --git a/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx b/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx index 46431701859c..252640cef714 100644 --- a/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx +++ b/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx @@ -6,6 +6,21 @@ import { useSearchParams } from "next/navigation"; const RESULT_STORAGE_KEY = "litellm-mcp-oauth-result"; const RETURN_URL_STORAGE_KEY = "litellm-mcp-oauth-return-url"; +const resolveDefaultRedirect = () => { + if (typeof window === "undefined") { + return "/ui"; + } + + const path = window.location.pathname || ""; + const uiIndex = path.indexOf("/ui"); + if (uiIndex >= 0) { + const prefix = path.slice(0, uiIndex + 3); + return prefix.endsWith("/") ? prefix : `${prefix}`; + } + + return "/"; +}; + const McpOAuthCallbackPage = () => { const searchParams = useSearchParams(); @@ -33,11 +48,8 @@ const McpOAuthCallbackPage = () => { const returnUrl = window.sessionStorage.getItem(RETURN_URL_STORAGE_KEY); console.info("[MCP OAuth callback] returnUrl", returnUrl); - if (returnUrl) { - window.location.replace(returnUrl); - } else { - window.location.replace("/"); - } + const destination = returnUrl || resolveDefaultRedirect(); + window.location.replace(destination); }, [payload]); return ( diff --git a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx index d4b8e953f099..9600c962564b 100644 --- a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx +++ b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx @@ -8,6 +8,7 @@ import { exchangeMcpOAuthToken, getProxyBaseUrl, registerMcpOAuthClient, + serverRootPath, } from "@/components/networking"; export type McpOAuthStatus = "idle" | "authorizing" | "exchanging" | "success" | "error"; @@ -87,13 +88,22 @@ export const useMcpOAuthFlow = ({ } }; - const callbackUrl = () => { - if (typeof window === "undefined") { - return `${getProxyBaseUrl()}/v1/mcp/oauth/callback`; + const buildCallbackUrl = () => { + if (typeof window !== "undefined") { + const path = window.location.pathname || ""; + const uiIndex = path.indexOf("/ui"); + const uiPrefix = uiIndex >= 0 ? path.slice(0, uiIndex + 3) : ""; + const normalizedPrefix = uiPrefix.replace(/\/+$/, ""); + return `${window.location.origin}${normalizedPrefix}/mcp/oauth/callback`; } - return `${window.location.origin}/mcp/oauth/callback`; + + const base = (getProxyBaseUrl() || "").replace(/\/+$/, ""); + const rootPrefix = serverRootPath && serverRootPath !== "/" ? serverRootPath : ""; + return `${base}${rootPrefix}/ui/mcp/oauth/callback`; }; + const callbackUrl = () => buildCallbackUrl(); + const startOAuthFlow = useCallback(async () => { const credentials = getCredentials() || {}; From 77ef77b79d84ddf22d6f8ae41edffa6f0dc1061a Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Thu, 11 Dec 2025 07:51:40 +0900 Subject: [PATCH 2/2] test: add test for proxy_server --- tests/test_litellm/proxy/test_proxy_server.py | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 1b7d285bf336..22a9d5e647ba 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -5,6 +5,7 @@ import socket import subprocess import sys +from pathlib import Path from datetime import datetime from unittest import mock from unittest.mock import AsyncMock, MagicMock, mock_open, patch @@ -162,6 +163,39 @@ def test_sso_key_generate_shows_deprecation_banner(client_no_auth, monkeypatch): assert "Deprecated:" in html +def test_restructure_ui_html_files_handles_nested_routes(tmp_path): + from litellm.proxy import proxy_server + + ui_root = tmp_path / "ui" + ui_root.mkdir() + + def write_file(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + write_file(ui_root / "home.html", "home") + write_file(ui_root / "mcp" / "oauth" / "callback.html", "callback") + write_file(ui_root / "existing" / "index.html", "keep") + write_file(ui_root / "_next" / "ignore.html", "asset") + write_file(ui_root / "litellm-asset-prefix" / "ignore.html", "asset") + + proxy_server._restructure_ui_html_files(str(ui_root)) + + assert not (ui_root / "home.html").exists() + assert (ui_root / "home" / "index.html").read_text() == "home" + assert not (ui_root / "mcp" / "oauth" / "callback.html").exists() + assert ( + (ui_root / "mcp" / "oauth" / "callback" / "index.html").read_text() + == "callback" + ) + assert (ui_root / "existing" / "index.html").read_text() == "keep" + assert (ui_root / "_next" / "ignore.html").read_text() == "asset" + assert ( + (ui_root / "litellm-asset-prefix" / "ignore.html").read_text() + == "asset" + ) + + @pytest.mark.asyncio async def test_initialize_scheduled_jobs_credentials(monkeypatch): """ @@ -2791,4 +2825,3 @@ def getenv_side_effect(key, default=""): # Verify FileResponse was called assert mock_file_response.called, "FileResponse should be called" -