From f37d6480ecd084c274b5d6bc3c680838d6a9e45b Mon Sep 17 00:00:00 2001 From: YutaSaito <36355491+uc4w6c@users.noreply.github.com> Date: Mon, 24 Nov 2025 14:54:01 +0900 Subject: [PATCH] feat: add UI support for registering MCP OAuth2 auth_type (#17007) --- .../src/app/mcp/oauth/callback/page.tsx | 58 ++++ .../mcp_tools/create_mcp_server.tsx | 238 +++++++++++++- .../mcp_tools/mcp_connection_status.tsx | 6 +- .../components/mcp_tools/mcp_server_edit.tsx | 216 ++++++++++++- .../src/components/mcp_tools/mcp_servers.tsx | 21 ++ .../mcp_tools/mcp_tool_configuration.tsx | 3 + .../src/components/mcp_tools/types.tsx | 1 + .../src/components/networking.tsx | 157 +++++++++- .../src/hooks/useMcpOAuthFlow.tsx | 290 ++++++++++++++++++ .../src/hooks/useTestMCPConnection.tsx | 19 +- 10 files changed, 991 insertions(+), 18 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx create mode 100644 ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx diff --git a/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx b/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx new file mode 100644 index 00000000000..46431701859 --- /dev/null +++ b/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect, useMemo } from "react"; +import { useSearchParams } from "next/navigation"; + +const RESULT_STORAGE_KEY = "litellm-mcp-oauth-result"; +const RETURN_URL_STORAGE_KEY = "litellm-mcp-oauth-return-url"; + +const McpOAuthCallbackPage = () => { + const searchParams = useSearchParams(); + + const payload = useMemo(() => { + if (!searchParams) { + return null; + } + return { + type: "litellm-mcp-oauth", + code: searchParams.get("code"), + state: searchParams.get("state"), + }; + }, [searchParams]); + + useEffect(() => { + if (!payload || typeof window === "undefined") { + return; + } + + try { + window.sessionStorage.setItem(RESULT_STORAGE_KEY, JSON.stringify(payload)); + } catch (err) { + console.error("Failed to persist OAuth callback payload", err); + } + + 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("/"); + } + }, [payload]); + + return ( +
+
+

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. +

+
+
+ ); +}; + +export default McpOAuthCallbackPage; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index 4ec60ed276a..65a6c6c84eb 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { Modal, Tooltip, Form, Select } from "antd"; +import { Modal, Tooltip, Form, Select, Input } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Button, TextInput } from "@tremor/react"; import { createMCPServer } from "../networking"; @@ -12,6 +12,7 @@ import MCPPermissionManagement from "./MCPPermissionManagement"; import { isAdminRole } from "@/utils/roles"; import { validateMCPServerUrl, validateMCPServerName } from "./utils"; import NotificationsManager from "../molecules/notifications_manager"; +import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow"; const asset_logos_folder = "../ui/assets/logos/"; export const mcpLogoImg = `${asset_logos_folder}mcp_logo.png`; @@ -26,6 +27,8 @@ interface CreateMCPServerProps { } const AUTH_TYPES_REQUIRING_AUTH_VALUE = [AUTH_TYPE.API_KEY, AUTH_TYPE.BEARER_TOKEN, AUTH_TYPE.BASIC]; +const AUTH_TYPES_REQUIRING_CREDENTIALS = [...AUTH_TYPES_REQUIRING_AUTH_VALUE, AUTH_TYPE.OAUTH2]; +const CREATE_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-create-state"; const CreateMCPServer: React.FC = ({ userRole, @@ -39,14 +42,88 @@ const CreateMCPServer: React.FC = ({ const [isLoading, setIsLoading] = useState(false); const [costConfig, setCostConfig] = useState({}); const [formValues, setFormValues] = useState>({}); + const [pendingRestoredValues, setPendingRestoredValues] = useState<{ values: Record; transport?: string } | null>(null); const [aliasManuallyEdited, setAliasManuallyEdited] = useState(false); const [tools, setTools] = useState([]); const [allowedTools, setAllowedTools] = useState([]); const [transportType, setTransportType] = useState(""); const [searchValue, setSearchValue] = useState(""); const [urlWarning, setUrlWarning] = useState(""); + const [oauthAccessToken, setOauthAccessToken] = useState(null); const authType = formValues.auth_type as string | undefined; const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false; + const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2; + + const persistCreateUiState = () => { + if (typeof window === "undefined") { + return; + } + try { + const values = form.getFieldsValue(true); + window.sessionStorage.setItem( + CREATE_OAUTH_UI_STATE_KEY, + JSON.stringify({ + modalVisible: isModalVisible, + formValues: values, + transportType, + costConfig, + allowedTools, + searchValue, + aliasManuallyEdited, + }), + ); + } catch (err) { + console.warn("Failed to persist MCP create state", err); + } + }; + + const { + startOAuthFlow, + status: oauthStatus, + error: oauthError, + tokenResponse: oauthTokenResponse, + } = useMcpOAuthFlow({ + accessToken, + getCredentials: () => form.getFieldValue("credentials"), + getTemporaryPayload: () => { + const values = form.getFieldsValue(true); + const url = values.url; + const transport = values.transport || transportType; + if (!url || !transport) { + return null; + } + const staticHeaders = Array.isArray(values.static_headers) + ? values.static_headers.reduce((acc: Record, entry: Record) => { + const header = entry?.header?.trim(); + if (!header) { + return acc; + } + acc[header] = entry?.value ?? ""; + return acc; + }, {}) + : ({} as Record); + + return { + server_id: undefined, + server_name: values.server_name, + alias: values.alias, + description: values.description, + url, + transport, + auth_type: AUTH_TYPE.OAUTH2, + credentials: values.credentials, + mcp_access_groups: values.mcp_access_groups, + static_headers: staticHeaders, + command: values.command, + args: values.args, + env: values.env, + }; + }, + onTokenReceived: (token) => { + setOauthAccessToken(token?.access_token ?? null); + }, + onBeforeRedirect: persistCreateUiState, + }); // Function to check URL format based on transport type const checkUrlFormat = (url: string, transport: string) => { @@ -64,6 +141,63 @@ const CreateMCPServer: React.FC = ({ } }; + React.useEffect(() => { + if (typeof window === "undefined") { + return; + } + const storedState = window.sessionStorage.getItem(CREATE_OAUTH_UI_STATE_KEY); + if (!storedState) { + return; + } + + try { + const parsed = JSON.parse(storedState); + if (parsed.modalVisible) { + setModalVisible(true); + } + const restoredTransport = parsed.formValues?.transport || parsed.transportType || ""; + if (restoredTransport) { + setTransportType(restoredTransport); + } + if (parsed.formValues) { + setPendingRestoredValues({ values: parsed.formValues, transport: restoredTransport }); + } + if (parsed.costConfig) { + setCostConfig(parsed.costConfig); + } + if (parsed.allowedTools) { + setAllowedTools(parsed.allowedTools); + } + if (parsed.searchValue) { + setSearchValue(parsed.searchValue); + } + if (typeof parsed.aliasManuallyEdited === "boolean") { + setAliasManuallyEdited(parsed.aliasManuallyEdited); + } + } catch (err) { + console.error("Failed to restore MCP create state", err); + } finally { + window.sessionStorage.removeItem(CREATE_OAUTH_UI_STATE_KEY); + } + }, [form, setModalVisible]); + + React.useEffect(() => { + if (!pendingRestoredValues) { + return; + } + const transportReady = transportType || pendingRestoredValues.transport || ""; + if (pendingRestoredValues.transport && !transportType) { + // wait until transportType state catches up so the URL field is mounted + return; + } + form.setFieldsValue(pendingRestoredValues.values); + setFormValues(pendingRestoredValues.values); + if (pendingRestoredValues.values.url && transportReady) { + checkUrlFormat(pendingRestoredValues.values.url, transportReady); + } + setPendingRestoredValues(null); + }, [pendingRestoredValues, form, transportType]); + const handleCreate = async (values: Record) => { setIsLoading(true); try { @@ -165,7 +299,7 @@ const CreateMCPServer: React.FC = ({ }; payload.static_headers = staticHeaders; - const includeCredentials = restValues.auth_type && AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(restValues.auth_type); + const includeCredentials = restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type); if (includeCredentials && credentialsPayload && Object.keys(credentialsPayload).length > 0) { payload.credentials = credentialsPayload; @@ -208,7 +342,7 @@ const CreateMCPServer: React.FC = ({ setTransportType(value); // Clear fields that are not relevant for the selected transport if (value === "stdio") { - form.setFieldsValue({ url: undefined, auth_type: undefined }); + form.setFieldsValue({ url: undefined, auth_type: undefined, credentials: undefined }); setUrlWarning(""); } else { form.setFieldsValue({ command: undefined, args: undefined, env: undefined }); @@ -403,10 +537,15 @@ const CreateMCPServer: React.FC = ({ ]} >
- { + const value = e.target.value; + checkUrlFormat(value, transportType); + form.setFieldValue("url", value); + }} placeholder="https://your-mcp-server.com" className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" - onChange={(e) => checkUrlFormat(e.target.value, transportType)} /> {urlWarning &&
{urlWarning}
}
@@ -425,6 +564,7 @@ const CreateMCPServer: React.FC = ({ API Key Bearer Token Basic Auth + OAuth )} @@ -450,6 +590,86 @@ const CreateMCPServer: React.FC = ({ )} + {transportType !== "stdio" && isOAuthAuthType && ( + <> + + OAuth Client ID (optional) + + + + + } + name={["credentials", "client_id"]} + > + + + + OAuth Client Secret (optional) + + + + + } + name={["credentials", "client_secret"]} + > + + + + OAuth Scopes (optional) + + + + + } + name={["credentials", "scopes"]} + > + @@ -309,6 +440,84 @@ const MCPServerEdit: React.FC = ({ )} + {isOAuthAuthType && ( + <> + + OAuth Client ID (optional) + + + + + } + name={["credentials", "client_id"]} + > + + + + OAuth Client Secret (optional) + + + + + } + name={["credentials", "client_secret"]} + > + + + + OAuth Scopes (optional) + + + + + } + name={["credentials", "scopes"]} + > +