🔐 feat: MCP Server Auth UX with Dynamic Detection & Manual OAuth (#10978)

* 🔐 feat: Improve MCP Server Auth UX with Dynamic Detection & Manual OAuth

* 🔧 fix: Update OAuth input autocomplete and refine translation description for clarity

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Atef Bellaaj 2025-12-15 23:06:13 +01:00 committed by GitHub
parent 03ced7a894
commit e53619959d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 61 additions and 136 deletions

View file

@ -1,59 +0,0 @@
import { useState } from 'react';
import { GearIcon, MCPIcon } from '@librechat/client';
import type { MCP } from 'librechat-data-provider';
import { cn } from '~/utils';
type MCPProps = {
mcp: MCP;
onClick: () => void;
};
export default function MCP({ mcp, onClick }: MCPProps) {
const [isHovering, setIsHovering] = useState(false);
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onClick();
}
}}
className="group flex w-full rounded-lg border border-border-medium text-sm hover:cursor-pointer focus:outline-none focus:ring-2 focus:ring-text-primary"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
aria-label={`MCP for ${mcp.metadata.name}`}
>
<div className="flex h-9 items-center gap-2 px-3">
{mcp.metadata.icon ? (
<img
src={mcp.metadata.icon}
alt={`${mcp.metadata.name} icon`}
className="h-6 w-6 rounded-md object-cover"
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-surface-secondary">
<MCPIcon />
</div>
)}
<div
className="grow overflow-hidden text-ellipsis whitespace-nowrap"
style={{ wordBreak: 'break-all' }}
>
{mcp.metadata.name}
</div>
</div>
<div
className={cn(
'ml-auto h-9 w-9 min-w-9 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-tertiary focus:outline-none focus:ring-2 focus:ring-text-primary group-focus:flex',
isHovering ? 'flex' : 'hidden',
)}
aria-label="Settings"
>
<GearIcon className="icon-sm" aria-hidden="true" />
</div>
</div>
);
}

View file

@ -1,55 +0,0 @@
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import {
AuthorizationTypeEnum,
TokenExchangeMethodEnum,
AuthTypeEnum,
} from 'librechat-data-provider';
import ActionsAuth from '~/components/SidePanel/Builder/ActionsAuth';
export default function MCPAuth() {
// Create a separate form for auth
const authMethods = useForm({
defaultValues: {
/* General */
type: AuthTypeEnum.None,
saved_auth_fields: false,
/* API key */
api_key: '',
authorization_type: AuthorizationTypeEnum.Basic,
custom_auth_header: '',
/* OAuth */
oauth_client_id: '',
oauth_client_secret: '',
authorization_url: '',
client_url: '',
scope: '',
token_exchange_method: TokenExchangeMethodEnum.DefaultPost,
},
});
const { watch, setValue } = authMethods;
const type = watch('type');
// Sync form state when auth type changes
useEffect(() => {
if (type === 'none') {
// Reset auth fields when type is none
setValue('api_key', '');
setValue('authorization_type', AuthorizationTypeEnum.Basic);
setValue('custom_auth_header', '');
setValue('oauth_client_id', '');
setValue('oauth_client_secret', '');
setValue('authorization_url', '');
setValue('client_url', '');
setValue('scope', '');
setValue('token_exchange_method', TokenExchangeMethodEnum.DefaultPost);
}
}, [type, setValue]);
return (
<FormProvider {...authMethods}>
<ActionsAuth />
</FormProvider>
);
}

View file

