fix: MCP OAuth 2.0 UX gaps across admin and internal user flows

Fixes found by dogfooding the MCP OAuth 2.0 flow as both an admin and
an internal user:

1. Fix [object Object] error when creating MCP server
   - Use parseErrorMessage() in create_mcp_server.tsx catch handler
   - Fix deriveErrorMessage() in networking.tsx to always return a string
     instead of potentially returning an object

2. Pre-configured OAuth servers auto-select auth type
   - Add auth_type field to DiscoverableMCPServer type
   - Add auth_type='oauth2' to Atlassian and Linear in mcp_registry.json
   - Pre-fill auth_type in create form when selecting from catalog
   - Use requestAnimationFrame to defer setting after transport dropdown mounts

3. Backend: drop tool_choice when MCP resolves no tools
   - In chat_completions_handler.py, remove tool_choice from kwargs when
     all_tools is empty after MCP resolution, preventing 400 errors from
     providers that reject tool_choice without tools

4. Chat UI: OAuth-aware MCP server management
   - MCPAppsPanel: show lock icon for OAuth servers in grid, show
     'Authorization required' banner + 'Authorize' button in detail view,
     and initiate PKCE OAuth flow via existing proxy endpoints
   - MCPConnectPicker: show lock icon next to OAuth server names
   - Store user OAuth tokens in sessionStorage for the session

Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-03-07 03:37:30 +00:00
parent 0e78aa4cf1
commit bc9a48d407
7 changed files with 284 additions and 28 deletions

View file

@ -35,6 +35,7 @@
"registry_url": "https://registry.modelcontextprotocol.io/servers/com.atlassian%2Fatlassian-mcp-server",
"transport": "sse",
"url": "https://mcp.atlassian.com/v1/sse",
"auth_type": "oauth2",
"env_vars": []
},
{
@ -46,6 +47,7 @@
"registry_url": "https://registry.modelcontextprotocol.io/servers/app.linear%2Flinear",
"transport": "sse",
"url": "https://mcp.linear.app/sse",
"auth_type": "oauth2",
"env_vars": []
},
{

View file

@ -160,6 +160,10 @@ async def acompletion_with_mcp( # noqa: PLR0915
# Remove keys that shouldn't be passed to acompletion
clean_kwargs = {k: v for k, v in kwargs.items() if k not in ["acompletion"]}
# If no tools were resolved, also drop tool_choice to avoid provider errors
if not all_tools:
clean_kwargs.pop("tool_choice", None)
base_call_args = {
"model": model,
"messages": messages,

View file

@ -1,10 +1,10 @@
"use client";
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useCallback } from "react";
import { Switch, Spin, Input, Button } from "antd";
import { SearchOutlined, ArrowLeftOutlined, RightOutlined } from "@ant-design/icons";
import { fetchMCPServers, listMCPTools } from "../networking";
import { MCPServer } from "../mcp_tools/types";
import { SearchOutlined, ArrowLeftOutlined, RightOutlined, LockOutlined, CheckCircleOutlined } from "@ant-design/icons";
import { fetchMCPServers, listMCPTools, registerMcpOAuthClient, buildMcpOAuthAuthorizeUrl, exchangeMcpOAuthToken, cacheTemporaryMcpServer } from "../networking";
import { MCPServer, AUTH_TYPE } from "../mcp_tools/types";
import { message } from "antd";
interface Props {
@ -24,6 +24,38 @@ function getAvatarColor(name: string): string {
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
}
const OAUTH_TOKEN_STORAGE_PREFIX = "litellm-mcp-user-oauth-token-";
const OAUTH_FLOW_STATE_KEY = "litellm-mcp-chat-oauth-flow-state";
const OAUTH_RESULT_KEY = "litellm-mcp-oauth-result";
const OAUTH_RETURN_URL_KEY = "litellm-mcp-oauth-return-url";
const CHAT_OAUTH_SELECTED_SERVERS_KEY = "litellm-mcp-chat-selected-servers";
function getStoredOAuthToken(serverId: string): string | null {
try {
const stored = sessionStorage.getItem(OAUTH_TOKEN_STORAGE_PREFIX + serverId) ||
localStorage.getItem(OAUTH_TOKEN_STORAGE_PREFIX + serverId);
return stored || null;
} catch { return null; }
}
function storeOAuthToken(serverId: string, token: string) {
try {
sessionStorage.setItem(OAUTH_TOKEN_STORAGE_PREFIX + serverId, token);
localStorage.setItem(OAUTH_TOKEN_STORAGE_PREFIX + serverId, token);
} catch { /* ignore */ }
}
function isOAuthServer(server: MCPServer): boolean {
return server.auth_type === AUTH_TYPE.OAUTH2 || server.auth_type === "oauth2";
}
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(/=+$/, "");
};
type TabKey = "all" | "connected";
const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange }) => {
@ -33,6 +65,8 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
const [activeTab, setActiveTab] = useState<TabKey>("all");
const [togglingOn, setTogglingOn] = useState<Set<string>>(new Set());
const [detailServer, setDetailServer] = useState<MCPServer | null>(null);
const [oauthAuthorizing, setOauthAuthorizing] = useState(false);
const [oauthTokens, setOauthTokens] = useState<Record<string, string>>({});
useEffect(() => {
let cancelled = false;
@ -42,6 +76,14 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
if (cancelled) return;
const list: MCPServer[] = Array.isArray(data) ? data : (data?.data ?? []);
setServers(list);
const tokens: Record<string, string> = {};
list.forEach((s) => {
if (isOAuthServer(s)) {
const t = getStoredOAuthToken(s.server_id);
if (t) tokens[s.server_id] = t;
}
});
setOauthTokens(tokens);
})
.catch(() => {
if (!cancelled) setServers([]);
@ -52,6 +94,147 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
return () => { cancelled = true; };
}, [accessToken]);
// Resume OAuth flow after redirect
useEffect(() => {
if (typeof window === "undefined") return;
const storedResult = sessionStorage.getItem(OAUTH_RESULT_KEY) || localStorage.getItem(OAUTH_RESULT_KEY);
const storedFlowState = sessionStorage.getItem(OAUTH_FLOW_STATE_KEY) || localStorage.getItem(OAUTH_FLOW_STATE_KEY);
if (!storedResult || !storedFlowState) return;
const resumeOAuth = async () => {
try {
const result = JSON.parse(storedResult);
const flowState = JSON.parse(storedFlowState);
// Clean up storage
[sessionStorage, localStorage].forEach((s) => {
try {
s.removeItem(OAUTH_RESULT_KEY);
s.removeItem(OAUTH_FLOW_STATE_KEY);
s.removeItem(OAUTH_RETURN_URL_KEY);
} catch { /* ignore */ }
});
if (result.error) {
message.error(result.error_description || result.error);
return;
}
if (!result.code || !flowState.state || result.state !== flowState.state) {
message.error("OAuth state mismatch. Please try again.");
return;
}
const token = await exchangeMcpOAuthToken({
serverId: flowState.serverId,
code: result.code,
clientId: flowState.clientId,
clientSecret: flowState.clientSecret,
codeVerifier: flowState.codeVerifier,
redirectUri: flowState.redirectUri,
});
if (token?.access_token) {
storeOAuthToken(flowState.originalServerId || flowState.serverId, token.access_token);
setOauthTokens((prev) => ({ ...prev, [flowState.originalServerId || flowState.serverId]: token.access_token }));
message.success("Connected successfully!");
// Restore selected servers and auto-connect
try {
const savedServers = sessionStorage.getItem(CHAT_OAUTH_SELECTED_SERVERS_KEY);
if (savedServers) {
const parsed = JSON.parse(savedServers);
sessionStorage.removeItem(CHAT_OAUTH_SELECTED_SERVERS_KEY);
if (Array.isArray(parsed)) {
onChange(parsed);
}
}
} catch { /* ignore */ }
}
} catch (err) {
message.error("Failed to complete OAuth authorization.");
}
};
resumeOAuth();
}, [accessToken, onChange]);
const startOAuthForServer = useCallback(async (server: MCPServer) => {
if (!accessToken) return;
setOauthAuthorizing(true);
try {
const serverPayload = {
server_id: server.server_id,
server_name: server.server_name,
alias: server.alias,
description: server.description,
url: server.url,
transport: server.transport || "http",
auth_type: AUTH_TYPE.OAUTH2,
};
const cached = await cacheTemporaryMcpServer(accessToken, serverPayload);
const serverId = cached?.server_id?.trim();
if (!serverId) throw new Error("Failed to prepare OAuth session");
let registeredClient: { clientId?: string; clientSecret?: string } = {};
const registration = await registerMcpOAuthClient(accessToken, serverId, {
client_name: server.alias || server.server_name || serverId,
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
});
registeredClient = {
clientId: registration?.client_id,
clientSecret: registration?.client_secret,
};
const verifierArray = new Uint8Array(32);
crypto.getRandomValues(verifierArray);
const verifier = base64UrlEncode(verifierArray.buffer);
const challengeData = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest("SHA-256", challengeData);
const challenge = base64UrlEncode(digest);
const state = crypto.randomUUID();
const path = window.location.pathname || "";
const uiIndex = path.indexOf("/ui");
const uiPrefix = uiIndex >= 0 ? path.slice(0, uiIndex + 3) : "";
const redirectUri = `${window.location.origin}${uiPrefix}/mcp/oauth/callback`;
const authorizeUrl = buildMcpOAuthAuthorizeUrl({
serverId,
clientId: registeredClient.clientId,
redirectUri,
state,
codeChallenge: challenge,
});
const flowState = {
state,
codeVerifier: verifier,
clientId: registeredClient.clientId,
clientSecret: registeredClient.clientSecret,
serverId,
originalServerId: server.server_id,
redirectUri,
};
// Persist state for after redirect
[sessionStorage, localStorage].forEach((s) => {
try {
s.setItem(OAUTH_FLOW_STATE_KEY, JSON.stringify(flowState));
s.setItem(OAUTH_RETURN_URL_KEY, window.location.href);
s.setItem(CHAT_OAUTH_SELECTED_SERVERS_KEY, JSON.stringify(selectedServers));
} catch { /* ignore */ }
});
window.location.href = authorizeUrl;
} catch (err) {
message.error(err instanceof Error ? err.message : "Failed to start OAuth flow");
setOauthAuthorizing(false);
}
}, [accessToken, selectedServers]);
const handleToggle = async (serverName: string, checked: boolean) => {
if (!checked) {
onChange(selectedServers.filter((s) => s !== serverName));
@ -95,6 +278,8 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
const isConnected = selectedServers.includes(name);
const isTogglingOn = togglingOn.has(name);
const color = getAvatarColor(name);
const isOAuth = isOAuthServer(detailServer);
const hasOAuthToken = Boolean(oauthTokens[detailServer.server_id]);
return (
<div style={{ width: "100%" }}>
@ -122,26 +307,68 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
{name.charAt(0).toUpperCase()}
</div>
<div style={{ flex: 1 }}>
<h2 style={{ margin: "0 0 4px", fontSize: 22, fontWeight: 700, color: "#111827" }}>{name}</h2>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 700, color: "#111827" }}>{name}</h2>
{isOAuth && (
<span style={{
fontSize: 10, fontWeight: 600,
color: hasOAuthToken ? "#059669" : "#d97706",
background: hasOAuthToken ? "#ecfdf5" : "#fffbeb",
borderRadius: 4, padding: "2px 6px",
display: "inline-flex", alignItems: "center", gap: 3,
}}>
{hasOAuthToken ? <><CheckCircleOutlined style={{ fontSize: 10 }} /> Authorized</> : <><LockOutlined style={{ fontSize: 10 }} /> OAuth</>}
</span>
)}
</div>
<p style={{ margin: 0, fontSize: 14, color: "#6b7280" }}>{detailServer.description ?? "MCP server"}</p>
</div>
<Button
type={isConnected ? "default" : "primary"}
loading={isTogglingOn}
onClick={() => handleToggle(name, !isConnected)}
style={{ borderRadius: 8, fontWeight: 600, height: 38, minWidth: 110 }}
>
{isConnected ? "Disconnect" : "Connect"}
</Button>
{isOAuth && !hasOAuthToken ? (
<Button
type="primary"
loading={oauthAuthorizing}
onClick={() => startOAuthForServer(detailServer)}
style={{ borderRadius: 8, fontWeight: 600, height: 38, minWidth: 130 }}
icon={<LockOutlined />}
>
Authorize
</Button>
) : (
<Button
type={isConnected ? "default" : "primary"}
loading={isTogglingOn}
onClick={() => handleToggle(name, !isConnected)}
style={{ borderRadius: 8, fontWeight: 600, height: 38, minWidth: 110 }}
>
{isConnected ? "Disconnect" : "Connect"}
</Button>
)}
</div>
{/* OAuth notice for unauthenticated OAuth servers */}
{isOAuth && !hasOAuthToken && (
<div style={{
background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 10,
padding: "14px 16px", marginBottom: 20, fontSize: 13, color: "#92400e",
display: "flex", alignItems: "flex-start", gap: 10,
}}>
<LockOutlined style={{ marginTop: 2, flexShrink: 0 }} />
<div>
<div style={{ fontWeight: 600, marginBottom: 2 }}>Authorization required</div>
This server uses OAuth 2.0. Click &ldquo;Authorize&rdquo; to sign in with the service
provider and grant access to your account. Your credentials are not shared with LiteLLM.
</div>
</div>
)}
{/* Info table */}
<h3 style={{ margin: "0 0 12px", fontSize: 15, fontWeight: 600, color: "#111827" }}>Information</h3>
<div style={{ border: "1px solid #e5e7eb", borderRadius: 10, overflow: "hidden" }}>
{[
["Server ID", detailServer.server_id],
["Transport", (detailServer as MCPServer & { mcp_info?: { server_url?: string } }).mcp_info?.server_url ? "HTTP" : "stdio"],
["Status", isConnected ? "Connected" : "Not connected"],
["Authentication", isOAuth ? "OAuth 2.0" : (detailServer.auth_type || "None")],
["Status", isConnected ? "Connected" : (isOAuth && !hasOAuthToken ? "Not authorized" : "Not connected")],
].filter(([, v]) => v).map(([label, value], i, arr) => (
<div key={label} style={{
display: "flex",
@ -227,6 +454,8 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
const isConnected = selectedServers.includes(name);
const color = getAvatarColor(name);
const isLeftCol = idx % 2 === 0;
const isOAuth = isOAuthServer(server);
const hasToken = Boolean(oauthTokens[server.server_id]);
return (
<div
@ -251,8 +480,13 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
{name.charAt(0).toUpperCase()}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 500, color: "#111827", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{name}
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 14, fontWeight: 500, color: "#111827", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{name}
</span>
{isOAuth && (
<LockOutlined style={{ fontSize: 11, color: hasToken ? "#059669" : "#d97706", flexShrink: 0 }} />
)}
</div>
<div style={{ fontSize: 12, color: "#9ca3af", marginTop: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{server.description ?? "MCP server"}

View file

@ -1,7 +1,8 @@
import React, { useEffect, useState } from "react";
import { Switch, Spin, message } from "antd";
import { LockOutlined } from "@ant-design/icons";
import { fetchMCPServers, listMCPTools } from "../networking";
import { MCPServer } from "../mcp_tools/types";
import { MCPServer, AUTH_TYPE } from "../mcp_tools/types";
interface Props {
accessToken: string;
@ -9,10 +10,13 @@ interface Props {
onChange: (servers: string[]) => void;
}
function isOAuthServer(server: MCPServer): boolean {
return server.auth_type === AUTH_TYPE.OAUTH2 || server.auth_type === "oauth2";
}
const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onChange }) => {
const [servers, setServers] = useState<MCPServer[]>([]);
const [loadingServers, setLoadingServers] = useState(true);
// Track which individual servers are being toggled on (verifying tools)
const [togglingOn, setTogglingOn] = useState<Set<string>>(new Set());
useEffect(() => {
@ -23,7 +27,6 @@ const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onCha
try {
const data = await fetchMCPServers(accessToken);
if (cancelled) return;
// API returns { data: MCPServer[] } or MCPServer[]
const list: MCPServer[] = Array.isArray(data) ? data : (data?.data ?? []);
setServers(list);
} catch {
@ -46,21 +49,17 @@ const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onCha
const handleToggle = async (serverName: string, checked: boolean) => {
if (!checked) {
// Toggle OFF — remove immediately, no tool fetch needed
onChange(selectedServers.filter((s) => s !== serverName));
return;
}
// Toggle ON — verify tools are reachable first
setTogglingOn((prev) => new Set(prev).add(serverName));
try {
const result = await listMCPTools(accessToken, serverName);
// listMCPTools never throws; it returns { tools, error, message } on failure
if (result?.error) {
message.warning(
`Could not load tools for ${serverName} — it will be excluded from this message.`
);
// Do not add to selectedServers
return;
}
onChange([...selectedServers, serverName]);
@ -68,7 +67,6 @@ const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onCha
message.warning(
`Could not load tools for ${serverName} — it will be excluded from this message.`
);
// Do not add to selectedServers
} finally {
setTogglingOn((prev) => {
const next = new Set(prev);
@ -100,6 +98,7 @@ const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onCha
const name = server.server_name ?? server.alias ?? server.server_id;
const isSelected = selectedServers.includes(name);
const isTogglingOn = togglingOn.has(name);
const isOAuth = isOAuthServer(server);
return (
<div
@ -121,9 +120,15 @@ const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onCha
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
display: "flex",
alignItems: "center",
gap: 5,
}}
>
{name}
{isOAuth && (
<LockOutlined style={{ fontSize: 10, color: "#d97706", flexShrink: 0 }} />
)}
</div>
{server.description && (
<div

View file

@ -13,6 +13,7 @@ import MCPPermissionManagement from "./MCPPermissionManagement";
import { isAdminRole } from "@/utils/roles";
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
import { parseErrorMessage } from "../shared/errorUtils";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
const asset_logos_folder = "../ui/assets/logos/";
@ -249,6 +250,14 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
form.setFieldsValue(prefillValues);
setFormValues(prefillValues);
setAliasManuallyEdited(false);
// Auth type must be set after a render cycle so the auth dropdown is mounted
if (prefillData.auth_type && transport !== "stdio") {
requestAnimationFrame(() => {
form.setFieldsValue({ auth_type: prefillData.auth_type });
setFormValues((prev) => ({ ...prev, auth_type: prefillData.auth_type }));
});
}
}, [isModalVisible, prefillData, form]);
const handleCreate = async (values: Record<string, any>) => {
@ -385,7 +394,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
onCreateSuccess(response);
}
} catch (error) {
NotificationsManager.fromBackend("Error creating MCP Server: " + error);
NotificationsManager.fromBackend("Error creating MCP Server: " + parseErrorMessage(error));
} finally {
setIsLoading(false);
}

View file

@ -202,6 +202,7 @@ export interface DiscoverableMCPServer {
registry_url?: string | null;
transport: string;
url?: string | null;
auth_type?: string | null;
command?: string | null;
args?: string[] | null;
env_vars?: Array<{ name: string; description?: string; secret?: boolean }> | null;

View file

@ -8799,13 +8799,14 @@ export const perUserAnalyticsCall = async (
};
export const deriveErrorMessage = (errorData: any): string => {
return (
const raw =
(errorData?.error && (errorData.error.message || errorData.error)) ||
errorData?.message ||
errorData?.detail ||
errorData?.error ||
JSON.stringify(errorData)
);
errorData;
if (typeof raw === "string") return raw;
return JSON.stringify(raw);
};
export interface LoginRequest {