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:
Ishaan Jaffer 2026-03-10 16:40:57 -07:00
parent 3bf91ed9fe
commit 7c804fc8a7
6 changed files with 222 additions and 63 deletions

View file

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

View file

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

View file

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

View file

@ -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,
}}
/>
)}

View file

@ -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();
};

View file

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