mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
fix(mcp): fix OpenAPI OAuth flow — transport mapping, error messages, and discovery bypass
Three bugs fixed to make the end-to-end OAuth flow work for OpenAPI MCP servers:
1. **Transport mapping in getTemporaryPayload**: `TRANSPORT.OPENAPI` is a UI-only concept;
the backend only accepts `"http"`, `"sse"`, or `"stdio"`. The pre-OAuth temp-session
call was sending `transport: "openapi"` and getting a 422. Fixed by mapping to `"http"`.
2. **deriveErrorMessage handles FastAPI 422 arrays**: FastAPI validation errors return
`detail` as an array of `{loc, msg, type}` objects. The shared error extractor was
returning the array directly, causing `Error: [object Object]`. Fixed to map each
item to its `.msg` field.
3. **Skip OAuth discovery when authorization_url already provided**: `build_mcp_server_from_table`
was unconditionally calling `_descovery_metadata(server_url)` for OAuth servers. For
OpenAPI servers the url is the spec JSON file, not the API base — this caused a timeout
fetching e.g. the GitHub spec (2 MB). Fixed by skipping discovery when `authorization_url`
is already set.
Also: collapsible auth section in MCP server form, "Create OAuth App →" link next to
Client ID when a docs URL is available (e.g. GitHub OAuth App creation page), and
`extractErrorMessage` helper in `useMcpOAuthFlow` for cleaner error display.
This commit is contained in:
parent
3bf91ed9fe
commit
7c804fc8a7
6 changed files with 222 additions and 63 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<OAuthFormFieldsProps> = ({
|
|||
isEditing = false,
|
||||
oauthFlow,
|
||||
initialFlowType,
|
||||
docsUrl,
|
||||
}) => {
|
||||
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
|
||||
|
||||
|
|
@ -98,7 +101,22 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
label={<FieldLabel label="Client ID (optional)" tooltip="Provide only if your MCP server cannot handle dynamic client registration." />}
|
||||
label={
|
||||
<span className="flex items-center justify-between w-full">
|
||||
<FieldLabel label="Client ID (optional)" tooltip="Provide only if your MCP server cannot handle dynamic client registration." />
|
||||
{docsUrl && (
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-500 hover:text-blue-700 ml-2 font-normal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Create OAuth App →
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "client_id"]}
|
||||
>
|
||||
<TextInput type="password" placeholder={`Enter client ID${placeholderSuffix}`} className={fieldClassName} />
|
||||
|
|
|
|||
|
|
@ -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<string, any>) => 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<OpenAPIFormSectionProps> = ({
|
|||
accessToken,
|
||||
onValuesChange,
|
||||
onKeyToolsChange,
|
||||
onOAuthDocsUrlChange,
|
||||
}) => {
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -35,14 +38,19 @@ const OpenAPIFormSection: React.FC<OpenAPIFormSectionProps> = ({
|
|||
};
|
||||
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<OpenAPIFormSectionProps> = ({
|
|||
// so stale suggested tools from a previous preset don't persist.
|
||||
setSelectedPreset(null);
|
||||
onKeyToolsChange?.([]);
|
||||
onOAuthDocsUrlChange?.(null);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
|
|
|||
|
|
@ -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<CreateMCPServerProps> = ({
|
|||
const [keyTools, setKeyTools] = useState<OpenAPIKeyTool[]>([]);
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
const [oauthAccessToken, setOauthAccessToken] = useState<string | null>(null);
|
||||
const [oauthDocsUrl, setOauthDocsUrl] = useState<string | null>(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<CreateMCPServerProps> = ({
|
|||
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<CreateMCPServerProps> = ({
|
|||
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<CreateMCPServerProps> = ({
|
|||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
setCostConfig({});
|
||||
setTools([]);
|
||||
clearTools();
|
||||
setAllowedTools([]);
|
||||
setAliasManuallyEdited(false);
|
||||
setModalVisible(false);
|
||||
|
|
@ -649,6 +654,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
setFormValues((prev) => ({ ...prev, ...updates }))
|
||||
}
|
||||
onKeyToolsChange={setKeyTools}
|
||||
onOAuthDocsUrlChange={setOauthDocsUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -738,60 +744,74 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
|
||||
{/* Authentication - show for HTTP, SSE, and OpenAPI */}
|
||||
{transportType !== "stdio" && transportType !== "" && (
|
||||
<Form.Item
|
||||
label={<span className="text-sm font-medium text-gray-700">Authentication</span>}
|
||||
name="auth_type"
|
||||
rules={[{ required: true, message: "Please select an auth type" }]}
|
||||
>
|
||||
<Select placeholder="Select auth type" className="rounded-lg" size="large">
|
||||
<Select.Option value="none">None</Select.Option>
|
||||
<Select.Option value="api_key">API Key</Select.Option>
|
||||
<Select.Option value="bearer_token">Bearer Token</Select.Option>
|
||||
<Select.Option value="token">Token</Select.Option>
|
||||
<Select.Option value="basic">Basic Auth</Select.Option>
|
||||
<Select.Option value="oauth2">OAuth</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{transportType !== "stdio" && transportType !== "" && shouldShowAuthValueField && (
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Authentication Value
|
||||
<Tooltip title="Token, password, or header value to send with each request for the selected auth type.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "auth_value"]}
|
||||
rules={[
|
||||
<Collapse
|
||||
defaultActiveKey={["auth"]}
|
||||
className="mb-4"
|
||||
items={[
|
||||
{
|
||||
validator: (_, value) =>
|
||||
value && typeof value === "string" && value.trim() === ""
|
||||
? Promise.reject(new Error("Authentication value cannot be empty whitespace"))
|
||||
: Promise.resolve(),
|
||||
key: "auth",
|
||||
label: <span className="text-sm font-semibold text-gray-700">Authentication</span>,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item
|
||||
name="auth_type"
|
||||
rules={[{ required: true, message: "Please select an auth type" }]}
|
||||
>
|
||||
<Select placeholder="Select auth type" className="rounded-lg" size="large">
|
||||
<Select.Option value="none">None</Select.Option>
|
||||
<Select.Option value="api_key">API Key</Select.Option>
|
||||
<Select.Option value="bearer_token">Bearer Token</Select.Option>
|
||||
<Select.Option value="token">Token</Select.Option>
|
||||
<Select.Option value="basic">Basic Auth</Select.Option>
|
||||
<Select.Option value="oauth2">OAuth</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{shouldShowAuthValueField && (
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Authentication Value
|
||||
<Tooltip title="Token, password, or header value to send with each request for the selected auth type.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
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(),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextInput
|
||||
type="password"
|
||||
placeholder="Enter token or secret"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{isOAuthAuthType && (
|
||||
<OAuthFormFields
|
||||
isM2M={isM2MFlow}
|
||||
initialFlowType={OAUTH_FLOW.INTERACTIVE}
|
||||
docsUrl={oauthDocsUrl}
|
||||
oauthFlow={{
|
||||
startOAuthFlow,
|
||||
status: oauthStatus,
|
||||
error: oauthError,
|
||||
tokenResponse: oauthTokenResponse,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextInput
|
||||
type="password"
|
||||
placeholder="Enter token or secret"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{transportType !== "stdio" && transportType !== "" && isOAuthAuthType && (
|
||||
<OAuthFormFields
|
||||
isM2M={isM2MFlow}
|
||||
initialFlowType={OAUTH_FLOW.INTERACTIVE}
|
||||
oauthFlow={{
|
||||
startOAuthFlow,
|
||||
status: oauthStatus,
|
||||
error: oauthError,
|
||||
tokenResponse: oauthTokenResponse,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MCPOAuthUserCredentialStatus> => {
|
||||
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<MCPOAuthUserCredentialStatus> => {
|
||||
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<MCPOAuthUserCredentialStatus> => {
|
||||
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<MCPUserCredentialListItem[]> => {
|
||||
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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue