diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OAuth2ConnectButton.tsx b/ui/litellm-dashboard/src/components/mcp_tools/OAuth2ConnectButton.tsx new file mode 100644 index 00000000000..853a757b871 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/OAuth2ConnectButton.tsx @@ -0,0 +1,114 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { Button, message } from "antd"; +import { getMcpOAuth2ConnectUrl, getMcpOAuth2Status } from "../networking"; +import { MCPServer } from "./types"; + +interface OAuth2ConnectButtonProps { + server: MCPServer; + accessToken: string; + onConnected: () => void; +} + +const POLL_INTERVAL_MS = 2000; + +export const OAuth2ConnectButton: React.FC = ({ + server, + accessToken, + onConnected, +}) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const popupRef = useRef(null); + const pollTimerRef = useRef | null>(null); + + const stopPolling = () => { + if (pollTimerRef.current !== null) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + }; + + const handleConnected = () => { + stopPolling(); + if (popupRef.current && !popupRef.current.closed) { + popupRef.current.close(); + } + popupRef.current = null; + setLoading(false); + message.success(`Connected to ${server.alias || server.server_name || "server"}`); + onConnected(); + }; + + const startPolling = () => { + pollTimerRef.current = setInterval(async () => { + // Stop if popup was closed by user + if (popupRef.current && popupRef.current.closed) { + stopPolling(); + setLoading(false); + return; + } + + try { + const status = await getMcpOAuth2Status(server.server_id, accessToken); + if (status.connected) { + handleConnected(); + } + } catch { + // Ignore polling errors; keep trying until popup is closed + } + }, POLL_INTERVAL_MS); + }; + + const handleClick = async () => { + setError(null); + setLoading(true); + try { + const { authorization_url } = await getMcpOAuth2ConnectUrl(server.server_id, accessToken); + const popup = window.open(authorization_url, "oauth2_connect", "width=600,height=700,scrollbars=yes"); + if (!popup) { + setError("Popup was blocked. Please allow popups for this page and try again."); + setLoading(false); + return; + } + popupRef.current = popup; + startPolling(); + } catch (e: any) { + setError(e.message || "Failed to start OAuth2 connection"); + setLoading(false); + } + }; + + // Clean up on unmount + useEffect(() => { + return () => { + stopPolling(); + }; + }, []); + + const isConnected = !!server.has_user_credential; + + return ( +
+
+ {isConnected && ( + + ✓ Connected + + )} + +
+ {error && {error}} +
+ ); +}; + +export default OAuth2ConnectButton; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx index 0db1f4c14cd..15a66526d55 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx @@ -1,10 +1,11 @@ import { ColumnDef } from "@tanstack/react-table"; -import { MCPServer } from "./types"; +import { MCPServer, AUTH_TYPE } from "./types"; import { Icon } from "@tremor/react"; import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; import { getMaskedAndFullUrl } from "./utils"; import { Tooltip } from "antd"; import { CheckOutlined } from "@ant-design/icons"; +import { OAuth2ConnectButton } from "./OAuth2ConnectButton"; export const mcpServerColumns = ( userRole: string, @@ -13,6 +14,8 @@ export const mcpServerColumns = ( onDelete: (serverId: string) => void, isLoadingHealth?: boolean, onByokConnect?: (server: MCPServer) => void, + accessToken?: string, + refreshServers?: () => void, ): ColumnDef[] => [ { accessorKey: "server_id", @@ -202,6 +205,16 @@ export const mcpServerColumns = ( if (!server.is_byok) { return —; } + if (server.is_byok && server.auth_type === AUTH_TYPE.OAUTH2) { + if (!accessToken || !refreshServers) return null; + return ( + + ); + } if (server.has_user_credential) { return (
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx index f48649d6653..5953036cc49 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx @@ -173,8 +173,10 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) handleDelete, isLoadingHealth, (server: MCPServer) => setByokModalServer(server), + accessToken ?? undefined, + refetch, ), - [userRole, isLoadingHealth], + [userRole, isLoadingHealth, accessToken, refetch], ); function handleDelete(server_id: string) { diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 30c1d3c5b81..a7c1a418aba 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -6395,6 +6395,68 @@ export const fetchMCPClientIp = async (accessToken: string): Promise => { + try { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/server/${serverId}/oauth2/connect` + : `/v1/mcp/server/${serverId}/oauth2/connect`; + + const response = await fetch(url, { + method: HTTP_REQUEST.GET, + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + return await response.json(); + } catch (error) { + console.error("Failed to get MCP OAuth2 connect URL:", error); + throw error; + } +}; + +export const getMcpOAuth2Status = async ( + serverId: string, + accessToken: string, +): Promise<{ connected: boolean }> => { + try { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/server/${serverId}/oauth2/status` + : `/v1/mcp/server/${serverId}/oauth2/status`; + + const response = await fetch(url, { + method: HTTP_REQUEST.GET, + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + return await response.json(); + } catch (error) { + console.error("Failed to get MCP OAuth2 status:", error); + throw error; + } +}; + export const createMCPServer = async ( accessToken: string, formValues: Record, // Assuming formValues is an object