= ({
= ({ accessToken, userRole, userID }) => {
@@ -54,6 +56,25 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID })
const [isDeletingServer, setIsDeletingServer] = useState(false);
const isInternalUser = userRole === "Internal User";
+ useEffect(() => {
+ if (typeof window === "undefined") {
+ return;
+ }
+ try {
+ const stored = window.sessionStorage.getItem(EDIT_OAUTH_UI_STATE_KEY);
+ if (!stored) {
+ return;
+ }
+ const parsed = JSON.parse(stored);
+ if (parsed?.serverId) {
+ setSelectedServerId(parsed.serverId);
+ setEditServer(true);
+ }
+ } catch (err) {
+ console.error("Failed to restore MCP edit view state", err);
+ }
+ }, []);
+
// Get unique teams from all servers
const uniqueTeams = React.useMemo(() => {
if (!mcpServers) return [];
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx
index 204a128953a..89da1bda156 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx
@@ -6,6 +6,7 @@ import { useTestMCPConnection } from "../../hooks/useTestMCPConnection";
interface MCPToolConfigurationProps {
accessToken: string | null;
+ oauthAccessToken?: string | null;
formValues: Record;
allowedTools: string[];
existingAllowedTools: string[] | null;
@@ -14,6 +15,7 @@ interface MCPToolConfigurationProps {
const MCPToolConfiguration: React.FC = ({
accessToken,
+ oauthAccessToken,
formValues,
allowedTools,
existingAllowedTools,
@@ -23,6 +25,7 @@ const MCPToolConfiguration: React.FC = ({
const { tools, isLoadingTools, toolsError, canFetchTools } = useTestMCPConnection({
accessToken,
+ oauthAccessToken,
formValues,
enabled: true,
});
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
index 3d5360207ac..6c01ca2e611 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
@@ -10,6 +10,7 @@ export const AUTH_TYPE = {
API_KEY: "api_key",
BEARER_TOKEN: "bearer_token",
BASIC: "basic",
+ OAUTH2: "oauth2",
};
export const TRANSPORT = {
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx
index 0731d356cb7..87c8563e467 100644
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -7296,19 +7296,32 @@ export const testMCPConnectionRequest = async (accessToken: string, mcpServerCon
}
};
-export const testMCPToolsListRequest = async (accessToken: string, mcpServerConfig: Record) => {
+export const testMCPToolsListRequest = async (
+ accessToken: string | null,
+ mcpServerConfig: Record,
+ oauthAccessToken?: string | null,
+) => {
try {
console.log("Testing MCP tools list with config:", JSON.stringify(mcpServerConfig));
// Construct the URL for POST request
const url = proxyBaseUrl ? `${proxyBaseUrl}/mcp-rest/test/tools/list` : `/mcp-rest/test/tools/list`;
+ const headers: Record = {
+ "Content-Type": "application/json",
+ };
+ if (accessToken) {
+ headers["x-litellm-api-key"] = accessToken;
+ }
+ if (oauthAccessToken) {
+ headers["Authorization"] = `Bearer ${oauthAccessToken}`;
+ } else if (accessToken) {
+ headers[globalLitellmHeaderName] = `Bearer ${accessToken}`;
+ }
+
const response = await fetch(url, {
method: "POST",
- headers: {
- "Content-Type": "application/json",
- [globalLitellmHeaderName]: `Bearer ${accessToken}`,
- },
+ headers,
body: JSON.stringify(mcpServerConfig),
});
@@ -7346,6 +7359,140 @@ export const testMCPToolsListRequest = async (accessToken: string, mcpServerConf
}
};
+export const cacheTemporaryMcpServer = async (accessToken: string, payload: Record) => {
+ const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/server/oauth/session` : `/v1/mcp/server/oauth/session`;
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ [globalLitellmHeaderName]: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+
+ const data = await response.json();
+ if (!response.ok) {
+ const errorMessage = deriveErrorMessage(data) || data?.error || "Failed to cache MCP server";
+ throw new Error(errorMessage);
+ }
+ return data;
+};
+
+interface RegisterMcpOAuthClientPayload {
+ client_name?: string;
+ grant_types?: string[];
+ response_types?: string[];
+ token_endpoint_auth_method?: string;
+}
+
+export const registerMcpOAuthClient = async (accessToken: string, serverId: string, payload: RegisterMcpOAuthClientPayload) => {
+ const base = getProxyBaseUrl();
+ const normalizedServerId = encodeURIComponent(serverId.trim());
+ const url = `${base}/v1/mcp/server/oauth/${normalizedServerId}/register`;
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ [globalLitellmHeaderName]: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "Accept": "application/json, text/event-stream",
+ },
+ body: JSON.stringify(payload),
+ });
+
+ const data = await response.json();
+ if (!response.ok) {
+ const errorMessage = deriveErrorMessage(data) || data?.detail || "Failed to register OAuth client";
+ throw new Error(errorMessage);
+ }
+ return data;
+};
+
+interface BuildOAuthAuthorizeURLParams {
+ serverId: string;
+ clientId?: string;
+ redirectUri: string;
+ state: string;
+ codeChallenge: string;
+ scope?: string;
+}
+
+export const buildMcpOAuthAuthorizeUrl = ({
+ serverId,
+ clientId,
+ redirectUri,
+ state,
+ codeChallenge,
+ scope,
+}: BuildOAuthAuthorizeURLParams): string => {
+ const base = getProxyBaseUrl();
+ const normalizedServerId = encodeURIComponent(serverId.trim());
+ const url = `${base}/v1/mcp/server/oauth/${normalizedServerId}/authorize`;
+ const params = new URLSearchParams({
+ redirect_uri: redirectUri,
+ state,
+ response_type: "code",
+ code_challenge: codeChallenge,
+ code_challenge_method: "S256",
+ });
+ if (clientId && clientId.trim().length > 0) {
+ params.set("client_id", clientId);
+ }
+ if (scope && scope.trim().length > 0) {
+ params.set("scope", scope);
+ }
+ return `${url}?${params.toString()}`;
+};
+
+interface ExchangeMcpOAuthTokenParams {
+ serverId: string;
+ code: string;
+ clientId?: string;
+ clientSecret?: string;
+ codeVerifier: string;
+ redirectUri: string;
+}
+
+export const exchangeMcpOAuthToken = async ({
+ serverId,
+ code,
+ clientId,
+ clientSecret,
+ codeVerifier,
+ redirectUri,
+}: ExchangeMcpOAuthTokenParams) => {
+ const base = getProxyBaseUrl();
+ const normalizedServerId = encodeURIComponent(serverId.trim());
+ const url = `${base}/v1/mcp/server/oauth/${normalizedServerId}/token`;
+
+ const body = new URLSearchParams();
+ body.set("grant_type", "authorization_code");
+ body.set("code", code);
+ if (clientId && clientId.trim().length > 0) {
+ body.set("client_id", clientId);
+ }
+ if (clientSecret && clientSecret.trim().length > 0) {
+ body.set("client_secret", clientSecret);
+ }
+ body.set("code_verifier", codeVerifier);
+ body.set("redirect_uri", redirectUri);
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: body.toString(),
+ });
+
+ const data = await response.json();
+ if (!response.ok) {
+ const errorMessage = deriveErrorMessage(data) || data?.detail || "OAuth token exchange failed";
+ throw new Error(errorMessage);
+ }
+ return data;
+};
+
export const vectorStoreSearchCall = async (
accessToken: string,
vectorStoreId: string,
diff --git a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx
new file mode 100644
index 00000000000..d4b8e953f09
--- /dev/null
+++ b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx
@@ -0,0 +1,290 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import NotificationsManager from "@/components/molecules/notifications_manager";
+import {
+ buildMcpOAuthAuthorizeUrl,
+ cacheTemporaryMcpServer,
+ exchangeMcpOAuthToken,
+ getProxyBaseUrl,
+ registerMcpOAuthClient,
+} from "@/components/networking";
+
+export type McpOAuthStatus = "idle" | "authorizing" | "exchanging" | "success" | "error";
+
+interface UseMcpOAuthFlowOptions {
+ accessToken: string | null;
+ getCredentials: () => {
+ client_id?: string;
+ client_secret?: string;
+ scopes?: string[];
+ } | undefined;
+ getTemporaryPayload: () => Record | null;
+ onTokenReceived: (tokenResponse: Record) => void;
+ onBeforeRedirect?: () => void;
+}
+
+interface UseMcpOAuthFlowResult {
+ startOAuthFlow: () => Promise;
+ status: McpOAuthStatus;
+ error: string | null;
+ tokenResponse: Record | null;
+}
+
+const base64UrlEncode = (buffer: ArrayBuffer) => {
+ const bytes = new Uint8Array(buffer);
+ let binary = "";
+ bytes.forEach((b) => (binary += String.fromCharCode(b)));
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+};
+
+const generateCodeVerifier = () => {
+ const array = new Uint8Array(32);
+ window.crypto.getRandomValues(array);
+ return base64UrlEncode(array.buffer);
+};
+
+const generateCodeChallenge = async (verifier: string) => {
+ const data = new TextEncoder().encode(verifier);
+ const digest = await window.crypto.subtle.digest("SHA-256", data);
+ return base64UrlEncode(digest);
+};
+
+export const useMcpOAuthFlow = ({
+ accessToken,
+ getCredentials,
+ getTemporaryPayload,
+ onTokenReceived,
+ onBeforeRedirect,
+}: UseMcpOAuthFlowOptions): UseMcpOAuthFlowResult => {
+ const [status, setStatus] = useState("idle");
+ const [error, setError] = useState(null);
+ const [tokenResponse, setTokenResponse] = useState | null>(null);
+
+ const FLOW_STATE_KEY = "litellm-mcp-oauth-flow-state";
+ const RESULT_KEY = "litellm-mcp-oauth-result";
+ const RETURN_URL_KEY = "litellm-mcp-oauth-return-url";
+
+ type StoredFlowState = {
+ state: string;
+ codeVerifier: string;
+ clientId?: string;
+ clientSecret?: string;
+ serverId: string;
+ redirectUri: string;
+ };
+
+ const clearStoredFlow = () => {
+ if (typeof window === "undefined") {
+ return;
+ }
+ try {
+ window.sessionStorage.removeItem(FLOW_STATE_KEY);
+ window.sessionStorage.removeItem(RESULT_KEY);
+ window.sessionStorage.removeItem(RETURN_URL_KEY);
+ } catch (err) {
+ console.warn("Failed to clear OAuth storage", err);
+ }
+ };
+
+ const callbackUrl = () => {
+ if (typeof window === "undefined") {
+ return `${getProxyBaseUrl()}/v1/mcp/oauth/callback`;
+ }
+ return `${window.location.origin}/mcp/oauth/callback`;
+ };
+
+ const startOAuthFlow = useCallback(async () => {
+ const credentials = getCredentials() || {};
+
+ if (!accessToken) {
+ setError("Missing admin token");
+ NotificationsManager.error("Access token missing. Please re-authenticate and try again.");
+ return;
+ }
+
+ const temporaryPayload = getTemporaryPayload();
+ if (!temporaryPayload || !temporaryPayload.url || !temporaryPayload.transport) {
+ const message = "Please complete server URL and transport before starting OAuth.";
+ setError(message);
+ NotificationsManager.error(message);
+ return;
+ }
+ try {
+ setStatus("authorizing");
+ setError(null);
+
+ const cachedServer = await cacheTemporaryMcpServer(accessToken, temporaryPayload);
+ const serverId = cachedServer?.server_id?.trim();
+ if (!serverId) {
+ throw new Error("Temporary MCP server identifier missing. Please retry.");
+ }
+
+ let registeredClient: { clientId?: string; clientSecret?: string } = {};
+ const hasPreconfiguredCredentials = Boolean(temporaryPayload.credentials?.client_id && temporaryPayload.credentials?.client_secret);
+
+ if (!hasPreconfiguredCredentials) {
+ const registration = await registerMcpOAuthClient(accessToken, serverId, {
+ client_name: temporaryPayload.alias || temporaryPayload.server_name || serverId,
+ grant_types: ["authorization_code"],
+ response_types: ["code"],
+ token_endpoint_auth_method:
+ temporaryPayload.credentials && temporaryPayload.credentials.client_secret ? "client_secret_post" : "none",
+ });
+ registeredClient = {
+ clientId: registration?.client_id,
+ clientSecret: registration?.client_secret,
+ };
+ }
+
+ const verifier = generateCodeVerifier();
+ const challenge = await generateCodeChallenge(verifier);
+ const state = crypto.randomUUID();
+
+ const clientId = registeredClient.clientId || credentials.client_id;
+ const scopeString = Array.isArray(credentials.scopes)
+ ? credentials.scopes.filter((s) => s && s.trim().length > 0).join(" ")
+ : undefined;
+
+ const authorizeUrl = buildMcpOAuthAuthorizeUrl({
+ serverId,
+ clientId: clientId,
+ redirectUri: callbackUrl(),
+ state,
+ codeChallenge: challenge,
+ scope: scopeString,
+ });
+
+ const flowState: StoredFlowState = {
+ state,
+ codeVerifier: verifier,
+ clientId,
+ clientSecret: registeredClient.clientSecret || credentials.client_secret,
+ serverId,
+ redirectUri: callbackUrl(),
+ };
+
+ if (typeof window === "undefined") {
+ throw new Error("OAuth redirect is only supported in the browser.");
+ }
+
+ if (onBeforeRedirect) {
+ try {
+ onBeforeRedirect();
+ } catch (prepErr) {
+ console.error("Failed to prepare for OAuth redirect", prepErr);
+ }
+ }
+
+ try {
+ window.sessionStorage.setItem(FLOW_STATE_KEY, JSON.stringify(flowState));
+ window.sessionStorage.setItem(RETURN_URL_KEY, window.location.href);
+ } catch (storageErr) {
+ console.error("Unable to persist OAuth state", storageErr);
+ throw new Error("Unable to access browser storage for OAuth. Please enable storage and retry.");
+ }
+
+ window.location.href = authorizeUrl;
+ } catch (err) {
+ console.error("Failed to start OAuth flow", err);
+ setStatus("error");
+ const message = err instanceof Error ? err.message : String(err);
+ setError(message);
+ NotificationsManager.error(message);
+ }
+ }, [accessToken, getCredentials, getTemporaryPayload, onBeforeRedirect]);
+
+ const resumeOAuthFlow = useCallback(async () => {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ let payload: Record | null = null;
+ let flowState: StoredFlowState | null = null;
+
+ try {
+ const storedPayload = window.sessionStorage.getItem(RESULT_KEY);
+ if (!storedPayload) {
+ return;
+ }
+ payload = JSON.parse(storedPayload);
+ flowState = JSON.parse(window.sessionStorage.getItem(FLOW_STATE_KEY) || "null");
+ } catch (err) {
+ console.error("Failed to read OAuth session state", err);
+ clearStoredFlow();
+ setError("Failed to resume OAuth flow. Please retry.");
+ setStatus("error");
+ NotificationsManager.error("Failed to resume OAuth flow. Please retry.");
+ return;
+ }
+
+ if (!payload) {
+ return;
+ }
+
+ window.sessionStorage.removeItem(RESULT_KEY);
+
+ try {
+ if (!flowState || !flowState.state || !flowState.codeVerifier || !flowState.serverId) {
+ throw new Error("Missing OAuth session state. Please retry.");
+ }
+ if (!payload.state || payload.state !== flowState.state) {
+ throw new Error("OAuth state mismatch. Please retry.");
+ }
+ if (payload.error) {
+ throw new Error(payload.error_description || payload.error);
+ }
+ if (!payload.code) {
+ throw new Error("Authorization code missing in callback.");
+ }
+
+ setStatus("exchanging");
+ const token = await exchangeMcpOAuthToken({
+ serverId: flowState.serverId,
+ code: payload.code,
+ clientId: flowState.clientId,
+ clientSecret: flowState.clientSecret,
+ codeVerifier: flowState.codeVerifier,
+ redirectUri: flowState.redirectUri,
+ });
+
+ onTokenReceived(token);
+ setTokenResponse(token);
+ setStatus("success");
+ setError(null);
+ NotificationsManager.success("OAuth token retrieved successfully");
+ } catch (err) {
+ console.error("OAuth flow failed", err);
+ const message = err instanceof Error ? err.message : String(err);
+ setError(message);
+ setStatus("error");
+ NotificationsManager.error(message);
+ } finally {
+ clearStoredFlow();
+ }
+ }, [onTokenReceived]);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const maybeResume = async () => {
+ if (cancelled) {
+ return;
+ }
+ await resumeOAuthFlow();
+ };
+
+ maybeResume();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [resumeOAuthFlow]);
+
+ return {
+ startOAuthFlow,
+ status,
+ error,
+ tokenResponse,
+ };
+};
diff --git a/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx b/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx
index 0cad2fe81ab..a82bb2fa45b 100644
--- a/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx
+++ b/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import { testMCPToolsListRequest } from "../components/networking";
+import { AUTH_TYPE } from "@/components/mcp_tools/types";
interface MCPServerConfig {
server_id?: string;
@@ -19,6 +20,7 @@ interface MCPServerConfig {
interface UseTestMCPConnectionProps {
accessToken: string | null;
+ oauthAccessToken?: string | null;
formValues: Record;
enabled?: boolean; // Optional flag to enable/disable auto-fetching
}
@@ -35,6 +37,7 @@ interface UseTestMCPConnectionReturn {
export const useTestMCPConnection = ({
accessToken,
+ oauthAccessToken,
formValues,
enabled = true,
}: UseTestMCPConnectionProps): UseTestMCPConnectionReturn => {
@@ -44,7 +47,14 @@ export const useTestMCPConnection = ({
const [hasShownSuccessMessage, setHasShownSuccessMessage] = useState(false);
// Check if we have the minimum required fields to fetch tools
- const canFetchTools = !!(formValues.url && formValues.transport && formValues.auth_type && accessToken);
+ const requiresOAuthToken = formValues.auth_type === AUTH_TYPE.OAUTH2;
+ const canFetchTools = !!(
+ formValues.url &&
+ formValues.transport &&
+ formValues.auth_type &&
+ accessToken &&
+ (!requiresOAuthToken || oauthAccessToken)
+ );
const staticHeadersKey = JSON.stringify(formValues.static_headers ?? {});
const credentialsKey = JSON.stringify(formValues.credentials ?? {});
@@ -54,6 +64,10 @@ export const useTestMCPConnection = ({
return;
}
+ if (requiresOAuthToken && !oauthAccessToken) {
+ return;
+ }
+
setIsLoadingTools(true);
setToolsError(null);
@@ -118,7 +132,7 @@ export const useTestMCPConnection = ({
mcpServerConfig.credentials = credentials;
}
- const toolsResponse = await testMCPToolsListRequest(accessToken, mcpServerConfig);
+ const toolsResponse = await testMCPToolsListRequest(accessToken, mcpServerConfig, oauthAccessToken);
if (toolsResponse.tools && !toolsResponse.error) {
setTools(toolsResponse.tools);
@@ -166,6 +180,7 @@ export const useTestMCPConnection = ({
formValues.auth_type,
accessToken,
enabled,
+ oauthAccessToken,
canFetchTools,
staticHeadersKey,
credentialsKey,