diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 48ea3d384a9..0823925f505 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -618,7 +618,11 @@ class MCPServerManager: mcp_info["description"] = mcp_server.description auth_type = cast(MCPAuthType, mcp_server.auth_type) - if mcp_server.url and auth_type == MCPAuth.oauth2: + if ( + mcp_server.url + and auth_type == MCPAuth.oauth2 + and not mcp_server.authorization_url + ): mcp_oauth_metadata = await self._descovery_metadata( server_url=mcp_server.url, ) diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx index acad72cb21c..85487a8a479 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx @@ -16,6 +16,8 @@ interface OAuthFormFieldsProps { isEditing?: boolean; oauthFlow?: OAuthFlowStatus; initialFlowType?: string; + /** Link to provider docs for creating an OAuth app (e.g. GitHub). */ + docsUrl?: string | null; } const fieldClassName = "rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"; @@ -34,6 +36,7 @@ const OAuthFormFields: React.FC = ({ isEditing = false, oauthFlow, initialFlowType, + docsUrl, }) => { const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : ""; @@ -98,7 +101,22 @@ const OAuthFormFields: React.FC = ({ ) : ( <> } + label={ + + + {docsUrl && ( + e.stopPropagation()} + > + Create OAuth App → + + )} + + } name={["credentials", "client_id"]} > diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx b/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx index 68412ea21bc..23aae6cb14f 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { Form, Input, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { FormInstance } from "antd/es/form"; -import { AUTH_TYPE } from "./types"; +import { AUTH_TYPE, OAUTH_FLOW } from "./types"; import OpenAPIQuickPicker, { OpenAPIRegistryEntry, OpenAPIKeyTool } from "./OpenAPIQuickPicker"; interface OpenAPIFormSectionProps { @@ -12,6 +12,8 @@ interface OpenAPIFormSectionProps { onValuesChange: (updates: Record) => void; /** Called when key tools change (from registry preset selection). */ onKeyToolsChange?: (tools: OpenAPIKeyTool[]) => void; + /** Called when the OAuth docs URL changes (e.g. link to create a GitHub OAuth App). */ + onOAuthDocsUrlChange?: (url: string | null) => void; } /** @@ -24,6 +26,7 @@ const OpenAPIFormSection: React.FC = ({ accessToken, onValuesChange, onKeyToolsChange, + onOAuthDocsUrlChange, }) => { const [selectedPreset, setSelectedPreset] = useState(null); @@ -35,14 +38,19 @@ const OpenAPIFormSection: React.FC = ({ }; if (entry.oauth) { updates.auth_type = AUTH_TYPE.OAUTH2; + // OAuth2 registry entries always use the interactive (PKCE) flow — users + // authorize via their browser, not machine-to-machine client credentials. + updates.oauth_flow_type = OAUTH_FLOW.INTERACTIVE; updates.authorization_url = entry.oauth.authorization_url; updates.token_url = entry.oauth.token_url; form.setFieldsValue(updates); + onOAuthDocsUrlChange?.(entry.oauth.docs_url ?? null); } else { // resetFields is required to visually clear Ant Design form fields — // setFieldsValue with undefined silently skips undefined keys. form.resetFields(["auth_type", "authorization_url", "token_url"]); form.setFieldsValue(updates); + onOAuthDocsUrlChange?.(null); } onValuesChange(updates); }; @@ -75,6 +83,7 @@ const OpenAPIFormSection: React.FC = ({ // so stale suggested tools from a previous preset don't persist. setSelectedPreset(null); onKeyToolsChange?.([]); + onOAuthDocsUrlChange?.(null); }} /> diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index de60ff2b782..48a834318e0 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { Modal, Tooltip, Form, Select, Input, Switch } from "antd"; +import { Modal, Tooltip, Form, Select, Input, Switch, Collapse } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Button, TextInput } from "@tremor/react"; import { createMCPServer, registerMCPServer } from "../networking"; @@ -61,6 +61,7 @@ const CreateMCPServer: React.FC = ({ const [keyTools, setKeyTools] = useState([]); const [searchValue, setSearchValue] = useState(""); const [oauthAccessToken, setOauthAccessToken] = useState(null); + const [oauthDocsUrl, setOauthDocsUrl] = useState(null); // Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests. const { tools, isLoadingTools, toolsError, toolsErrorStackTrace, canFetchTools, fetchTools, clearTools } = useTestMCPConnection({ @@ -108,8 +109,12 @@ const CreateMCPServer: React.FC = ({ getCredentials: () => form.getFieldValue("credentials"), getTemporaryPayload: () => { const values = form.getFieldsValue(true); - const url = values.url; const transport = values.transport || transportType; + // For OpenAPI transport the form has spec_path instead of url. + // We pass the spec_path as url so the temp-session endpoint has something + // to store; the backend uses authorization_url / token_url for the actual + // OAuth redirect, so the spec_path value is never used for OAuth itself. + const url = values.url || (transport === TRANSPORT.OPENAPI ? values.spec_path : undefined); if (!url || !transport) { return null; } @@ -130,7 +135,7 @@ const CreateMCPServer: React.FC = ({ alias: values.alias, description: values.description, url, - transport, + transport: transport === TRANSPORT.OPENAPI ? "http" : transport, auth_type: AUTH_TYPE.OAUTH2, credentials: values.credentials, authorization_url: values.authorization_url, @@ -415,7 +420,7 @@ const CreateMCPServer: React.FC = ({ const handleCancel = () => { form.resetFields(); setCostConfig({}); - setTools([]); + clearTools(); setAllowedTools([]); setAliasManuallyEdited(false); setModalVisible(false); @@ -649,6 +654,7 @@ const CreateMCPServer: React.FC = ({ setFormValues((prev) => ({ ...prev, ...updates })) } onKeyToolsChange={setKeyTools} + onOAuthDocsUrlChange={setOauthDocsUrl} /> )} @@ -738,60 +744,74 @@ const CreateMCPServer: React.FC = ({ {/* Authentication - show for HTTP, SSE, and OpenAPI */} {transportType !== "stdio" && transportType !== "" && ( - Authentication} - name="auth_type" - rules={[{ required: true, message: "Please select an auth type" }]} - > - - - )} - - {transportType !== "stdio" && transportType !== "" && shouldShowAuthValueField && ( - - Authentication Value - - - - - } - name={["credentials", "auth_value"]} - rules={[ + - value && typeof value === "string" && value.trim() === "" - ? Promise.reject(new Error("Authentication value cannot be empty whitespace")) - : Promise.resolve(), + key: "auth", + label: Authentication, + children: ( + <> + + + + + {shouldShowAuthValueField && ( + + Authentication Value + + + + + } + name={["credentials", "auth_value"]} + rules={[ + { + validator: (_, value) => + value && typeof value === "string" && value.trim() === "" + ? Promise.reject(new Error("Authentication value cannot be empty whitespace")) + : Promise.resolve(), + }, + ]} + > + + + )} + + {isOAuthAuthType && ( + + )} + + ), }, ]} - > - - - )} - - {transportType !== "stdio" && transportType !== "" && isOAuthAuthType && ( - )} diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index fbde083c2da..2176ef584a4 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -8946,11 +8946,16 @@ export const perUserAnalyticsCall = async ( }; export const deriveErrorMessage = (errorData: any): string => { + const detail = errorData?.detail; + const detailStr = Array.isArray(detail) + ? detail.map((d: any) => d?.msg || JSON.stringify(d)).join("; ") + : typeof detail === "string" + ? detail + : undefined; return ( - (errorData?.error && (errorData.error.message || errorData.error)) || + (errorData?.error && (errorData.error.message || (typeof errorData.error === "string" ? errorData.error : undefined))) || errorData?.message || - errorData?.detail || - errorData?.error || + detailStr || JSON.stringify(errorData) ); }; @@ -9556,3 +9561,95 @@ export const deleteToolPolicyOverride = async ( } return response.json(); }; + +// ── MCP OAuth user-credential helpers ──────────────────────────────────────── + +export interface MCPOAuthUserCredentialStatus { + server_id: string; + has_credential: boolean; + expires_at?: string | null; + is_expired: boolean; + connected_at?: string | null; +} + +export interface MCPUserCredentialListItem { + server_id: string; + server_name?: string | null; + alias?: string | null; + credential_type: string; + has_credential: boolean; + expires_at?: string | null; + connected_at?: string | null; +} + +export const storeMCPOAuthUserCredential = async ( + accessToken: string, + serverId: string, + tokenResponse: { access_token: string; refresh_token?: string; expires_in?: number; scopes?: string[] }, +): Promise => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/server/${serverId}/oauth-user-credential` + : `/v1/mcp/server/${serverId}/oauth-user-credential`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(tokenResponse), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error((err as { detail?: { error?: string } })?.detail?.error || "Failed to store OAuth credential"); + } + return response.json(); +}; + +export const deleteMCPOAuthUserCredential = async ( + accessToken: string, + serverId: string, +): Promise => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/server/${serverId}/oauth-user-credential` + : `/v1/mcp/server/${serverId}/oauth-user-credential`; + const response = await fetch(url, { + method: "DELETE", + headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error((err as { detail?: { error?: string } })?.detail?.error || "Failed to revoke OAuth credential"); + } + return response.json(); +}; + +export const getMCPOAuthUserCredentialStatus = async ( + accessToken: string, + serverId: string, +): Promise => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/server/${serverId}/oauth-user-credential/status` + : `/v1/mcp/server/${serverId}/oauth-user-credential/status`; + const response = await fetch(url, { + method: "GET", + headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + return { server_id: serverId, has_credential: false, is_expired: false }; + } + return response.json(); +}; + +export const listMCPUserCredentials = async ( + accessToken: string, +): Promise => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/user-credentials` + : `/v1/mcp/user-credentials`; + const response = await fetch(url, { + method: "GET", + headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` }, + }); + if (!response.ok) return []; + return response.json(); +}; diff --git a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx index f914e42f043..97d48510c2e 100644 --- a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx +++ b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx @@ -13,6 +13,17 @@ import { export type McpOAuthStatus = "idle" | "authorizing" | "exchanging" | "success" | "error"; +function extractErrorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (err && typeof err === "object") { + const e = err as Record; + if (typeof e.detail === "string") return e.detail; + if (typeof e.message === "string") return e.message; + return JSON.stringify(err); + } + return String(err); +} + interface UseMcpOAuthFlowOptions { accessToken: string | null; getCredentials: () => { @@ -223,7 +234,7 @@ export const useMcpOAuthFlow = ({ } catch (err) { console.error("Failed to start OAuth flow", err); setStatus("error"); - const message = err instanceof Error ? err.message : String(err); + const message = extractErrorMessage(err); setError(message); NotificationsManager.error(message); } @@ -310,7 +321,7 @@ export const useMcpOAuthFlow = ({ setError(null); NotificationsManager.success("OAuth token retrieved successfully"); } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = extractErrorMessage(err); setError(message); setStatus("error"); NotificationsManager.error(message);