Models
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,
diff --git a/ui/litellm-dashboard/src/utils/roles.test.tsx b/ui/litellm-dashboard/src/utils/roles.test.tsx
new file mode 100644
index 00000000000..871bbaba072
--- /dev/null
+++ b/ui/litellm-dashboard/src/utils/roles.test.tsx
@@ -0,0 +1,41 @@
+import { describe, it, expect } from "vitest";
+import { isAdminRole, isProxyAdminRole } from "./roles";
+
+describe("roles", () => {
+ describe("isAdminRole", () => {
+ it("should return true for all admin roles", () => {
+ expect(isAdminRole("Admin")).toBe(true);
+ expect(isAdminRole("Admin Viewer")).toBe(true);
+ expect(isAdminRole("proxy_admin")).toBe(true);
+ expect(isAdminRole("proxy_admin_viewer")).toBe(true);
+ expect(isAdminRole("org_admin")).toBe(true);
+ });
+
+ it("should return false for non-admin roles", () => {
+ expect(isAdminRole("Internal User")).toBe(false);
+ expect(isAdminRole("Internal Viewer")).toBe(false);
+ expect(isAdminRole("regular_user")).toBe(false);
+ expect(isAdminRole("")).toBe(false);
+ });
+ });
+
+ describe("isProxyAdminRole", () => {
+ it("should return true for proxy_admin and Admin roles", () => {
+ expect(isProxyAdminRole("proxy_admin")).toBe(true);
+ expect(isProxyAdminRole("Admin")).toBe(true);
+ });
+
+ it("should return false for other admin roles", () => {
+ expect(isProxyAdminRole("Admin Viewer")).toBe(false);
+ expect(isProxyAdminRole("proxy_admin_viewer")).toBe(false);
+ expect(isProxyAdminRole("org_admin")).toBe(false);
+ });
+
+ it("should return false for non-admin roles", () => {
+ expect(isProxyAdminRole("Internal User")).toBe(false);
+ expect(isProxyAdminRole("Internal Viewer")).toBe(false);
+ expect(isProxyAdminRole("regular_user")).toBe(false);
+ expect(isProxyAdminRole("")).toBe(false);
+ });
+ });
+});
diff --git a/ui/litellm-dashboard/src/utils/roles.ts b/ui/litellm-dashboard/src/utils/roles.ts
index f542da105f5..da9b00082e3 100644
--- a/ui/litellm-dashboard/src/utils/roles.ts
+++ b/ui/litellm-dashboard/src/utils/roles.ts
@@ -11,3 +11,7 @@ export const rolesWithWriteAccess = ["Internal User", "Admin", "proxy_admin"];
export const isAdminRole = (role: string): boolean => {
return all_admin_roles.includes(role);
};
+
+export const isProxyAdminRole = (role: string): boolean => {
+ return role === "proxy_admin" || role === "Admin";
+};
diff --git a/ui/litellm-dashboard/tests/setupTests.ts b/ui/litellm-dashboard/tests/setupTests.ts
index fdb3dea36e7..0a21f9dd4d5 100644
--- a/ui/litellm-dashboard/tests/setupTests.ts
+++ b/ui/litellm-dashboard/tests/setupTests.ts
@@ -72,3 +72,11 @@ Object.defineProperty(HTMLAnchorElement.prototype, "click", {
if (!document.getAnimations) {
document.getAnimations = () => [];
}
+
+// Mock ResizeObserver for components that use it (e.g., Tremor UI components)
+// This prevents "ResizeObserver is not defined" errors in JSDOM
+global.ResizeObserver = class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+};