mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
feat: add UI support for registering MCP OAuth2 auth_type (#17007)
This commit is contained in:
parent
5b0729034c
commit
f37d6480ec
10 changed files with 991 additions and 18 deletions
58
ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx
Normal file
58
ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx
Normal 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 saved—you can close it manually.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default McpOAuthCallbackPage;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const AUTH_TYPE = {
|
|||
API_KEY: "api_key",
|
||||
BEARER_TOKEN: "bearer_token",
|
||||
BASIC: "basic",
|
||||
OAUTH2: "oauth2",
|
||||
};
|
||||
|
||||
export const TRANSPORT = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
290
ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx
Normal file
290
ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue