mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🔐 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:
parent
03ced7a894
commit
e53619959d
4 changed files with 61 additions and 136 deletions
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue