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:
Ishaan Jaffer 2026-03-05 18:29:46 -08:00
parent c1fcbf6219
commit 2c8d9d0f32
4 changed files with 193 additions and 2 deletions

View file

@ -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">
&#10003; 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;

View file

@ -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">

View file

@ -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) {

View file

@ -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