mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
feat(ui): add OAuth2 Connect button for BYOK OpenAPI MCP servers
When a server has is_byok=true and auth_type=oauth2, the Credentials
column in the MCP Servers table shows an OAuth2ConnectButton instead
of the static key entry modal.
- OAuth2ConnectButton: calls /v1/mcp/server/{id}/oauth2/connect,
opens the returned authorization_url in a popup, polls
/v1/mcp/server/{id}/oauth2/status every 2 s until connected=true,
then shows a Connected badge and calls onConnected() to refresh the table
- mcp_server_columns: branches on auth_type===oauth2 to render the
new button, passing accessToken and refreshServers
- mcp_servers: passes accessToken and refetch down to the columns factory
- networking: adds getMcpOAuth2ConnectUrl and getMcpOAuth2Status helpers
This commit is contained in:
parent
c1fcbf6219
commit
2c8d9d0f32
4 changed files with 193 additions and 2 deletions
|
|
@ -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<OAuth2ConnectButtonProps> = ({
|
||||
server,
|
||||
accessToken,
|
||||
onConnected,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const popupRef = useRef<Window | null>(null);
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | 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 (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected && (
|
||||
<span className="text-green-600 text-xs font-medium flex items-center gap-1">
|
||||
✓ Connected
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type={isConnected ? "default" : "primary"}
|
||||
size="small"
|
||||
loading={loading}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{isConnected ? "Reconnect" : "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <span className="text-red-500 text-xs">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuth2ConnectButton;
|
||||
|
|
@ -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<MCPServer>[] => [
|
||||
{
|
||||
accessorKey: "server_id",
|
||||
|
|
@ -202,6 +205,16 @@ export const mcpServerColumns = (
|
|||
if (!server.is_byok) {
|
||||
return <span className="text-gray-300 text-xs">—</span>;
|
||||
}
|
||||
if (server.is_byok && server.auth_type === AUTH_TYPE.OAUTH2) {
|
||||
if (!accessToken || !refreshServers) return null;
|
||||
return (
|
||||
<OAuth2ConnectButton
|
||||
server={server}
|
||||
accessToken={accessToken}
|
||||
onConnected={refreshServers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (server.has_user_credential) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -173,8 +173,10 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
handleDelete,
|
||||
isLoadingHealth,
|
||||
(server: MCPServer) => setByokModalServer(server),
|
||||
accessToken ?? undefined,
|
||||
refetch,
|
||||
),
|
||||
[userRole, isLoadingHealth],
|
||||
[userRole, isLoadingHealth, accessToken, refetch],
|
||||
);
|
||||
|
||||
function handleDelete(server_id: string) {
|
||||
|
|
|
|||
|
|
@ -6395,6 +6395,68 @@ export const fetchMCPClientIp = async (accessToken: string): Promise<string | nu
|
|||
}
|
||||
};
|
||||
|
||||
export const getMcpOAuth2ConnectUrl = async (
|
||||
serverId: string,
|
||||
accessToken: string,
|
||||
): Promise<{ authorization_url: string; server_id: string; server_name: string }> => {
|
||||
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<string, any>, // Assuming formValues is an object
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue