feat: add UI support for registering MCP OAuth2 auth_type (#17007)

This commit is contained in:
YutaSaito 2025-11-24 14:54:01 +09:00 committed by GitHub
parent 5b0729034c
commit f37d6480ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 991 additions and 18 deletions

View file

@ -0,0 +1,58 @@
"use client";
import { useEffect, useMemo } from "react";
import { useSearchParams } from "next/navigation";
const RESULT_STORAGE_KEY = "litellm-mcp-oauth-result";
const RETURN_URL_STORAGE_KEY = "litellm-mcp-oauth-return-url";
const McpOAuthCallbackPage = () => {
const searchParams = useSearchParams();
const payload = useMemo(() => {
if (!searchParams) {
return null;
}
return {
type: "litellm-mcp-oauth",
code: searchParams.get("code"),
state: searchParams.get("state"),
};
}, [searchParams]);
useEffect(() => {
if (!payload || typeof window === "undefined") {
return;
}
try {
window.sessionStorage.setItem(RESULT_STORAGE_KEY, JSON.stringify(payload));
} catch (err) {
console.error("Failed to persist OAuth callback payload", err);
}
const returnUrl = window.sessionStorage.getItem(RETURN_URL_STORAGE_KEY);
console.info("[MCP OAuth callback] returnUrl", returnUrl);
if (returnUrl) {
window.location.replace(returnUrl);
} else {
window.location.replace("/");
}
}, [payload]);
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 p-6">
<div className="max-w-lg w-full rounded-lg bg-white shadow-md p-8 text-center space-y-4">
<h1 className="text-xl font-semibold text-slate-900">LiteLLM MCP OAuth</h1>
<p className="text-sm text-slate-700">
Authorization complete. You may close this window and return to the LiteLLM dashboard.
</p>
<p className="text-xs text-slate-500">
If the window does not close automatically, everything is still savedyou can close it manually.
</p>
</div>
</div>
);
};
export default McpOAuthCallbackPage;

View file