@ -50,9 +50,9 @@ function getAuthLocalizationKey(type: AuthTypeEnum): TranslationKeys {
case AuthTypeEnum.ServiceHttp: case AuthTypeEnum.ServiceHttp:
return 'com_ui_api_key'; return 'com_ui_api_key';
case AuthTypeEnum.OAuth: case AuthTypeEnum.OAuth:
return 'com_ui_oauth'; return 'com_ui_manual_oauth';
default: default:
return 'com_ui_none'; return 'com_ui_auto_detect';
} }
} }
@ -156,12 +156,15 @@ export default function MCPAuth({
style={{ outline: 'none' }} style={{ outline: 'none' }}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label htmlFor="auth-none" className="flex cursor-pointer items-center gap-1"> <label
htmlFor="auth-auto-detect"
className="flex cursor-pointer items-center gap-1"
>
<RadioGroup.Item <RadioGroup.Item
type="button" type="button"
role="radio" role="radio"
value={AuthTypeEnum.None} value={AuthTypeEnum.None}
id="auth-none" id="auth-auto-detect"
className={cn( className={cn(
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border', 'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
'border-border-heavy bg-surface-primary', 'border-border-heavy bg-surface-primary',
@ -169,7 +172,7 @@ export default function MCPAuth({
> >
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" /> <RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
</RadioGroup.Item> </RadioGroup.Item>
{localize('com_ui_none')} {localize('com_ui_auto_detect')}
</label> </label>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -190,12 +193,15 @@ export default function MCPAuth({
</label> </label>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label htmlFor="auth-oauth" className="flex cursor-pointer items-center gap-1"> <label
htmlFor="auth-manual-oauth"
className="flex cursor-pointer items-center gap-1"
>
<RadioGroup.Item <RadioGroup.Item
type="button" type="button"
role="radio" role="radio"
value={AuthTypeEnum.OAuth} value={AuthTypeEnum.OAuth}
id="auth-oauth" id="auth-manual-oauth"
className={cn( className={cn(
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border', 'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
'border-border-heavy bg-surface-primary', 'border-border-heavy bg-surface-primary',
@ -203,12 +209,18 @@ export default function MCPAuth({
> >
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" /> <RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
</RadioGroup.Item> </RadioGroup.Item>
{localize('com_ui_oauth')} {localize('com_ui_manual_oauth')}
</label> </label>
</div> </div>
</RadioGroup.Root> </RadioGroup.Root>
</div> </div>
{authType === AuthTypeEnum.None && null} {authType === AuthTypeEnum.None && (
<div className="rounded-lg border border-border-medium bg-surface-secondary p-3">
<p className="text-sm text-text-secondary">
{localize('com_ui_auto_detect_description')}
</p>
</div>
)}
{authType === AuthTypeEnum.ServiceHttp && <ApiKey inputClasses={inputClasses} />} {authType === AuthTypeEnum.ServiceHttp && <ApiKey inputClasses={inputClasses} />}
{authType === AuthTypeEnum.OAuth && <OAuth inputClasses={inputClasses} />} {authType === AuthTypeEnum.OAuth && <OAuth inputClasses={inputClasses} />}
</div> </div>
@ -384,8 +396,9 @@ const ApiKey = ({ inputClasses }: { inputClasses: string }) => {
const OAuth = ({ inputClasses }: { inputClasses: string }) => { const OAuth = ({ inputClasses }: { inputClasses: string }) => {
const localize = useLocalize(); const localize = useLocalize();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const { register, watch } = useFormContext(); const { register, watch, formState } = useFormContext();
const [isCopying, setIsCopying] = useState(false); const [isCopying, setIsCopying] = useState(false);
const { errors } = formState;
// Check if we're in edit mode (server exists with ID) // Check if we're in edit mode (server exists with ID)
const serverId = watch('server_id'); const serverId = watch('server_id');
@ -400,26 +413,48 @@ const OAuth = ({ inputClasses }: { inputClasses: string }) => {
return ( return (
<> <>
<label className="mb-1 block text-sm font-medium">{localize('com_ui_client_id')}</label> <label className="mb-1 block text-sm font-medium">
{localize('com_ui_client_id')} {!isEditMode && <span className="text-red-500">*</span>}
</label>
<input <input
placeholder="<HIDDEN>" placeholder={isEditMode ? localize('com_ui_leave_blank_to_keep') : ''}
autoComplete="off"
className={inputClasses}
{...register('oauth_client_id', { required: !isEditMode })}
/>
{errors.oauth_client_id && (
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
)}
<label className="mb-1 block text-sm font-medium">
{localize('com_ui_client_secret')} {!isEditMode && <span className="text-red-500">*</span>}
</label>
<input
placeholder={isEditMode ? localize('com_ui_leave_blank_to_keep') : ''}
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
className={inputClasses} className={inputClasses}
{...register('oauth_client_id')} {...register('oauth_client_secret', { required: !isEditMode })}
/> />
<label className="mb-1 block text-sm font-medium">{localize('com_ui_client_secret')}</label> {errors.oauth_client_secret && (
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
)}
<label className="mb-1 block text-sm font-medium">
{localize('com_ui_auth_url')} <span className="text-red-500">*</span>
</label>
<input <input
placeholder="<HIDDEN>"
type="password"
autoComplete="new-password"
className={inputClasses} className={inputClasses}
{...register('oauth_client_secret')} {...register('oauth_authorization_url', { required: true })}
/> />
<label className="mb-1 block text-sm font-medium">{localize('com_ui_auth_url')}</label> {errors.oauth_authorization_url && (
<input className={inputClasses} {...register('oauth_authorization_url')} /> <span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
<label className="mb-1 block text-sm font-medium">{localize('com_ui_token_url')}</label> )}
<input className={inputClasses} {...register('oauth_token_url')} /> <label className="mb-1 block text-sm font-medium">
{localize('com_ui_token_url')} <span className="text-red-500">*</span>
</label>
<input className={inputClasses} {...register('oauth_token_url', { required: true })} />
{errors.oauth_token_url && (
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
)}
{/* Redirect URI - read-only in edit mode, info message in create mode */} {/* Redirect URI - read-only in edit mode, info message in create mode */}
<label className="mb-1 block text-sm font-medium">{localize('com_ui_redirect_uri')}</label> <label className="mb-1 block text-sm font-medium">{localize('com_ui_redirect_uri')}</label>

View file

@ -739,6 +739,8 @@
"com_ui_authentication": "Authentication", "com_ui_authentication": "Authentication",
"com_ui_authentication_type": "Authentication Type", "com_ui_authentication_type": "Authentication Type",
"com_ui_auto": "Auto", "com_ui_auto": "Auto",
"com_ui_auto_detect": "Auto Detect",
"com_ui_auto_detect_description": "DCR will be attempted if auth is required. Choose this if your MCP server has no auth requirements or supports DCR.",
"com_ui_avatar": "Avatar", "com_ui_avatar": "Avatar",
"com_ui_azure": "Azure", "com_ui_azure": "Azure",
"com_ui_azure_ad": "Entra ID", "com_ui_azure_ad": "Entra ID",
@ -1036,6 +1038,7 @@
"com_ui_latest_footer": "Every AI for Everyone.", "com_ui_latest_footer": "Every AI for Everyone.",
"com_ui_latest_production_version": "Latest production version", "com_ui_latest_production_version": "Latest production version",
"com_ui_latest_version": "Latest version", "com_ui_latest_version": "Latest version",
"com_ui_leave_blank_to_keep": "Leave blank to keep existing",
"com_ui_librechat_code_api_key": "Get your LibreChat Code Interpreter API key", "com_ui_librechat_code_api_key": "Get your LibreChat Code Interpreter API key",
"com_ui_librechat_code_api_subtitle": "Secure. Multi-language. Input/Output Files.", "com_ui_librechat_code_api_subtitle": "Secure. Multi-language. Input/Output Files.",
"com_ui_librechat_code_api_title": "Run AI Code", "com_ui_librechat_code_api_title": "Run AI Code",
@ -1046,6 +1049,7 @@
"com_ui_logo": "{{0}} Logo", "com_ui_logo": "{{0}} Logo",
"com_ui_low": "Low", "com_ui_low": "Low",
"com_ui_manage": "Manage", "com_ui_manage": "Manage",
"com_ui_manual_oauth": "Manual OAuth",
"com_ui_marketplace": "Marketplace", "com_ui_marketplace": "Marketplace",
"com_ui_marketplace_allow_use": "Allow using Marketplace", "com_ui_marketplace_allow_use": "Allow using Marketplace",
"com_ui_max_favorites_reached": "Maximum pinned items reached ({{0}}). Unpin an item to add more.", "com_ui_max_favorites_reached": "Maximum pinned items reached ({{0}}). Unpin an item to add more.",