@ -1,5 +1,5 @@
import React, { useState } from "react";
import { Modal, Tooltip, Form, Select } from "antd";
import { Modal, Tooltip, Form, Select, Input } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { createMCPServer } from "../networking";
@ -12,6 +12,7 @@ import MCPPermissionManagement from "./MCPPermissionManagement";
import { isAdminRole } from "@/utils/roles";
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
const asset_logos_folder = "../ui/assets/logos/";
export const mcpLogoImg = `${asset_logos_folder}mcp_logo.png`;
@ -26,6 +27,8 @@ interface CreateMCPServerProps {
}
const AUTH_TYPES_REQUIRING_AUTH_VALUE = [AUTH_TYPE.API_KEY, AUTH_TYPE.BEARER_TOKEN, AUTH_TYPE.BASIC];
const AUTH_TYPES_REQUIRING_CREDENTIALS = [...AUTH_TYPES_REQUIRING_AUTH_VALUE, AUTH_TYPE.OAUTH2];
const CREATE_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-create-state";
const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
userRole,
@ -39,14 +42,88 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const [isLoading, setIsLoading] = useState(false);
const [costConfig, setCostConfig] = useState<MCPServerCostInfo>({});
const [formValues, setFormValues] = useState<Record<string, any>>({});
const [pendingRestoredValues, setPendingRestoredValues] = useState<{ values: Record<string, any>; transport?: string } | null>(null);
const [aliasManuallyEdited, setAliasManuallyEdited] = useState(false);
const [tools, setTools] = useState<any[]>([]);
const [allowedTools, setAllowedTools] = useState<string[]>([]);
const [transportType, setTransportType] = useState<string>("");
const [searchValue, setSearchValue] = useState<string>("");
const [urlWarning, setUrlWarning] = useState<string>("");
const [oauthAccessToken, setOauthAccessToken] = useState<string | null>(null);
const authType = formValues.auth_type as string | undefined;
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
const persistCreateUiState = () => {
if (typeof window === "undefined") {
return;
}
try {
const values = form.getFieldsValue(true);
window.sessionStorage.setItem(
CREATE_OAUTH_UI_STATE_KEY,
JSON.stringify({
modalVisible: isModalVisible,
formValues: values,
transportType,
costConfig,
allowedTools,
searchValue,
aliasManuallyEdited,
}),
);
} catch (err) {
console.warn("Failed to persist MCP create state", err);
}
};
const {
startOAuthFlow,
status: oauthStatus,
error: oauthError,
tokenResponse: oauthTokenResponse,
} = useMcpOAuthFlow({
accessToken,
getCredentials: () => form.getFieldValue("credentials"),
getTemporaryPayload: () => {
const values = form.getFieldsValue(true);
const url = values.url;
const transport = values.transport || transportType;
if (!url || !transport) {
return null;
}
const staticHeaders = Array.isArray(values.static_headers)
? values.static_headers.reduce((acc: Record<string, string>, entry: Record<string, string>) => {
const header = entry?.header?.trim();
if (!header) {
return acc;
}
acc[header] = entry?.value ?? "";
return acc;
}, {})
: ({} as Record<string, string>);
return {
server_id: undefined,
server_name: values.server_name,
alias: values.alias,
description: values.description,
url,
transport,
auth_type: AUTH_TYPE.OAUTH2,
credentials: values.credentials,
mcp_access_groups: values.mcp_access_groups,
static_headers: staticHeaders,
command: values.command,
args: values.args,
env: values.env,
};
},
onTokenReceived: (token) => {
setOauthAccessToken(token?.access_token ?? null);
},
onBeforeRedirect: persistCreateUiState,
});
// Function to check URL format based on transport type
const checkUrlFormat = (url: string, transport: string) => {
@ -64,6 +141,63 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
}
};
React.useEffect(() => {
if (typeof window === "undefined") {
return;
}
const storedState = window.sessionStorage.getItem(CREATE_OAUTH_UI_STATE_KEY);
if (!storedState) {
return;
}
try {
const parsed = JSON.parse(storedState);
if (parsed.modalVisible) {
setModalVisible(true);
}
const restoredTransport = parsed.formValues?.transport || parsed.transportType || "";
if (restoredTransport) {
setTransportType(restoredTransport);
}
if (parsed.formValues) {
setPendingRestoredValues({ values: parsed.formValues, transport: restoredTransport });
}
if (parsed.costConfig) {
setCostConfig(parsed.costConfig);
}
if (parsed.allowedTools) {
setAllowedTools(parsed.allowedTools);
}
if (parsed.searchValue) {
setSearchValue(parsed.searchValue);
}
if (typeof parsed.aliasManuallyEdited === "boolean") {
setAliasManuallyEdited(parsed.aliasManuallyEdited);
}
} catch (err) {
console.error("Failed to restore MCP create state", err);
} finally {
window.sessionStorage.removeItem(CREATE_OAUTH_UI_STATE_KEY);
}
}, [form, setModalVisible]);
React.useEffect(() => {
if (!pendingRestoredValues) {
return;
}
const transportReady = transportType || pendingRestoredValues.transport || "";
if (pendingRestoredValues.transport && !transportType) {
// wait until transportType state catches up so the URL field is mounted
return;
}
form.setFieldsValue(pendingRestoredValues.values);
setFormValues(pendingRestoredValues.values);
if (pendingRestoredValues.values.url && transportReady) {
checkUrlFormat(pendingRestoredValues.values.url, transportReady);
}
setPendingRestoredValues(null);
}, [pendingRestoredValues, form, transportType]);
const handleCreate = async (values: Record<string, any>) => {
setIsLoading(true);
try {
@ -165,7 +299,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
};
payload.static_headers = staticHeaders;
const includeCredentials = restValues.auth_type && AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(restValues.auth_type);
const includeCredentials = restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type);
if (includeCredentials && credentialsPayload && Object.keys(credentialsPayload).length > 0) {
payload.credentials = credentialsPayload;
@ -208,7 +342,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
setTransportType(value);
// Clear fields that are not relevant for the selected transport
if (value === "stdio") {
form.setFieldsValue({ url: undefined, auth_type: undefined });
form.setFieldsValue({ url: undefined, auth_type: undefined, credentials: undefined });
setUrlWarning("");
} else {
form.setFieldsValue({ command: undefined, args: undefined, env: undefined });
@ -403,10 +537,15 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
]}
>
<div>
<TextInput
<Input
value={form.getFieldValue("url") ?? ""}
onChange={(e) => {
const value = e.target.value;
checkUrlFormat(value, transportType);
form.setFieldValue("url", value);
}}
placeholder="https://your-mcp-server.com"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
onChange={(e) => checkUrlFormat(e.target.value, transportType)}
/>
{urlWarning && <div className="mt-1 text-red-500 text-sm font-medium">{urlWarning}</div>}
</div>
@ -425,6 +564,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
<Select.Option value="api_key">API Key</Select.Option>
<Select.Option value="bearer_token">Bearer Token</Select.Option>
<Select.Option value="basic">Basic Auth</Select.Option>
<Select.Option value="oauth2">OAuth</Select.Option>
</Select>
</Form.Item>
)}
@ -450,6 +590,86 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
</Form.Item>
)}
{transportType !== "stdio" && isOAuthAuthType && (
<>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
OAuth Client ID (optional)
<Tooltip title="Provide only if your MCP server cannot handle dynamic client registration.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "client_id"]}
>
<TextInput
type="password"
placeholder="Enter OAuth client ID"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
OAuth Client Secret (optional)
<Tooltip title="Provide only if your MCP server cannot handle dynamic client registration.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "client_secret"]}
>
<TextInput
type="password"
placeholder="Enter OAuth client secret"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
OAuth Scopes (optional)
<Tooltip title="Optional scopes requested during token exchange. Separate multiple scopes with enter or commas.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "scopes"]}
>
<Select
mode="tags"
tokenSeparators={[","]}
placeholder="Add scopes"
className="rounded-lg"
size="large"
/>
</Form.Item>
<div className="rounded-lg border border-dashed border-gray-300 p-4 space-y-2">
<p className="text-sm text-gray-600">
Complete the OAuth authorization flow to fetch an access token and store it as the authentication value.
</p>
<Button
variant="secondary"
onClick={startOAuthFlow}
disabled={oauthStatus === "authorizing" || oauthStatus === "exchanging"}
>
{oauthStatus === "authorizing"
? "Waiting for authorization..."
: oauthStatus === "exchanging"
? "Exchanging authorization code..."
: "Authorize & Fetch Token"}
</Button>
{oauthError && <p className="text-sm text-red-500">{oauthError}</p>}
{oauthStatus === "success" && oauthTokenResponse?.access_token && (
<p className="text-sm text-green-600">
Token fetched. Expires in {oauthTokenResponse.expires_in ?? "?"} seconds.
</p>
)}
</div>
</>
)}
{/* Stdio Configuration - only show for stdio transport */}
<StdioConfiguration isVisible={transportType === "stdio"} />
</div>
@ -467,13 +687,19 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
{/* Connection Status Section */}
<div className="mt-8 pt-6 border-t border-gray-200">
<MCPConnectionStatus accessToken={accessToken} formValues={formValues} onToolsLoaded={setTools} />
<MCPConnectionStatus
accessToken={accessToken}
oauthAccessToken={oauthAccessToken}
formValues={formValues}
onToolsLoaded={setTools}
/>
</div>
{/* Tool Configuration Section */}
<div className="mt-6">
<MCPToolConfiguration
accessToken={accessToken}
oauthAccessToken={oauthAccessToken}
formValues={formValues}
allowedTools={allowedTools}
existingAllowedTools={null}

View file

@ -6,14 +6,16 @@ import { useTestMCPConnection } from "../../hooks/useTestMCPConnection";
interface MCPConnectionStatusProps {
accessToken: string | null;
oauthAccessToken?: string | null;
formValues: Record<string, any>;
onToolsLoaded?: (tools: any[]) => void;
}
const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({ accessToken, formValues, onToolsLoaded }) => {
const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({ accessToken, oauthAccessToken, formValues, onToolsLoaded }) => {
const { tools, isLoadingTools, toolsError, canFetchTools, fetchTools } = useTestMCPConnection({
accessToken,
formValues,
oauthAccessToken,
formValues,
enabled: true, // Auto-fetch when required fields are available
});

View file

@ -9,6 +9,7 @@ import MCPPermissionManagement from "./MCPPermissionManagement";
import MCPToolConfiguration from "./mcp_tool_configuration";
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
interface MCPServerEditProps {
mcpServer: MCPServer;
@ -19,6 +20,8 @@ interface MCPServerEditProps {
}
const AUTH_TYPES_REQUIRING_AUTH_VALUE = [AUTH_TYPE.API_KEY, AUTH_TYPE.BEARER_TOKEN, AUTH_TYPE.BASIC];
const AUTH_TYPES_REQUIRING_CREDENTIALS = [...AUTH_TYPES_REQUIRING_AUTH_VALUE, AUTH_TYPE.OAUTH2];
const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
const MCPServerEdit: React.FC<MCPServerEditProps> = ({
mcpServer,
@ -34,8 +37,82 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
const [searchValue, setSearchValue] = useState<string>("");
const [aliasManuallyEdited, setAliasManuallyEdited] = useState(false);
const [allowedTools, setAllowedTools] = useState<string[]>([]);
const [pendingRestoredValues, setPendingRestoredValues] = useState<Record<string, any> | null>(null);
const authType = Form.useWatch("auth_type", form) as string | undefined;
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
const [oauthAccessToken, setOauthAccessToken] = useState<string | null>(null);
const persistEditUiState = () => {
if (typeof window === "undefined") {
return;
}
try {
const values = form.getFieldsValue(true);
window.sessionStorage.setItem(
EDIT_OAUTH_UI_STATE_KEY,
JSON.stringify({
serverId: mcpServer.server_id,
formValues: values,
costConfig,
allowedTools,
searchValue,
aliasManuallyEdited,
}),
);
} catch (err) {
console.warn("Failed to persist MCP edit state", err);
}
};
const {
startOAuthFlow,
status: oauthStatus,
error: oauthError,
tokenResponse: oauthTokenResponse,
} = useMcpOAuthFlow({
accessToken,
getCredentials: () => form.getFieldValue("credentials"),
getTemporaryPayload: () => {
const values = form.getFieldsValue(true);
const url = values.url || mcpServer.url;
const transport = values.transport || mcpServer.transport;
if (!url || !transport) {
return null;
}
const staticHeaders = Array.isArray(values.static_headers)
? values.static_headers.reduce((acc: Record<string, string>, entry: Record<string, string>) => {
const header = entry?.header?.trim();
if (!header) {
return acc;
}
acc[header] = entry?.value ?? "";
return acc;
}, {})
: ({} as Record<string, string>);
return {
server_id: mcpServer.server_id,
server_name: values.server_name || mcpServer.server_name || mcpServer.alias,
alias: values.alias || mcpServer.alias,
description: values.description || mcpServer.description,
url,
transport,
auth_type: AUTH_TYPE.OAUTH2,
credentials: values.credentials,
mcp_access_groups: values.mcp_access_groups || mcpServer.mcp_access_groups,
static_headers: staticHeaders,
command: values.command,
args: values.args,
env: values.env,
};
},
onTokenReceived: (token) => {
setOauthAccessToken(token?.access_token ?? null);
},
onBeforeRedirect: persistEditUiState,
});
const initialStaticHeaders = React.useMemo(() => {
if (!mcpServer.static_headers) {
@ -69,6 +146,55 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
}
}, [mcpServer]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const storedState = window.sessionStorage.getItem(EDIT_OAUTH_UI_STATE_KEY);
if (!storedState) {
return;
}
try {
const parsed = JSON.parse(storedState);
if (!parsed || parsed.serverId !== mcpServer.server_id) {
return;
}
if (parsed.formValues) {
setPendingRestoredValues({ ...mcpServer, ...parsed.formValues });
}
if (parsed.costConfig) {
setCostConfig(parsed.costConfig);
}
if (parsed.allowedTools) {
setAllowedTools(parsed.allowedTools);
}
if (parsed.searchValue) {
setSearchValue(parsed.searchValue);
}
if (typeof parsed.aliasManuallyEdited === "boolean") {
setAliasManuallyEdited(parsed.aliasManuallyEdited);
}
} catch (err) {
console.error("Failed to restore MCP edit state", err);
} finally {
window.sessionStorage.removeItem(EDIT_OAUTH_UI_STATE_KEY);
}
}, [form, mcpServer]);
useEffect(() => {
if (!pendingRestoredValues) {
return;
}
const transport = pendingRestoredValues.transport || mcpServer.transport;
if (transport && transport !== form.getFieldValue("transport")) {
form.setFieldsValue({ transport });
return;
}
form.setFieldsValue(pendingRestoredValues);
setPendingRestoredValues(null);
}, [pendingRestoredValues, form, mcpServer.transport]);
// Transform string array to object array for initial form values
useEffect(() => {
if (mcpServer.mcp_access_groups) {
@ -81,13 +207,17 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
// Fetch tools when component mounts
useEffect(() => {
fetchTools();
}, [mcpServer, accessToken]);
}, [mcpServer, accessToken, oauthAccessToken]);
const fetchTools = async () => {
if (!accessToken || !mcpServer.url) {
return;
}
if (mcpServer.auth_type === AUTH_TYPE.OAUTH2 && !oauthAccessToken) {
return;
}
setIsLoadingTools(true);
try {
@ -101,7 +231,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
mcp_info: mcpServer.mcp_info,
};
const toolsResponse = await testMCPToolsListRequest(accessToken, mcpServerConfig);
const toolsResponse = await testMCPToolsListRequest(accessToken, mcpServerConfig, oauthAccessToken);
if (toolsResponse.tools && !toolsResponse.error) {
setTools(toolsResponse.tools);
@ -208,7 +338,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
static_headers: staticHeaders,
};
const includeCredentials = restValues.auth_type && AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(restValues.auth_type);
const includeCredentials = restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type);
if (includeCredentials && credentialsPayload && Object.keys(credentialsPayload).length > 0) {
payload.credentials = credentialsPayload;
@ -278,6 +408,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
<Select.Option value="api_key">API Key</Select.Option>
<Select.Option value="bearer_token">Bearer Token</Select.Option>
<Select.Option value="basic">Basic Auth</Select.Option>
<Select.Option value="oauth2">OAuth</Select.Option>
</Select>
</Form.Item>
@ -309,6 +440,84 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
</Form.Item>
)}
{isOAuthAuthType && (
<>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
OAuth Client ID (optional)
<Tooltip title="Provide only if your MCP server cannot handle dynamic client registration.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "client_id"]}
>
<TextInput
type="password"
placeholder="Enter OAuth client ID (leave blank to keep existing)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
OAuth Client Secret (optional)
<Tooltip title="Provide only if your MCP server cannot handle dynamic client registration.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "client_secret"]}
>
<TextInput
type="password"
placeholder="Enter OAuth client secret (leave blank to keep existing)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
OAuth Scopes (optional)
<Tooltip title="Add scopes to override the default scope list used for this MCP server.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "scopes"]}
>
<Select
mode="tags"
tokenSeparators={[","]}
placeholder="Add scopes"
className="rounded-lg"
size="large"
/>
</Form.Item>
<div className="rounded-lg border border-dashed border-gray-300 p-4 space-y-2">
<p className="text-sm text-gray-600">Use OAuth to fetch a fresh access token and save it as the authentication value.</p>
<Button
variant="secondary"
onClick={startOAuthFlow}
disabled={oauthStatus === "authorizing" || oauthStatus === "exchanging"}
>
{oauthStatus === "authorizing"
? "Waiting for authorization..."
: oauthStatus === "exchanging"
? "Exchanging authorization code..."
: "Authorize & Fetch Token"}
</Button>
{oauthError && <p className="text-sm text-red-500">{oauthError}</p>}
{oauthStatus === "success" && oauthTokenResponse?.access_token && (
<p className="text-sm text-green-600">
Token fetched. Expires in {oauthTokenResponse.expires_in ?? "?"} seconds.
</p>
)}
</div>
</>
)}
{/* Permission Management / Access Control Section */}
<div className="mt-6">
<MCPPermissionManagement
@ -324,6 +533,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
<div className="mt-6">
<MCPToolConfiguration
accessToken={accessToken}
oauthAccessToken={oauthAccessToken}
formValues={{
server_id: mcpServer.server_id,
server_name: mcpServer.server_name,

View file

@ -14,6 +14,8 @@ import { MCPServerView } from "./mcp_server_view";
import { MCPServer, MCPServerProps, Team } from "./types";
const { Text: AntdText, Title: AntdTitle } = Typography;
const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
const { Option } = Select;
const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID }) => {
@ -54,6 +56,25 @@ const MCPServers: React.FC<MCPServerProps> = ({ 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 [];

View file

@ -6,6 +6,7 @@ import { useTestMCPConnection } from "../../hooks/useTestMCPConnection";
interface MCPToolConfigurationProps {
accessToken: string | null;
oauthAccessToken?: string | null;
formValues: Record<string, any>;
allowedTools: string[];
existingAllowedTools: string[] | null;
@ -14,6 +15,7 @@ interface MCPToolConfigurationProps {
const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
accessToken,
oauthAccessToken,
formValues,
allowedTools,
existingAllowedTools,
@ -23,6 +25,7 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
const { tools, isLoadingTools, toolsError, canFetchTools } = useTestMCPConnection({
accessToken,
oauthAccessToken,
formValues,
enabled: true,
});

View file

@ -10,6 +10,7 @@ export const AUTH_TYPE = {
API_KEY: "api_key",
BEARER_TOKEN: "bearer_token",
BASIC: "basic",
OAUTH2: "oauth2",
};
export const TRANSPORT = {

View file

@ -7296,19 +7296,32 @@ export const testMCPConnectionRequest = async (accessToken: string, mcpServerCon
}
};
export const testMCPToolsListRequest = async (accessToken: string, mcpServerConfig: Record<string, any>) => {
export const testMCPToolsListRequest = async (
accessToken: string | null,
mcpServerConfig: Record<string, any>,
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<string, string> = {
"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<string, any>) => {
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,

View file

@ -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<string, any> | null;
onTokenReceived: (tokenResponse: Record<string, any>) => void;
onBeforeRedirect?: () => void;
}
interface UseMcpOAuthFlowResult {
startOAuthFlow: () => Promise<void>;
status: McpOAuthStatus;
error: string | null;
tokenResponse: Record<string, any> | 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<McpOAuthStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [tokenResponse, setTokenResponse] = useState<Record<string, any> | 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<string, any> | 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,
};
};

View file

@ -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<string, any>;
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,