mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-31 15:48:51 +01:00
🔌 refactor: MCP UI with Improved Accessibility and Reusable Components (#11118)
* feat: enhance MCP server selection UI with new components and improved accessibility * fix(i18n): add missing com_ui_mcp_servers translation key The MCP server menu aria-label was referencing a non-existent translation key. Added the missing key for accessibility. * feat(MCP): enhance MCP components with improved accessibility and focus management * fix(i18n): remove outdated MCP server translation keys * fix(MCPServerList): improve color contrast by updating text color for no MCP servers message * refactor(MCP): Server status components and improve user action handling Updated MCPServerStatusIcon to use a unified icon system for better clarity Introduced new MCPCardActions component for standardized action buttons on server cards Created MCPServerCard component to encapsulate server display logic and actions Enhanced MCPServerList to render MCPServerCard components, improving code organization Added MCPStatusBadge for consistent status representation in dialogs Updated utility functions for status color and text retrieval to align with new design Improved localization keys for better clarity and consistency in user messages * style(MCP): update button and card background styles for improved UI consistency * feat(MCP): implement global server initialization state management using Jotai * refactor(MCP): modularize MCPServerDialog into structured component architecture - Split monolithic dialog into dedicated section components (Auth, BasicInfo, Connection, Transport, Trust) - Extract form logic into useMCPServerForm custom hook - Add utility modules for JSON import and URL handling - Introduce reusable SecretInput component in @librechat/client - Remove deprecated MCPAuth component * style(MCP): update button styles for improved layout and adjust empty state background color * refactor(Radio): enhance component mounting logic and background style updates * refactor(translation): remove unused keys and streamline localization strings
This commit is contained in:
parent
0b8e0fcede
commit
e4870ed0b0
32 changed files with 2594 additions and 1646 deletions
|
|
@ -31,12 +31,12 @@ export default function MCPIcon({ icon, onIconChange }: MCPIconProps) {
|
|||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className="bg-token-surface-secondary dark:bg-token-surface-tertiary border-token-border-medium flex h-16 w-16 shrink-0 cursor-pointer items-center justify-center rounded-[1.5rem] border-2 border-dashed"
|
||||
className="bg-token-surface-secondary dark:bg-token-surface-tertiary border-token-border-medium flex h-16 w-16 shrink-0 cursor-pointer items-center justify-center rounded-xl border-2 border-dashed"
|
||||
>
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
className="h-full w-full rounded-[1.5rem] object-cover"
|
||||
className="h-full w-full rounded-xl object-cover"
|
||||
alt="MCP Icon"
|
||||
width="64"
|
||||
height="64"
|
||||
|
|
@ -49,7 +49,7 @@ export default function MCPIcon({ icon, onIconChange }: MCPIconProps) {
|
|||
<span className="token-text-secondary text-sm">
|
||||
{localize('com_ui_icon')} {localize('com_ui_optional')}
|
||||
</span>
|
||||
<span className="token-text-tertiary text-xs">{localize('com_agents_mcp_icon_size')}</span>
|
||||
<span className="text-xs text-text-secondary">{localize('com_agents_mcp_icon_size')}</span>
|
||||
</div>
|
||||
<input
|
||||
accept="image/png,.png,image/jpeg,.jpg,.jpeg,image/gif,.gif,image/webp,.webp"
|
||||
|
|
|
|||
|
|
@ -1,505 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { FormProvider, useForm, useFormContext } from 'react-hook-form';
|
||||
import * as RadioGroup from '@radix-ui/react-radio-group';
|
||||
import { Copy, CopyCheck } from 'lucide-react';
|
||||
import {
|
||||
OGDialog,
|
||||
OGDialogTrigger,
|
||||
OGDialogTemplate,
|
||||
Button,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import { TranslationKeys, useLocalize, useCopyToClipboard } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
enum AuthTypeEnum {
|
||||
None = 'none',
|
||||
ServiceHttp = 'service_http',
|
||||
OAuth = 'oauth',
|
||||
}
|
||||
|
||||
enum AuthorizationTypeEnum {
|
||||
Basic = 'basic',
|
||||
Bearer = 'bearer',
|
||||
Custom = 'custom',
|
||||
}
|
||||
|
||||
// Auth configuration type
|
||||
export interface AuthConfig {
|
||||
auth_type?: AuthTypeEnum;
|
||||
api_key?: string;
|
||||
api_key_source?: 'admin' | 'user'; // Whether admin provides key for all or each user provides their own
|
||||
api_key_authorization_type?: AuthorizationTypeEnum;
|
||||
api_key_custom_header?: string;
|
||||
oauth_client_id?: string;
|
||||
oauth_client_secret?: string;
|
||||
oauth_authorization_url?: string;
|
||||
oauth_token_url?: string;
|
||||
oauth_scope?: string;
|
||||
server_id?: string; // For edit mode redirect URI
|
||||
}
|
||||
|
||||
// Export enums for parent components
|
||||
export { AuthTypeEnum, AuthorizationTypeEnum };
|
||||
|
||||
/**
|
||||
* Returns the appropriate localization key for authentication type
|
||||
*/
|
||||
function getAuthLocalizationKey(type: AuthTypeEnum): TranslationKeys {
|
||||
switch (type) {
|
||||
case AuthTypeEnum.ServiceHttp:
|
||||
return 'com_ui_api_key';
|
||||
case AuthTypeEnum.OAuth:
|
||||
return 'com_ui_manual_oauth';
|
||||
default:
|
||||
return 'com_ui_auto_detect';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth and API Key authentication dialog for MCP Server Builder
|
||||
* Self-contained controlled component with its own form state
|
||||
* Only updates parent on Save, discards changes on Cancel
|
||||
*/
|
||||
export default function MCPAuth({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: AuthConfig;
|
||||
onChange: (config: AuthConfig) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [openAuthDialog, setOpenAuthDialog] = useState(false);
|
||||
|
||||
// Create local form with current value as default
|
||||
const methods = useForm<AuthConfig>({
|
||||
defaultValues: value,
|
||||
});
|
||||
|
||||
const { handleSubmit, watch, reset } = methods;
|
||||
const authType = watch('auth_type') || AuthTypeEnum.None;
|
||||
|
||||
const inputClasses = cn(
|
||||
'mb-2 h-9 w-full resize-none overflow-y-auto rounded-lg border px-3 py-2 text-sm',
|
||||
'border-border-medium bg-surface-primary outline-none',
|
||||
'focus:ring-2 focus:ring-ring',
|
||||
);
|
||||
|
||||
// Reset form when dialog opens with latest value from parent
|
||||
const handleDialogOpen = (open: boolean) => {
|
||||
if (open) {
|
||||
reset(value);
|
||||
}
|
||||
setOpenAuthDialog(open);
|
||||
};
|
||||
|
||||
// Save: update parent and close
|
||||
const handleSave = handleSubmit((formData) => {
|
||||
onChange(formData);
|
||||
setOpenAuthDialog(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<OGDialog open={openAuthDialog} onOpenChange={handleDialogOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<div className="relative mb-4">
|
||||
<div className="mb-1.5 flex items-center">
|
||||
<label className="text-token-text-primary block font-medium">
|
||||
{localize('com_ui_authentication')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="border-token-border-medium flex rounded-lg border text-sm hover:cursor-pointer">
|
||||
<div className="h-9 grow px-3 py-2">{localize(getAuthLocalizationKey(authType))}</div>
|
||||
<div className="bg-token-border-medium w-px"></div>
|
||||
<button type="button" color="neutral" className="flex items-center gap-2 px-3">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-sm"
|
||||
>
|
||||
<path
|
||||
d="M11.6439 3C10.9352 3 10.2794 3.37508 9.92002 3.98596L9.49644 4.70605C8.96184 5.61487 7.98938 6.17632 6.93501 6.18489L6.09967 6.19168C5.39096 6.19744 4.73823 6.57783 4.38386 7.19161L4.02776 7.80841C3.67339 8.42219 3.67032 9.17767 4.01969 9.7943L4.43151 10.5212C4.95127 11.4386 4.95127 12.5615 4.43151 13.4788L4.01969 14.2057C3.67032 14.8224 3.67339 15.5778 4.02776 16.1916L4.38386 16.8084C4.73823 17.4222 5.39096 17.8026 6.09966 17.8083L6.93502 17.8151C7.98939 17.8237 8.96185 18.3851 9.49645 19.294L9.92002 20.014C10.2794 20.6249 10.9352 21 11.6439 21H12.3561C13.0648 21 13.7206 20.6249 14.08 20.014L14.5035 19.294C15.0381 18.3851 16.0106 17.8237 17.065 17.8151L17.9004 17.8083C18.6091 17.8026 19.2618 17.4222 19.6162 16.8084L19.9723 16.1916C20.3267 15.5778 20.3298 14.8224 19.9804 14.2057L19.5686 13.4788C19.0488 12.5615 19.0488 11.4386 19.5686 10.5212L19.9804 9.7943C20.3298 9.17767 20.3267 8.42219 19.9723 7.80841L19.6162 7.19161C19.2618 6.57783 18.6091 6.19744 17.9004 6.19168L17.065 6.18489C16.0106 6.17632 15.0382 5.61487 14.5036 4.70605L14.08 3.98596C13.7206 3.37508 13.0648 3 12.3561 3H11.6439Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="2.5" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</OGDialogTrigger>
|
||||
<FormProvider {...methods}>
|
||||
<OGDialogTemplate
|
||||
title={localize('com_ui_authentication')}
|
||||
showCloseButton={false}
|
||||
className="w-full max-w-md"
|
||||
main={
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
{localize('com_ui_authentication_type')}
|
||||
</label>
|
||||
<RadioGroup.Root
|
||||
defaultValue={AuthTypeEnum.None}
|
||||
onValueChange={(value) =>
|
||||
methods.setValue('auth_type', value as AuthConfig['auth_type'])
|
||||
}
|
||||
value={authType}
|
||||
role="radiogroup"
|
||||
aria-required="false"
|
||||
dir="ltr"
|
||||
className="flex gap-4"
|
||||
style={{ outline: 'none' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="auth-auto-detect"
|
||||
className="flex cursor-pointer items-center gap-1"
|
||||
>
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
role="radio"
|
||||
value={AuthTypeEnum.None}
|
||||
id="auth-auto-detect"
|
||||
className={cn(
|
||||
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
|
||||
'border-border-heavy bg-surface-primary',
|
||||
)}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
|
||||
</RadioGroup.Item>
|
||||
{localize('com_ui_auto_detect')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="auth-apikey" className="flex cursor-pointer items-center gap-1">
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
role="radio"
|
||||
value={AuthTypeEnum.ServiceHttp}
|
||||
id="auth-apikey"
|
||||
className={cn(
|
||||
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
|
||||
'border-border-heavy bg-surface-primary',
|
||||
)}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
|
||||
</RadioGroup.Item>
|
||||
{localize('com_ui_api_key')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="auth-manual-oauth"
|
||||
className="flex cursor-pointer items-center gap-1"
|
||||
>
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
role="radio"
|
||||
value={AuthTypeEnum.OAuth}
|
||||
id="auth-manual-oauth"
|
||||
className={cn(
|
||||
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
|
||||
'border-border-heavy bg-surface-primary',
|
||||
)}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
|
||||
</RadioGroup.Item>
|
||||
{localize('com_ui_manual_oauth')}
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
{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.OAuth && <OAuth inputClasses={inputClasses} />}
|
||||
</div>
|
||||
}
|
||||
buttons={
|
||||
<Button type="button" variant="submit" onClick={handleSave} className="text-white">
|
||||
{localize('com_ui_save')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</FormProvider>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const ApiKey = ({ inputClasses }: { inputClasses: string }) => {
|
||||
const localize = useLocalize();
|
||||
const { register, watch, setValue } = useFormContext();
|
||||
const api_key_source = watch('api_key_source') || 'admin';
|
||||
const authorization_type = watch('api_key_authorization_type') || AuthorizationTypeEnum.Bearer;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* API Key Source selection */}
|
||||
<label className="mb-1 block text-sm font-medium">{localize('com_ui_api_key_source')}</label>
|
||||
<RadioGroup.Root
|
||||
defaultValue="admin"
|
||||
onValueChange={(value) => setValue('api_key_source', value)}
|
||||
value={api_key_source}
|
||||
role="radiogroup"
|
||||
aria-required="true"
|
||||
dir="ltr"
|
||||
className="mb-3 flex flex-col gap-2"
|
||||
style={{ outline: 'none' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="source-admin" className="flex cursor-pointer items-center gap-1">
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
role="radio"
|
||||
value="admin"
|
||||
id="source-admin"
|
||||
className={cn(
|
||||
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
|
||||
'border-border-heavy bg-surface-primary',
|
||||
)}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
|
||||
</RadioGroup.Item>
|
||||
{localize('com_ui_admin_provides_key')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="source-user" className="flex cursor-pointer items-center gap-1">
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
role="radio"
|
||||
value="user"
|
||||
id="source-user"
|
||||
className={cn(
|
||||
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
|
||||
'border-border-heavy bg-surface-primary',
|
||||
)}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
|
||||
</RadioGroup.Item>
|
||||
{localize('com_ui_user_provides_key')}
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
||||
{/* API Key input - only show for admin-provided mode */}
|
||||
{api_key_source === 'admin' && (
|
||||
<>
|
||||
<label className="mb-1 block text-sm font-medium">{localize('com_ui_api_key')}</label>
|
||||
<input
|
||||
placeholder="<HIDDEN>"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className={inputClasses}
|
||||
{...register('api_key')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* User-provided mode info */}
|
||||
{api_key_source === 'user' && (
|
||||
<div className="mb-3 rounded-lg border border-border-medium bg-surface-secondary p-3">
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_user_provides_key_note')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header Format selection - shown for both modes */}
|
||||
<label className="mb-1 block text-sm font-medium">{localize('com_ui_header_format')}</label>
|
||||
<RadioGroup.Root
|
||||
defaultValue={AuthorizationTypeEnum.Bearer}
|
||||
onValueChange={(value) => setValue('api_key_authorization_type', value)}
|
||||
value={authorization_type}
|
||||
role="radiogroup"
|
||||
aria-required="true"
|
||||
dir="ltr"
|
||||
className="mb-2 flex gap-6 overflow-hidden rounded-lg"
|
||||
style={{ outline: 'none' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="auth-bearer" className="flex cursor-pointer items-center gap-1">
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
role="radio"
|
||||
value={AuthorizationTypeEnum.Bearer}
|
||||
id="auth-bearer"
|
||||
className={cn(
|
||||
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
|
||||
'border-border-heavy bg-surface-primary',
|
||||
)}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
|
||||
</RadioGroup.Item>
|
||||
{localize('com_ui_bearer')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="auth-basic" className="flex cursor-pointer items-center gap-1">
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
role="radio"
|
||||
value={AuthorizationTypeEnum.Basic}
|
||||
id="auth-basic"
|
||||
className={cn(
|
||||
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
|
||||
'border-border-heavy bg-surface-primary',
|
||||
)}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
|
||||
</RadioGroup.Item>
|
||||
{localize('com_ui_basic')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="auth-custom" className="flex cursor-pointer items-center gap-1">
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
role="radio"
|
||||
value={AuthorizationTypeEnum.Custom}
|
||||
id="auth-custom"
|
||||
className={cn(
|
||||
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
|
||||
'border-border-heavy bg-surface-primary',
|
||||
)}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
|
||||
</RadioGroup.Item>
|
||||
{localize('com_ui_custom')}
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
{authorization_type === AuthorizationTypeEnum.Custom && (
|
||||
<div className="mt-2">
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
{localize('com_ui_custom_header_name')}
|
||||
</label>
|
||||
<input
|
||||
className={inputClasses}
|
||||
placeholder="X-Api-Key"
|
||||
{...register('api_key_custom_header')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const OAuth = ({ inputClasses }: { inputClasses: string }) => {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { register, watch, formState } = useFormContext();
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const { errors } = formState;
|
||||
|
||||
// Check if we're in edit mode (server exists with ID)
|
||||
const serverId = watch('server_id');
|
||||
const isEditMode = !!serverId;
|
||||
|
||||
// Calculate redirect URI for edit mode
|
||||
const redirectUri = isEditMode
|
||||
? `${window.location.origin}/api/mcp/${serverId}/oauth/callback`
|
||||
: '';
|
||||
|
||||
const copyLink = useCopyToClipboard({ text: redirectUri });
|
||||
|
||||
return (
|
||||
<>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
{localize('com_ui_client_id')} {!isEditMode && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<input
|
||||
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"
|
||||
autoComplete="new-password"
|
||||
className={inputClasses}
|
||||
{...register('oauth_client_secret', { required: !isEditMode })}
|
||||
/>
|
||||
{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
|
||||
className={inputClasses}
|
||||
{...register('oauth_authorization_url', { required: true })}
|
||||
/>
|
||||
{errors.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')} <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 */}
|
||||
<label className="mb-1 block text-sm font-medium">{localize('com_ui_redirect_uri')}</label>
|
||||
{isEditMode ? (
|
||||
<div className="relative mb-2 flex items-center">
|
||||
<div className="border-token-border-medium bg-token-surface-primary hover:border-token-border-hover flex h-10 w-full rounded-lg border">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={redirectUri}
|
||||
className="w-full border-0 bg-transparent px-3 py-2 pr-12 text-sm text-text-secondary-alt focus:outline-none"
|
||||
style={{ direction: 'rtl' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-0 flex h-full items-center pr-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isCopying) {
|
||||
return;
|
||||
}
|
||||
showToast({ message: localize('com_ui_copied_to_clipboard') });
|
||||
copyLink(setIsCopying);
|
||||
}}
|
||||
className={cn('h-8 rounded-md px-2', isCopying ? 'cursor-default' : '')}
|
||||
aria-label={localize('com_ui_copy_link')}
|
||||
>
|
||||
{isCopying ? <CopyCheck className="size-4" /> : <Copy className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-2 rounded-lg border border-border-medium bg-surface-secondary p-2">
|
||||
<p className="text-xs text-text-secondary">{localize('com_ui_redirect_uri_info')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="mb-1 block text-sm font-medium">{localize('com_ui_scope')}</label>
|
||||
<input className={inputClasses} {...register('oauth_scope')} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { useState, useRef, useMemo } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { Button, Spinner, FilterInput, OGDialogTrigger } from '@librechat/client';
|
||||
import { Button, Spinner, FilterInput, OGDialogTrigger, TooltipAnchor } from '@librechat/client';
|
||||
import { useLocalize, useMCPServerManager, useHasAccess } from '~/hooks';
|
||||
import MCPServerList from './MCPServerList';
|
||||
import MCPServerDialog from './MCPServerDialog';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import MCPAdminSettings from './MCPAdminSettings';
|
||||
import MCPServerDialog from './MCPServerDialog';
|
||||
import MCPServerList from './MCPServerList';
|
||||
|
||||
export default function MCPBuilderPanel() {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -37,40 +37,48 @@ export default function MCPBuilderPanel() {
|
|||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-visible">
|
||||
<div role="region" aria-label="MCP Builder" className="mt-2 space-y-2">
|
||||
{/* Admin Settings Button */}
|
||||
<MCPAdminSettings />
|
||||
<div role="region" aria-label={localize('com_ui_mcp_servers')} className="mt-2 space-y-2">
|
||||
{/* Toolbar: Search + Add Button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterInput
|
||||
inputId="mcp-filter"
|
||||
label={localize('com_ui_filter_mcp_servers')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
containerClassName="flex-1"
|
||||
/>
|
||||
{hasCreateAccess && (
|
||||
<MCPServerDialog
|
||||
open={showDialog}
|
||||
onOpenChange={setShowDialog}
|
||||
triggerRef={addButtonRef}
|
||||
>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_add_mcp')}
|
||||
side="bottom"
|
||||
render={
|
||||
<Button
|
||||
ref={addButtonRef}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0 bg-transparent"
|
||||
onClick={() => setShowDialog(true)}
|
||||
aria-label={localize('com_ui_add_mcp')}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialogTrigger>
|
||||
</MCPServerDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<FilterInput
|
||||
inputId="mcp-filter"
|
||||
label={localize('com_ui_filter_mcp_servers')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
{hasCreateAccess && (
|
||||
<MCPServerDialog open={showDialog} onOpenChange={setShowDialog} triggerRef={addButtonRef}>
|
||||
<OGDialogTrigger asChild>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
ref={addButtonRef}
|
||||
variant="outline"
|
||||
className="w-full bg-transparent"
|
||||
onClick={() => setShowDialog(true)}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{localize('com_ui_add_mcp')}
|
||||
</Button>
|
||||
</div>
|
||||
</OGDialogTrigger>
|
||||
</MCPServerDialog>
|
||||
)}
|
||||
|
||||
{/* Server List */}
|
||||
{/* Server Cards List */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Spinner className="h-6 w-6" />
|
||||
<Spinner className="size-6" aria-label={localize('com_ui_loading')} />
|
||||
</div>
|
||||
) : (
|
||||
<MCPServerList
|
||||
|
|
@ -79,7 +87,12 @@ export default function MCPBuilderPanel() {
|
|||
isFiltered={searchQuery.trim().length > 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Config Dialog for custom user vars */}
|
||||
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
|
||||
|
||||
{/* Admin Settings Section */}
|
||||
<MCPAdminSettings />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
162
client/src/components/SidePanel/MCPBuilder/MCPCardActions.tsx
Normal file
162
client/src/components/SidePanel/MCPBuilder/MCPCardActions.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { Pencil, PlugZap, SlidersHorizontal, RefreshCw, X } from 'lucide-react';
|
||||
import { Spinner, TooltipAnchor } from '@librechat/client';
|
||||
import type { MCPServerStatus } from 'librechat-data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface MCPCardActionsProps {
|
||||
serverName: string;
|
||||
serverStatus?: MCPServerStatus;
|
||||
isInitializing: boolean;
|
||||
canCancel: boolean;
|
||||
hasCustomUserVars: boolean;
|
||||
canEdit: boolean;
|
||||
onEditClick: (e: React.MouseEvent) => void;
|
||||
onConfigClick: (e: React.MouseEvent) => void;
|
||||
onInitialize: () => void;
|
||||
onCancel: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized action buttons for MCP server cards.
|
||||
*
|
||||
* Unified icon system (each icon has ONE meaning):
|
||||
* - Pencil: Edit server definition (Settings panel only)
|
||||
* - PlugZap: Connect/Authenticate (for disconnected/error servers)
|
||||
* - SlidersHorizontal: Configure custom variables (for connected servers with vars)
|
||||
* - RefreshCw: Reconnect/Refresh (for connected servers)
|
||||
* - Spinner: Loading state (with X on hover for cancel)
|
||||
*/
|
||||
export default function MCPCardActions({
|
||||
serverName,
|
||||
serverStatus,
|
||||
isInitializing,
|
||||
canCancel,
|
||||
hasCustomUserVars,
|
||||
canEdit,
|
||||
onEditClick,
|
||||
onConfigClick,
|
||||
onInitialize,
|
||||
onCancel,
|
||||
}: MCPCardActionsProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const connectionState = serverStatus?.connectionState;
|
||||
const isConnected = connectionState === 'connected';
|
||||
const isConnecting = connectionState === 'connecting';
|
||||
const isDisconnected = connectionState === 'disconnected';
|
||||
const isError = connectionState === 'error';
|
||||
|
||||
const buttonBaseClass = cn(
|
||||
'flex size-7 items-center justify-center rounded-md',
|
||||
'transition-colors duration-150',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
'hover:bg-surface-tertiary',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
|
||||
);
|
||||
|
||||
// Loading state - show spinner (with cancel option)
|
||||
if (isInitializing || isConnecting) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Edit button stays visible during loading */}
|
||||
{canEdit && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_edit')}
|
||||
side="top"
|
||||
className={buttonBaseClass}
|
||||
aria-label={localize('com_ui_edit')}
|
||||
role="button"
|
||||
onClick={onEditClick}
|
||||
>
|
||||
<Pencil className="size-3.5" aria-hidden="true" />
|
||||
</TooltipAnchor>
|
||||
)}
|
||||
|
||||
{/* Spinner with cancel on hover */}
|
||||
{canCancel ? (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_cancel')}
|
||||
side="top"
|
||||
className={cn(buttonBaseClass, 'group')}
|
||||
aria-label={localize('com_ui_cancel')}
|
||||
role="button"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div className="relative size-4">
|
||||
<Spinner className="size-4 group-hover:opacity-0" />
|
||||
<X className="absolute inset-0 size-4 text-red-500 opacity-0 group-hover:opacity-100" />
|
||||
</div>
|
||||
</TooltipAnchor>
|
||||
) : (
|
||||
<div className={cn(buttonBaseClass, 'cursor-default hover:bg-transparent')}>
|
||||
<Spinner
|
||||
className="size-4"
|
||||
aria-label={localize('com_nav_mcp_status_connecting', { 0: serverName })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Edit button - opens MCPServerDialog to edit server definition */}
|
||||
{canEdit && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_edit')}
|
||||
side="top"
|
||||
className={buttonBaseClass}
|
||||
aria-label={localize('com_ui_edit')}
|
||||
role="button"
|
||||
onClick={onEditClick}
|
||||
>
|
||||
<Pencil className="size-3.5" aria-hidden="true" />
|
||||
</TooltipAnchor>
|
||||
)}
|
||||
|
||||
{/* Connect button - for disconnected or error states */}
|
||||
{(isDisconnected || isError) && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_nav_mcp_connect')}
|
||||
side="top"
|
||||
className={buttonBaseClass}
|
||||
aria-label={localize('com_nav_mcp_connect')}
|
||||
role="button"
|
||||
onClick={() => onInitialize()}
|
||||
>
|
||||
<PlugZap className="size-4" aria-hidden="true" />
|
||||
</TooltipAnchor>
|
||||
)}
|
||||
|
||||
{/* Configure button - for connected servers with custom vars */}
|
||||
{isConnected && hasCustomUserVars && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_configure')}
|
||||
side="top"
|
||||
className={buttonBaseClass}
|
||||
aria-label={localize('com_ui_configure')}
|
||||
role="button"
|
||||
onClick={onConfigClick}
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" aria-hidden="true" />
|
||||
</TooltipAnchor>
|
||||
)}
|
||||
|
||||
{/* Refresh button - for connected servers (allows reconnection) */}
|
||||
{isConnected && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_nav_mcp_reconnect')}
|
||||
side="top"
|
||||
className={buttonBaseClass}
|
||||
aria-label={localize('com_nav_mcp_reconnect')}
|
||||
role="button"
|
||||
onClick={() => onInitialize()}
|
||||
>
|
||||
<RefreshCw className="size-3.5" aria-hidden="true" />
|
||||
</TooltipAnchor>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
client/src/components/SidePanel/MCPBuilder/MCPServerCard.tsx
Normal file
152
client/src/components/SidePanel/MCPBuilder/MCPServerCard.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { MCPIcon } from '@librechat/client';
|
||||
import { PermissionBits, hasPermissions } from 'librechat-data-provider';
|
||||
import type { MCPServerStatusIconProps } from '~/components/MCP/MCPServerStatusIcon';
|
||||
import type { MCPServerDefinition } from '~/hooks';
|
||||
import MCPServerDialog from './MCPServerDialog';
|
||||
import { getStatusDotColor } from './MCPStatusBadge';
|
||||
import MCPCardActions from './MCPCardActions';
|
||||
import { useMCPServerManager, useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface MCPServerCardProps {
|
||||
server: MCPServerDefinition;
|
||||
getServerStatusIconProps: (serverName: string) => MCPServerStatusIconProps;
|
||||
canCreateEditMCPs: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact card component for displaying an MCP server with status and actions.
|
||||
*
|
||||
* Visual design:
|
||||
* - Status shown via colored dot on icon (no separate badge - avoids redundancy)
|
||||
* - Action buttons clearly indicate available operations
|
||||
* - Consistent with MCPServerMenuItem in chat dropdown
|
||||
*/
|
||||
export default function MCPServerCard({
|
||||
server,
|
||||
getServerStatusIconProps,
|
||||
canCreateEditMCPs,
|
||||
}: MCPServerCardProps) {
|
||||
const localize = useLocalize();
|
||||
const { initializeServer } = useMCPServerManager();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const statusIconProps = getServerStatusIconProps(server.serverName);
|
||||
const {
|
||||
serverStatus,
|
||||
onConfigClick,
|
||||
isInitializing,
|
||||
canCancel,
|
||||
onCancel,
|
||||
hasCustomUserVars = false,
|
||||
} = statusIconProps;
|
||||
|
||||
const canEditThisServer = hasPermissions(server.effectivePermissions, PermissionBits.EDIT);
|
||||
const displayName = server.config?.title || server.serverName;
|
||||
const description = server.config?.description;
|
||||
const statusDotColor = getStatusDotColor(serverStatus, isInitializing);
|
||||
const canEdit = canCreateEditMCPs && canEditThisServer;
|
||||
|
||||
const handleInitialize = () => {
|
||||
initializeServer(server.serverName);
|
||||
};
|
||||
|
||||
const handleEditClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// Determine status text for accessibility
|
||||
const getStatusText = () => {
|
||||
if (isInitializing) return localize('com_nav_mcp_status_initializing');
|
||||
if (!serverStatus) return localize('com_nav_mcp_status_unknown');
|
||||
const { connectionState, requiresOAuth } = serverStatus;
|
||||
if (connectionState === 'connected') return localize('com_nav_mcp_status_connected');
|
||||
if (connectionState === 'connecting') return localize('com_nav_mcp_status_connecting');
|
||||
if (connectionState === 'error') return localize('com_nav_mcp_status_error');
|
||||
if (connectionState === 'disconnected') {
|
||||
return requiresOAuth
|
||||
? localize('com_nav_mcp_status_needs_auth')
|
||||
: localize('com_nav_mcp_status_disconnected');
|
||||
}
|
||||
return localize('com_nav_mcp_status_unknown');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-3 rounded-lg px-3 py-2.5',
|
||||
'border border-border-light bg-transparent',
|
||||
)}
|
||||
aria-label={`${displayName} - ${getStatusText()}`}
|
||||
>
|
||||
{/* Server Icon with Status Dot */}
|
||||
<div className="relative flex-shrink-0">
|
||||
{server.config?.iconPath ? (
|
||||
<img
|
||||
src={server.config.iconPath}
|
||||
className="size-8 rounded-lg object-cover"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-surface-tertiary">
|
||||
<MCPIcon className="size-5 text-text-secondary" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
{/* Status dot - color indicates connection state */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 size-3 rounded-full',
|
||||
'border-2 border-surface-primary',
|
||||
statusDotColor,
|
||||
(isInitializing || serverStatus?.connectionState === 'connecting') && 'animate-pulse',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Server Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate text-sm font-medium text-text-primary">{displayName}</span>
|
||||
{description && <p className="truncate text-xs text-text-secondary">{description}</p>}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex-shrink-0">
|
||||
<MCPCardActions
|
||||
serverName={server.serverName}
|
||||
serverStatus={serverStatus}
|
||||
isInitializing={isInitializing}
|
||||
canCancel={canCancel}
|
||||
hasCustomUserVars={hasCustomUserVars}
|
||||
canEdit={canEdit}
|
||||
onEditClick={handleEditClick}
|
||||
onConfigClick={onConfigClick}
|
||||
onInitialize={handleInitialize}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Dialog - separate from card */}
|
||||
{canEdit && (
|
||||
<MCPServerDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
triggerRef={triggerRef}
|
||||
server={server}
|
||||
>
|
||||
{/* Hidden trigger for focus management */}
|
||||
<button ref={triggerRef} className="sr-only" tabIndex={-1} aria-hidden="true">
|
||||
{localize('com_ui_edit')} {displayName}
|
||||
</button>
|
||||
</MCPServerDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,694 +0,0 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { FormProvider, useForm, Controller } from 'react-hook-form';
|
||||
import * as RadioGroup from '@radix-ui/react-radio-group';
|
||||
import type { MCPServerCreateParams } from 'librechat-data-provider';
|
||||
import {
|
||||
OGDialog,
|
||||
OGDialogTemplate,
|
||||
OGDialogContent,
|
||||
OGDialogHeader,
|
||||
OGDialogTitle,
|
||||
TrashIcon,
|
||||
Button,
|
||||
Label,
|
||||
Checkbox,
|
||||
Spinner,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import {
|
||||
useCreateMCPServerMutation,
|
||||
useUpdateMCPServerMutation,
|
||||
useDeleteMCPServerMutation,
|
||||
} from '~/data-provider/MCP';
|
||||
import MCPAuth, { type AuthConfig, AuthTypeEnum, AuthorizationTypeEnum } from './MCPAuth';
|
||||
import MCPIcon from '~/components/SidePanel/Agents/MCPIcon';
|
||||
import { useLocalize, useLocalizedConfig } from '~/hooks';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { cn } from '~/utils';
|
||||
import {
|
||||
SystemRoles,
|
||||
Permissions,
|
||||
ResourceType,
|
||||
PermissionBits,
|
||||
PermissionTypes,
|
||||
} from 'librechat-data-provider';
|
||||
import { GenericGrantAccessDialog } from '~/components/Sharing';
|
||||
import { useAuthContext, useHasAccess, useResourcePermissions, MCPServerDefinition } from '~/hooks';
|
||||
|
||||
// Form data with nested auth structure matching AuthConfig
|
||||
interface MCPServerFormData {
|
||||
// Server metadata
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
|
||||
// Connection details
|
||||
url: string;
|
||||
type: 'streamable-http' | 'sse';
|
||||
|
||||
// Nested auth configuration (matches AuthConfig directly)
|
||||
auth: AuthConfig;
|
||||
|
||||
// UI-only validation
|
||||
trust: boolean;
|
||||
}
|
||||
|
||||
interface MCPServerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
triggerRef?: React.MutableRefObject<HTMLButtonElement | null>;
|
||||
server?: MCPServerDefinition | null;
|
||||
}
|
||||
|
||||
export default function MCPServerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
triggerRef,
|
||||
server,
|
||||
}: MCPServerDialogProps) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const getLocalizedValue = useLocalizedConfig();
|
||||
|
||||
// Mutations
|
||||
const createMutation = useCreateMCPServerMutation();
|
||||
const updateMutation = useUpdateMCPServerMutation();
|
||||
const deleteMutation = useDeleteMCPServerMutation();
|
||||
|
||||
// Convert McpServer to form data
|
||||
const defaultValues = useMemo<MCPServerFormData>(() => {
|
||||
if (server) {
|
||||
// Determine auth type from server config
|
||||
let authType: AuthTypeEnum = AuthTypeEnum.None;
|
||||
if (server.config.oauth) {
|
||||
authType = AuthTypeEnum.OAuth;
|
||||
} else if ('apiKey' in server.config && server.config.apiKey) {
|
||||
authType = AuthTypeEnum.ServiceHttp;
|
||||
}
|
||||
|
||||
// Extract apiKey config if present
|
||||
const apiKeyConfig = 'apiKey' in server.config ? server.config.apiKey : undefined;
|
||||
|
||||
return {
|
||||
title: server.config.title || '',
|
||||
description: server.config.description || '',
|
||||
url: 'url' in server.config ? server.config.url : '',
|
||||
type: (server.config.type as 'streamable-http' | 'sse') || 'streamable-http',
|
||||
icon: server.config.iconPath || '',
|
||||
auth: {
|
||||
auth_type: authType,
|
||||
api_key: '', // NEVER pre-fill secrets
|
||||
api_key_source: (apiKeyConfig?.source as 'admin' | 'user') || 'admin',
|
||||
api_key_authorization_type:
|
||||
(apiKeyConfig?.authorization_type as AuthorizationTypeEnum) ||
|
||||
AuthorizationTypeEnum.Bearer,
|
||||
api_key_custom_header: apiKeyConfig?.custom_header || '',
|
||||
oauth_client_id: server.config.oauth?.client_id || '',
|
||||
oauth_client_secret: '', // NEVER pre-fill secrets
|
||||
oauth_authorization_url: server.config.oauth?.authorization_url || '',
|
||||
oauth_token_url: server.config.oauth?.token_url || '',
|
||||
oauth_scope: server.config.oauth?.scope || '',
|
||||
server_id: server.serverName, // For edit mode redirect URI
|
||||
},
|
||||
trust: true, // Pre-check for existing servers
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
url: '',
|
||||
type: 'streamable-http',
|
||||
icon: '',
|
||||
auth: {
|
||||
auth_type: AuthTypeEnum.None,
|
||||
api_key: '',
|
||||
api_key_source: 'admin',
|
||||
api_key_authorization_type: AuthorizationTypeEnum.Bearer,
|
||||
api_key_custom_header: '',
|
||||
oauth_client_id: '',
|
||||
oauth_client_secret: '',
|
||||
oauth_authorization_url: '',
|
||||
oauth_token_url: '',
|
||||
oauth_scope: '',
|
||||
},
|
||||
trust: false,
|
||||
};
|
||||
}, [server]);
|
||||
|
||||
const methods = useForm<MCPServerFormData>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
control,
|
||||
watch,
|
||||
reset,
|
||||
} = methods;
|
||||
|
||||
const iconValue = watch('icon');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showRedirectUriDialog, setShowRedirectUriDialog] = useState(false);
|
||||
const [createdServerId, setCreatedServerId] = useState<string | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Reset form when dialog opens or server changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
reset(defaultValues);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- defaultValues is derived from server
|
||||
}, [open, server, reset]);
|
||||
|
||||
const handleIconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64String = reader.result as string;
|
||||
methods.setValue('icon', base64String);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!server) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteMutation.mutateAsync(server.serverName);
|
||||
|
||||
showToast({
|
||||
message: localize('com_ui_mcp_server_deleted'),
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
setShowDeleteConfirm(false);
|
||||
onOpenChange(false);
|
||||
|
||||
setTimeout(() => {
|
||||
triggerRef?.current?.focus();
|
||||
}, 0);
|
||||
} catch (error: any) {
|
||||
let errorMessage = localize('com_ui_error');
|
||||
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as any;
|
||||
if (axiosError.response?.data?.error) {
|
||||
errorMessage = axiosError.response.data.error;
|
||||
}
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: errorMessage,
|
||||
status: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = handleSubmit(async (formData: MCPServerFormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Convert form data to API params - everything goes in config now
|
||||
const config: any = {
|
||||
type: formData.type,
|
||||
url: formData.url,
|
||||
title: formData.title,
|
||||
...(formData.description && { description: formData.description }),
|
||||
...(formData.icon && { iconPath: formData.icon }),
|
||||
};
|
||||
|
||||
// Add OAuth if auth type is oauth and any fields are filled
|
||||
if (
|
||||
formData.auth.auth_type === AuthTypeEnum.OAuth &&
|
||||
(formData.auth.oauth_client_id ||
|
||||
formData.auth.oauth_client_secret ||
|
||||
formData.auth.oauth_authorization_url ||
|
||||
formData.auth.oauth_token_url ||
|
||||
formData.auth.oauth_scope)
|
||||
) {
|
||||
config.oauth = {};
|
||||
if (formData.auth.oauth_client_id) {
|
||||
config.oauth.client_id = formData.auth.oauth_client_id;
|
||||
}
|
||||
if (formData.auth.oauth_client_secret) {
|
||||
config.oauth.client_secret = formData.auth.oauth_client_secret;
|
||||
}
|
||||
if (formData.auth.oauth_authorization_url) {
|
||||
config.oauth.authorization_url = formData.auth.oauth_authorization_url;
|
||||
}
|
||||
if (formData.auth.oauth_token_url) {
|
||||
config.oauth.token_url = formData.auth.oauth_token_url;
|
||||
}
|
||||
if (formData.auth.oauth_scope) {
|
||||
config.oauth.scope = formData.auth.oauth_scope;
|
||||
}
|
||||
}
|
||||
|
||||
// Add API Key if auth type is service_http
|
||||
if (formData.auth.auth_type === AuthTypeEnum.ServiceHttp) {
|
||||
const source = formData.auth.api_key_source || 'admin';
|
||||
const authorizationType = formData.auth.api_key_authorization_type || 'bearer';
|
||||
|
||||
config.apiKey = {
|
||||
source,
|
||||
authorization_type: authorizationType,
|
||||
...(source === 'admin' && formData.auth.api_key && { key: formData.auth.api_key }),
|
||||
...(authorizationType === 'custom' &&
|
||||
formData.auth.api_key_custom_header && {
|
||||
custom_header: formData.auth.api_key_custom_header,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const params: MCPServerCreateParams = {
|
||||
config,
|
||||
};
|
||||
|
||||
// Call mutation based on create vs edit mode
|
||||
const result = server
|
||||
? await updateMutation.mutateAsync({ serverName: server.serverName, data: params })
|
||||
: await createMutation.mutateAsync(params);
|
||||
|
||||
showToast({
|
||||
message: server
|
||||
? localize('com_ui_mcp_server_updated')
|
||||
: localize('com_ui_mcp_server_created'),
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
// Show redirect URI dialog only on creation with OAuth
|
||||
if (!server && formData.auth.auth_type === AuthTypeEnum.OAuth) {
|
||||
setCreatedServerId(result.serverName);
|
||||
setShowRedirectUriDialog(true);
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
triggerRef?.current?.focus();
|
||||
}, 0);
|
||||
} catch (error: any) {
|
||||
let errorMessage = localize('com_ui_error');
|
||||
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as any;
|
||||
if (axiosError.response?.data?.error === 'MCP_INSPECTION_FAILED') {
|
||||
errorMessage = localize('com_ui_mcp_server_connection_failed');
|
||||
} else if (axiosError.response?.data?.error === 'MCP_DOMAIN_NOT_ALLOWED') {
|
||||
errorMessage = localize('com_ui_mcp_domain_not_allowed');
|
||||
} else if (axiosError.response?.data?.error) {
|
||||
errorMessage = axiosError.response.data.error;
|
||||
}
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: errorMessage,
|
||||
status: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
const { user } = useAuthContext();
|
||||
|
||||
// Check global permission to share MCP servers
|
||||
const hasAccessToShareMcpServers = useHasAccess({
|
||||
permissionType: PermissionTypes.MCP_SERVERS,
|
||||
permission: Permissions.SHARE,
|
||||
});
|
||||
|
||||
// Check user's permissions on this specific MCP server
|
||||
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
|
||||
ResourceType.MCPSERVER,
|
||||
server?.dbId || '',
|
||||
);
|
||||
|
||||
const canShareThisServer = hasPermission(PermissionBits.SHARE);
|
||||
|
||||
const shouldShowShareButton =
|
||||
server && // Only in edit mode
|
||||
(user?.role === SystemRoles.ADMIN || canShareThisServer) &&
|
||||
hasAccessToShareMcpServers &&
|
||||
!permissionsLoading;
|
||||
|
||||
const redirectUri = createdServerId
|
||||
? `${window.location.origin}/api/mcp/${createdServerId}/oauth/callback`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Delete confirmation dialog */}
|
||||
<OGDialog
|
||||
open={showDeleteConfirm}
|
||||
onOpenChange={(open) => {
|
||||
setShowDeleteConfirm(open);
|
||||
}}
|
||||
>
|
||||
<OGDialogTemplate
|
||||
title={localize('com_ui_delete')}
|
||||
className="max-w-[450px]"
|
||||
main={
|
||||
<Label className="text-left text-sm font-medium">
|
||||
{localize('com_ui_mcp_server_delete_confirm')}
|
||||
</Label>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: handleDelete,
|
||||
selectClasses:
|
||||
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
|
||||
selectText: isDeleting ? <Spinner /> : localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
|
||||
{/* Post-creation redirect URI dialog */}
|
||||
<OGDialog
|
||||
open={showRedirectUriDialog}
|
||||
onOpenChange={(open) => {
|
||||
setShowRedirectUriDialog(open);
|
||||
if (!open) {
|
||||
onOpenChange(false);
|
||||
setCreatedServerId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<OGDialogContent className="w-full max-w-lg border-none bg-surface-primary text-text-primary">
|
||||
<OGDialogHeader className="border-b border-border-light sm:p-3">
|
||||
<OGDialogTitle>{localize('com_ui_mcp_server_created')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div className="p-4 sm:p-6 sm:pt-4">
|
||||
<p className="mb-4 text-sm text-text-primary">
|
||||
{localize('com_ui_redirect_uri_instructions')}
|
||||
</p>
|
||||
<div className="rounded-lg border border-border-medium bg-surface-secondary p-3">
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary">
|
||||
{localize('com_ui_redirect_uri')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="flex-1 rounded border border-border-medium bg-surface-primary px-3 py-2 text-sm"
|
||||
value={redirectUri}
|
||||
readOnly
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(redirectUri);
|
||||
showToast({
|
||||
message: localize('com_ui_copied'),
|
||||
status: 'success',
|
||||
});
|
||||
}}
|
||||
variant="outline"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{localize('com_ui_copy_link')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowRedirectUriDialog(false);
|
||||
onOpenChange(false);
|
||||
setCreatedServerId(null);
|
||||
}}
|
||||
variant="submit"
|
||||
className="text-white"
|
||||
>
|
||||
{localize('com_ui_done')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
|
||||
{/* Main MCP Server Dialog */}
|
||||
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
|
||||
{children}
|
||||
<OGDialogTemplate
|
||||
title={server ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server')}
|
||||
description={
|
||||
server
|
||||
? localize('com_ui_edit_mcp_server_dialog_description', {
|
||||
serverName: server.serverName,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
className="w-11/12 md:max-w-2xl"
|
||||
main={
|
||||
<FormProvider {...methods}>
|
||||
<div className="max-h-[60vh] space-y-4 overflow-y-auto px-1">
|
||||
{/* Icon Picker */}
|
||||
<div>
|
||||
<MCPIcon icon={iconValue} onIconChange={handleIconChange} />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title" className="text-sm font-medium">
|
||||
{localize('com_ui_name')} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<input
|
||||
autoComplete="off"
|
||||
{...register('title', {
|
||||
required: true,
|
||||
pattern: {
|
||||
value: /^[a-zA-Z0-9 ]+$/,
|
||||
message: localize('com_ui_mcp_title_invalid'),
|
||||
},
|
||||
})}
|
||||
className="border-token-border-medium flex h-9 w-full rounded-lg border bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-text-secondary-alt focus:ring-1 focus:ring-border-light"
|
||||
placeholder={localize('com_agents_mcp_name_placeholder')}
|
||||
/>
|
||||
{errors.title && (
|
||||
<span className="text-xs text-red-500">
|
||||
{errors.title.type === 'pattern'
|
||||
? errors.title.message
|
||||
: localize('com_ui_field_required')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-sm font-medium">
|
||||
{localize('com_ui_description')}
|
||||
<span className="ml-1 text-xs text-text-secondary-alt">
|
||||
{localize('com_ui_optional')}
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
id="description"
|
||||
{...register('description')}
|
||||
className="border-token-border-medium flex h-9 w-full rounded-lg border bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-text-secondary-alt focus:ring-1 focus:ring-border-light"
|
||||
placeholder={localize('com_agents_mcp_description_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url" className="text-sm font-medium">
|
||||
{localize('com_ui_mcp_url')} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<input
|
||||
id="url"
|
||||
{...register('url', {
|
||||
required: true,
|
||||
})}
|
||||
className="border-token-border-medium flex h-9 w-full rounded-lg border bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-text-secondary-alt focus:ring-1 focus:ring-border-light"
|
||||
placeholder="https://mcp.example.com"
|
||||
/>
|
||||
{errors.url && (
|
||||
<span className="text-xs text-red-500">
|
||||
{errors.url.type === 'required'
|
||||
? localize('com_ui_field_required')
|
||||
: errors.url.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Server Type */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type" className="text-sm font-medium">
|
||||
{localize('com_ui_mcp_server_type')}
|
||||
</Label>
|
||||
<Controller
|
||||
name="type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<RadioGroup.Root
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="type-streamable-http"
|
||||
className="flex cursor-pointer items-center gap-1"
|
||||
>
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
value="streamable-http"
|
||||
id="type-streamable-http"
|
||||
className={cn(
|
||||
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
|
||||
'border-border-heavy bg-surface-primary',
|
||||
)}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
|
||||
</RadioGroup.Item>
|
||||
{localize('com_ui_mcp_type_streamable_http')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="type-sse"
|
||||
className="flex cursor-pointer items-center gap-1"
|
||||
>
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
value="sse"
|
||||
id="type-sse"
|
||||
className={cn(
|
||||
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
|
||||
'border-border-heavy bg-surface-primary',
|
||||
)}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
|
||||
</RadioGroup.Item>
|
||||
{localize('com_ui_mcp_type_sse')}
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Authentication */}
|
||||
<Controller
|
||||
name="auth"
|
||||
control={control}
|
||||
render={({ field }) => <MCPAuth value={field.value} onChange={field.onChange} />}
|
||||
/>
|
||||
|
||||
{/* Trust Checkbox */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Controller
|
||||
name="trust"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="trust"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
aria-labelledby="trust-this-mcp-label"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Label
|
||||
id="trust-this-mcp-label"
|
||||
htmlFor="trust"
|
||||
className="flex cursor-pointer flex-col break-words text-sm font-medium"
|
||||
>
|
||||
<span>
|
||||
{startupConfig?.interface?.mcpServers?.trustCheckbox?.label ? (
|
||||
<span
|
||||
/** No sanitization required. trusted admin-controlled source (yml) */
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getLocalizedValue(
|
||||
startupConfig.interface.mcpServers.trustCheckbox.label,
|
||||
localize('com_ui_trust_app'),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
localize('com_ui_trust_app')
|
||||
)}{' '}
|
||||
<span className="text-red-500">*</span>
|
||||
</span>
|
||||
<span className="text-xs font-normal text-text-secondary">
|
||||
{startupConfig?.interface?.mcpServers?.trustCheckbox?.subLabel ? (
|
||||
<span
|
||||
/** No sanitization required. trusted admin-controlled source (yml) */
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getLocalizedValue(
|
||||
startupConfig.interface.mcpServers.trustCheckbox.subLabel,
|
||||
localize('com_agents_mcp_trust_subtext'),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
localize('com_agents_mcp_trust_subtext')
|
||||
)}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
{errors.trust && (
|
||||
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
|
||||
)}
|
||||
</div>
|
||||
</FormProvider>
|
||||
}
|
||||
footerClassName="sm:justify-between"
|
||||
leftButtons={
|
||||
server ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Delete MCP server"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={isSubmitting || isDeleting}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2 text-red-500">
|
||||
<TrashIcon />
|
||||
</div>
|
||||
</Button>
|
||||
{shouldShowShareButton && (
|
||||
<GenericGrantAccessDialog
|
||||
resourceDbId={server.dbId}
|
||||
resourceName={server.config.title || ''}
|
||||
resourceType={ResourceType.MCPSERVER}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
buttons={
|
||||
<Button
|
||||
type="button"
|
||||
variant="submit"
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="text-white"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
localize(server ? 'com_ui_update' : 'com_ui_create')
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { FormProvider } from 'react-hook-form';
|
||||
import ConnectionSection from './sections/ConnectionSection';
|
||||
import BasicInfoSection from './sections/BasicInfoSection';
|
||||
import TransportSection from './sections/TransportSection';
|
||||
import AuthSection from './sections/AuthSection';
|
||||
import TrustSection from './sections/TrustSection';
|
||||
import type { useMCPServerForm } from './hooks/useMCPServerForm';
|
||||
|
||||
interface MCPServerFormProps {
|
||||
formHook: ReturnType<typeof useMCPServerForm>;
|
||||
}
|
||||
|
||||
export default function MCPServerForm({ formHook }: MCPServerFormProps) {
|
||||
const { methods, isEditMode, server } = formHook;
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-1 py-1">
|
||||
<BasicInfoSection />
|
||||
|
||||
<ConnectionSection />
|
||||
|
||||
<TransportSection />
|
||||
|
||||
<AuthSection isEditMode={isEditMode} serverName={server?.serverName} />
|
||||
|
||||
<TrustSection />
|
||||
</div>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
import { useEffect, useMemo, useCallback, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { MCPServerCreateParams } from 'librechat-data-provider';
|
||||
import {
|
||||
useCreateMCPServerMutation,
|
||||
useUpdateMCPServerMutation,
|
||||
useDeleteMCPServerMutation,
|
||||
} from '~/data-provider/MCP';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { extractServerNameFromUrl, isValidUrl, normalizeUrl } from '../utils/urlUtils';
|
||||
import type { MCPServerDefinition } from '~/hooks';
|
||||
|
||||
// Auth type enum
|
||||
export enum AuthTypeEnum {
|
||||
None = 'none',
|
||||
ServiceHttp = 'service_http',
|
||||
OAuth = 'oauth',
|
||||
}
|
||||
|
||||
// Authorization type enum
|
||||
export enum AuthorizationTypeEnum {
|
||||
Basic = 'basic',
|
||||
Bearer = 'bearer',
|
||||
Custom = 'custom',
|
||||
}
|
||||
|
||||
// Auth configuration interface
|
||||
export interface AuthConfig {
|
||||
auth_type: AuthTypeEnum;
|
||||
api_key?: string;
|
||||
api_key_source?: 'admin' | 'user';
|
||||
api_key_authorization_type?: AuthorizationTypeEnum;
|
||||
api_key_custom_header?: string;
|
||||
oauth_client_id?: string;
|
||||
oauth_client_secret?: string;
|
||||
oauth_authorization_url?: string;
|
||||
oauth_token_url?: string;
|
||||
oauth_scope?: string;
|
||||
server_id?: string;
|
||||
}
|
||||
|
||||
// Form data interface
|
||||
export interface MCPServerFormData {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
url: string;
|
||||
type: 'streamable-http' | 'sse';
|
||||
auth: AuthConfig;
|
||||
trust: boolean;
|
||||
}
|
||||
|
||||
interface UseMCPServerFormProps {
|
||||
server?: MCPServerDefinition | null;
|
||||
onSuccess?: (serverName: string, isOAuth: boolean) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function useMCPServerForm({ server, onSuccess, onClose }: UseMCPServerFormProps) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
// Mutations
|
||||
const createMutation = useCreateMCPServerMutation();
|
||||
const updateMutation = useUpdateMCPServerMutation();
|
||||
const deleteMutation = useDeleteMCPServerMutation();
|
||||
|
||||
// State
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Check if editing existing server
|
||||
const isEditMode = !!server;
|
||||
|
||||
// Default form values
|
||||
const defaultValues = useMemo<MCPServerFormData>(() => {
|
||||
if (server) {
|
||||
let authType = AuthTypeEnum.None;
|
||||
if (server.config.oauth) {
|
||||
authType = AuthTypeEnum.OAuth;
|
||||
} else if ('apiKey' in server.config && server.config.apiKey) {
|
||||
authType = AuthTypeEnum.ServiceHttp;
|
||||
}
|
||||
|
||||
const apiKeyConfig = 'apiKey' in server.config ? server.config.apiKey : undefined;
|
||||
|
||||
return {
|
||||
title: server.config.title || '',
|
||||
description: server.config.description || '',
|
||||
url: 'url' in server.config ? server.config.url : '',
|
||||
type: (server.config.type as 'streamable-http' | 'sse') || 'streamable-http',
|
||||
icon: server.config.iconPath || '',
|
||||
auth: {
|
||||
auth_type: authType,
|
||||
api_key: '', // Never pre-fill secrets
|
||||
api_key_source: (apiKeyConfig?.source as 'admin' | 'user') || 'admin',
|
||||
api_key_authorization_type:
|
||||
(apiKeyConfig?.authorization_type as AuthorizationTypeEnum) ||
|
||||
AuthorizationTypeEnum.Bearer,
|
||||
api_key_custom_header: apiKeyConfig?.custom_header || '',
|
||||
oauth_client_id: server.config.oauth?.client_id || '',
|
||||
oauth_client_secret: '', // Never pre-fill secrets
|
||||
oauth_authorization_url: server.config.oauth?.authorization_url || '',
|
||||
oauth_token_url: server.config.oauth?.token_url || '',
|
||||
oauth_scope: server.config.oauth?.scope || '',
|
||||
server_id: server.serverName,
|
||||
},
|
||||
trust: true, // Pre-checked for existing servers
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
url: '',
|
||||
type: 'streamable-http',
|
||||
icon: '',
|
||||
auth: {
|
||||
auth_type: AuthTypeEnum.None,
|
||||
api_key: '',
|
||||
api_key_source: 'admin',
|
||||
api_key_authorization_type: AuthorizationTypeEnum.Bearer,
|
||||
api_key_custom_header: '',
|
||||
oauth_client_id: '',
|
||||
oauth_client_secret: '',
|
||||
oauth_authorization_url: '',
|
||||
oauth_token_url: '',
|
||||
oauth_scope: '',
|
||||
},
|
||||
trust: false,
|
||||
};
|
||||
}, [server]);
|
||||
|
||||
// Form instance
|
||||
const methods = useForm<MCPServerFormData>({
|
||||
defaultValues,
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const { reset, watch, setValue, getValues } = methods;
|
||||
|
||||
// Watch URL for auto-fill
|
||||
const watchedUrl = watch('url');
|
||||
const watchedTitle = watch('title');
|
||||
|
||||
// Auto-fill title from URL when title is empty
|
||||
const handleUrlChange = useCallback(
|
||||
(url: string) => {
|
||||
const currentTitle = getValues('title');
|
||||
if (!currentTitle && url) {
|
||||
const normalizedUrl = normalizeUrl(url);
|
||||
if (isValidUrl(normalizedUrl)) {
|
||||
const suggestedName = extractServerNameFromUrl(normalizedUrl);
|
||||
if (suggestedName) {
|
||||
setValue('title', suggestedName, { shouldValidate: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[getValues, setValue],
|
||||
);
|
||||
|
||||
// Watch for URL changes
|
||||
useEffect(() => {
|
||||
handleUrlChange(watchedUrl);
|
||||
}, [watchedUrl, handleUrlChange]);
|
||||
|
||||
// Reset form when dialog opens
|
||||
const resetForm = useCallback(() => {
|
||||
reset(defaultValues);
|
||||
}, [reset, defaultValues]);
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = methods.handleSubmit(async (formData: MCPServerFormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const config: Record<string, unknown> = {
|
||||
type: formData.type,
|
||||
url: formData.url,
|
||||
title: formData.title,
|
||||
...(formData.description && { description: formData.description }),
|
||||
...(formData.icon && { iconPath: formData.icon }),
|
||||
};
|
||||
|
||||
// Add OAuth configuration
|
||||
if (
|
||||
formData.auth.auth_type === AuthTypeEnum.OAuth &&
|
||||
(formData.auth.oauth_client_id ||
|
||||
formData.auth.oauth_client_secret ||
|
||||
formData.auth.oauth_authorization_url ||
|
||||
formData.auth.oauth_token_url ||
|
||||
formData.auth.oauth_scope)
|
||||
) {
|
||||
config.oauth = {
|
||||
...(formData.auth.oauth_client_id && { client_id: formData.auth.oauth_client_id }),
|
||||
...(formData.auth.oauth_client_secret && {
|
||||
client_secret: formData.auth.oauth_client_secret,
|
||||
}),
|
||||
...(formData.auth.oauth_authorization_url && {
|
||||
authorization_url: formData.auth.oauth_authorization_url,
|
||||
}),
|
||||
...(formData.auth.oauth_token_url && { token_url: formData.auth.oauth_token_url }),
|
||||
...(formData.auth.oauth_scope && { scope: formData.auth.oauth_scope }),
|
||||
};
|
||||
}
|
||||
|
||||
// Add API Key configuration
|
||||
if (formData.auth.auth_type === AuthTypeEnum.ServiceHttp) {
|
||||
const source = formData.auth.api_key_source || 'admin';
|
||||
const authorizationType = formData.auth.api_key_authorization_type || 'bearer';
|
||||
|
||||
config.apiKey = {
|
||||
source,
|
||||
authorization_type: authorizationType,
|
||||
...(source === 'admin' && formData.auth.api_key && { key: formData.auth.api_key }),
|
||||
...(authorizationType === 'custom' &&
|
||||
formData.auth.api_key_custom_header && {
|
||||
custom_header: formData.auth.api_key_custom_header,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const params: MCPServerCreateParams = { config };
|
||||
|
||||
const result = server
|
||||
? await updateMutation.mutateAsync({ serverName: server.serverName, data: params })
|
||||
: await createMutation.mutateAsync(params);
|
||||
|
||||
showToast({
|
||||
message: server
|
||||
? localize('com_ui_mcp_server_updated')
|
||||
: localize('com_ui_mcp_server_created'),
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
const isOAuth = formData.auth.auth_type === AuthTypeEnum.OAuth;
|
||||
onSuccess?.(result.serverName, isOAuth && !server);
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = localize('com_ui_error');
|
||||
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as { response?: { data?: { error?: string } } };
|
||||
if (axiosError.response?.data?.error === 'MCP_INSPECTION_FAILED') {
|
||||
errorMessage = localize('com_ui_mcp_server_connection_failed');
|
||||
} else if (axiosError.response?.data?.error === 'MCP_DOMAIN_NOT_ALLOWED') {
|
||||
errorMessage = localize('com_ui_mcp_domain_not_allowed');
|
||||
} else if (axiosError.response?.data?.error) {
|
||||
errorMessage = axiosError.response.data.error;
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: errorMessage,
|
||||
status: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!server) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteMutation.mutateAsync(server.serverName);
|
||||
|
||||
showToast({
|
||||
message: localize('com_ui_mcp_server_deleted'),
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
onClose?.();
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = localize('com_ui_error');
|
||||
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as { response?: { data?: { error?: string } } };
|
||||
if (axiosError.response?.data?.error) {
|
||||
errorMessage = axiosError.response.data.error;
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: errorMessage,
|
||||
status: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [server, deleteMutation, showToast, localize, onClose]);
|
||||
|
||||
return {
|
||||
methods,
|
||||
isEditMode,
|
||||
isSubmitting,
|
||||
isDeleting,
|
||||
onSubmit,
|
||||
handleDelete,
|
||||
resetForm,
|
||||
server,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
OGDialog,
|
||||
OGDialogTemplate,
|
||||
OGDialogContent,
|
||||
OGDialogHeader,
|
||||
OGDialogTitle,
|
||||
Button,
|
||||
TrashIcon,
|
||||
Spinner,
|
||||
} from '@librechat/client';
|
||||
import {
|
||||
SystemRoles,
|
||||
Permissions,
|
||||
ResourceType,
|
||||
PermissionBits,
|
||||
PermissionTypes,
|
||||
} from 'librechat-data-provider';
|
||||
import { GenericGrantAccessDialog } from '~/components/Sharing';
|
||||
import { useAuthContext, useHasAccess, useResourcePermissions, MCPServerDefinition } from '~/hooks';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useMCPServerForm } from './hooks/useMCPServerForm';
|
||||
import MCPServerForm from './MCPServerForm';
|
||||
|
||||
interface MCPServerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
triggerRef?: React.MutableRefObject<HTMLButtonElement | null>;
|
||||
server?: MCPServerDefinition | null;
|
||||
}
|
||||
|
||||
export default function MCPServerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
triggerRef,
|
||||
server,
|
||||
}: MCPServerDialogProps) {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
|
||||
// State for dialogs
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showRedirectUriDialog, setShowRedirectUriDialog] = useState(false);
|
||||
const [createdServerId, setCreatedServerId] = useState<string | null>(null);
|
||||
|
||||
// Form hook
|
||||
const formHook = useMCPServerForm({
|
||||
server,
|
||||
onSuccess: (serverName, isOAuth) => {
|
||||
if (isOAuth) {
|
||||
setCreatedServerId(serverName);
|
||||
setShowRedirectUriDialog(true);
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => {
|
||||
triggerRef?.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
onClose: () => {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => {
|
||||
triggerRef?.current?.focus();
|
||||
}, 0);
|
||||
},
|
||||
});
|
||||
|
||||
const { isEditMode, isSubmitting, isDeleting, onSubmit, handleDelete, resetForm } = formHook;
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetForm();
|
||||
}
|
||||
}, [open, resetForm]);
|
||||
|
||||
// Permissions
|
||||
const hasAccessToShareMcpServers = useHasAccess({
|
||||
permissionType: PermissionTypes.MCP_SERVERS,
|
||||
permission: Permissions.SHARE,
|
||||
});
|
||||
|
||||
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
|
||||
ResourceType.MCPSERVER,
|
||||
server?.dbId || '',
|
||||
);
|
||||
|
||||
const canShareThisServer = hasPermission(PermissionBits.SHARE);
|
||||
|
||||
const shouldShowShareButton =
|
||||
server &&
|
||||
(user?.role === SystemRoles.ADMIN || canShareThisServer) &&
|
||||
hasAccessToShareMcpServers &&
|
||||
!permissionsLoading;
|
||||
|
||||
const redirectUri = createdServerId
|
||||
? `${window.location.origin}/api/mcp/${createdServerId}/oauth/callback`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Delete confirmation dialog */}
|
||||
<OGDialog open={showDeleteConfirm} onOpenChange={(isOpen) => setShowDeleteConfirm(isOpen)}>
|
||||
<OGDialogTemplate
|
||||
title={localize('com_ui_delete')}
|
||||
className="max-w-[450px]"
|
||||
main={<p className="text-left text-sm">{localize('com_ui_mcp_server_delete_confirm')}</p>}
|
||||
selection={{
|
||||
selectHandler: handleDelete,
|
||||
selectClasses:
|
||||
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
|
||||
selectText: isDeleting ? <Spinner /> : localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
|
||||
{/* Post-creation redirect URI dialog */}
|
||||
<OGDialog
|
||||
open={showRedirectUriDialog}
|
||||
onOpenChange={(isOpen) => {
|
||||
setShowRedirectUriDialog(isOpen);
|
||||
if (!isOpen) {
|
||||
onOpenChange(false);
|
||||
setCreatedServerId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<OGDialogContent className="w-full max-w-lg border-none bg-surface-primary text-text-primary">
|
||||
<OGDialogHeader className="border-b border-border-light px-4 py-3">
|
||||
<OGDialogTitle>{localize('com_ui_mcp_server_created')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div className="space-y-4 p-4">
|
||||
<p className="text-sm text-text-secondary">
|
||||
{localize('com_ui_redirect_uri_instructions')}
|
||||
</p>
|
||||
<div className="rounded-lg border border-border-medium bg-surface-secondary p-3">
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary">
|
||||
{localize('com_ui_redirect_uri')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="flex-1 rounded border border-border-medium bg-surface-primary px-3 py-2 text-sm"
|
||||
value={redirectUri}
|
||||
readOnly
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(redirectUri);
|
||||
}}
|
||||
variant="outline"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{localize('com_ui_copy_link')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowRedirectUriDialog(false);
|
||||
onOpenChange(false);
|
||||
setCreatedServerId(null);
|
||||
}}
|
||||
variant="submit"
|
||||
className="text-white"
|
||||
>
|
||||
{localize('com_ui_done')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
|
||||
{/* Main Dialog */}
|
||||
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
|
||||
{children}
|
||||
<OGDialogTemplate
|
||||
title={
|
||||
isEditMode ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server')
|
||||
}
|
||||
description={
|
||||
isEditMode
|
||||
? localize('com_ui_edit_mcp_server_dialog_description', {
|
||||
serverName: server?.serverName || '',
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
className="w-11/12 md:max-w-3xl"
|
||||
main={<MCPServerForm formHook={formHook} />}
|
||||
footerClassName="sm:justify-between"
|
||||
leftButtons={
|
||||
isEditMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={localize('com_ui_delete')}
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={isSubmitting || isDeleting}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2 text-red-500">
|
||||
<TrashIcon />
|
||||
</div>
|
||||
</Button>
|
||||
{shouldShowShareButton && server && (
|
||||
<GenericGrantAccessDialog
|
||||
resourceDbId={server.dbId}
|
||||
resourceName={server.config.title || ''}
|
||||
resourceType={ResourceType.MCPSERVER}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
buttons={
|
||||
<Button
|
||||
type="button"
|
||||
variant="submit"
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="text-white"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
localize(isEditMode ? 'com_ui_update' : 'com_ui_create')
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { Label, Input, Checkbox, SecretInput, Radio, useToastContext } from '@librechat/client';
|
||||
import { Copy, CopyCheck } from 'lucide-react';
|
||||
import { useLocalize, useCopyToClipboard } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import { AuthTypeEnum, AuthorizationTypeEnum } from '../hooks/useMCPServerForm';
|
||||
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
|
||||
|
||||
interface AuthSectionProps {
|
||||
isEditMode: boolean;
|
||||
serverName?: string;
|
||||
}
|
||||
|
||||
export default function AuthSection({ isEditMode, serverName }: AuthSectionProps) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const {
|
||||
register,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useFormContext<MCPServerFormData>();
|
||||
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
|
||||
const authType = useWatch<MCPServerFormData, 'auth.auth_type'>({
|
||||
name: 'auth.auth_type',
|
||||
}) as AuthTypeEnum;
|
||||
|
||||
const apiKeySource = useWatch<MCPServerFormData, 'auth.api_key_source'>({
|
||||
name: 'auth.api_key_source',
|
||||
}) as 'admin' | 'user';
|
||||
|
||||
const authorizationType = useWatch<MCPServerFormData, 'auth.api_key_authorization_type'>({
|
||||
name: 'auth.api_key_authorization_type',
|
||||
}) as AuthorizationTypeEnum;
|
||||
|
||||
const redirectUri = serverName
|
||||
? `${window.location.origin}/api/mcp/${serverName}/oauth/callback`
|
||||
: '';
|
||||
|
||||
const copyLink = useCopyToClipboard({ text: redirectUri });
|
||||
|
||||
const authTypeOptions = useMemo(
|
||||
() => [
|
||||
{ value: AuthTypeEnum.None, label: localize('com_ui_no_auth') },
|
||||
{ value: AuthTypeEnum.ServiceHttp, label: localize('com_ui_api_key') },
|
||||
{ value: AuthTypeEnum.OAuth, label: 'OAuth' },
|
||||
],
|
||||
[localize],
|
||||
);
|
||||
|
||||
const headerFormatOptions = useMemo(
|
||||
() => [
|
||||
{ value: AuthorizationTypeEnum.Bearer, label: localize('com_ui_bearer') },
|
||||
{ value: AuthorizationTypeEnum.Basic, label: localize('com_ui_basic') },
|
||||
{ value: AuthorizationTypeEnum.Custom, label: localize('com_ui_custom') },
|
||||
],
|
||||
[localize],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Auth Type Radio */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">{localize('com_ui_authentication')}</Label>
|
||||
<Radio
|
||||
options={authTypeOptions}
|
||||
value={authType || AuthTypeEnum.None}
|
||||
onChange={(val) => setValue('auth.auth_type', val as AuthTypeEnum)}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Key Fields */}
|
||||
{authType === AuthTypeEnum.ServiceHttp && (
|
||||
<div className="space-y-3 rounded-lg border border-border-light p-3">
|
||||
{/* User provides own key checkbox */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="user_provides_key"
|
||||
checked={apiKeySource === 'user'}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue('auth.api_key_source', checked ? 'user' : 'admin')
|
||||
}
|
||||
aria-label={localize('com_ui_user_provides_key')}
|
||||
/>
|
||||
<label htmlFor="user_provides_key" className="cursor-pointer text-sm">
|
||||
{localize('com_ui_user_provides_key')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* API Key input - only when admin provides */}
|
||||
{apiKeySource !== 'user' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="api_key" className="text-sm font-medium">
|
||||
{localize('com_ui_api_key')}
|
||||
</Label>
|
||||
<SecretInput id="api_key" placeholder="sk-..." {...register('auth.api_key')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header Format Radio */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">{localize('com_ui_header_format')}</Label>
|
||||
<Radio
|
||||
options={headerFormatOptions}
|
||||
value={authorizationType || AuthorizationTypeEnum.Bearer}
|
||||
onChange={(val) =>
|
||||
setValue('auth.api_key_authorization_type', val as AuthorizationTypeEnum)
|
||||
}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom header name */}
|
||||
{authorizationType === AuthorizationTypeEnum.Custom && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="custom_header" className="text-sm font-medium">
|
||||
{localize('com_ui_custom_header_name')}
|
||||
</Label>
|
||||
<Input
|
||||
id="custom_header"
|
||||
placeholder="X-Api-Key"
|
||||
{...register('auth.api_key_custom_header')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth Fields */}
|
||||
{authType === AuthTypeEnum.OAuth && (
|
||||
<div className="space-y-3 rounded-lg border border-border-light p-3">
|
||||
{/* Client ID & Secret in a grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="oauth_client_id" className="text-sm font-medium">
|
||||
{localize('com_ui_client_id')}{' '}
|
||||
{!isEditMode && <span className="text-text-secondary">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id="oauth_client_id"
|
||||
autoComplete="off"
|
||||
placeholder={isEditMode ? localize('com_ui_leave_blank_to_keep') : ''}
|
||||
{...register('auth.oauth_client_id', { required: !isEditMode })}
|
||||
className={cn(errors.auth?.oauth_client_id && 'border-red-500')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="oauth_client_secret" className="text-sm font-medium">
|
||||
{localize('com_ui_client_secret')}{' '}
|
||||
{!isEditMode && <span className="text-text-secondary">*</span>}
|
||||
</Label>
|
||||
<SecretInput
|
||||
id="oauth_client_secret"
|
||||
placeholder={isEditMode ? localize('com_ui_leave_blank_to_keep') : ''}
|
||||
{...register('auth.oauth_client_secret', { required: !isEditMode })}
|
||||
className={cn(errors.auth?.oauth_client_secret && 'border-red-500')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth URL & Token URL in a grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="oauth_authorization_url" className="text-sm font-medium">
|
||||
{localize('com_ui_auth_url')}
|
||||
</Label>
|
||||
<Input
|
||||
id="oauth_authorization_url"
|
||||
placeholder="https://..."
|
||||
{...register('auth.oauth_authorization_url')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="oauth_token_url" className="text-sm font-medium">
|
||||
{localize('com_ui_token_url')}
|
||||
</Label>
|
||||
<Input
|
||||
id="oauth_token_url"
|
||||
placeholder="https://..."
|
||||
{...register('auth.oauth_token_url')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scope */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="oauth_scope" className="text-sm font-medium">
|
||||
{localize('com_ui_scope')}
|
||||
</Label>
|
||||
<Input id="oauth_scope" placeholder="read write" {...register('auth.oauth_scope')} />
|
||||
</div>
|
||||
|
||||
{/* Redirect URI */}
|
||||
{isEditMode && redirectUri && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">{localize('com_ui_redirect_uri')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
readOnly
|
||||
value={redirectUri}
|
||||
className="flex-1 text-xs text-text-secondary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isCopying) return;
|
||||
showToast({ message: localize('com_ui_copied_to_clipboard') });
|
||||
copyLink(setIsCopying);
|
||||
}}
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border-light text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary"
|
||||
aria-label={localize('com_ui_copy_link')}
|
||||
>
|
||||
{isCopying ? <CopyCheck className="size-4" /> : <Copy className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { useFormContext } from 'react-hook-form';
|
||||
import { Input, Label, TextareaAutosize } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import MCPIcon from '~/components/SidePanel/Agents/MCPIcon';
|
||||
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
|
||||
|
||||
export default function BasicInfoSection() {
|
||||
const localize = useLocalize();
|
||||
const {
|
||||
register,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useFormContext<MCPServerFormData>();
|
||||
|
||||
const iconValue = watch('icon');
|
||||
|
||||
const handleIconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64String = reader.result as string;
|
||||
setValue('icon', base64String);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Icon + Name row */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<MCPIcon icon={iconValue} onIconChange={handleIconChange} />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label htmlFor="title" className="text-sm font-medium">
|
||||
{localize('com_ui_name')} <span className="text-text-secondary">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
autoComplete="off"
|
||||
placeholder={localize('com_agents_mcp_name_placeholder')}
|
||||
{...register('title', {
|
||||
required: localize('com_ui_field_required'),
|
||||
pattern: {
|
||||
value: /^[a-zA-Z0-9 ]+$/,
|
||||
message: localize('com_ui_mcp_title_invalid'),
|
||||
},
|
||||
})}
|
||||
className={cn(errors.title && 'border-red-500 focus:border-red-500')}
|
||||
/>
|
||||
{errors.title && <p className="text-xs text-red-500">{errors.title.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description - always visible, full width */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description" className="text-sm font-medium">
|
||||
{localize('com_ui_description')}{' '}
|
||||
<span className="text-xs text-text-secondary">{localize('com_ui_optional')}</span>
|
||||
</Label>
|
||||
<TextareaAutosize
|
||||
id="description"
|
||||
aria-label={localize('com_ui_description')}
|
||||
placeholder={localize('com_agents_mcp_description_placeholder')}
|
||||
{...register('description')}
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
className="w-full resize-none rounded-lg border border-input bg-transparent px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { useFormContext } from 'react-hook-form';
|
||||
import { Input, Label } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import { isValidUrl, normalizeUrl } from '../utils/urlUtils';
|
||||
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
|
||||
|
||||
export default function ConnectionSection() {
|
||||
const localize = useLocalize();
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<MCPServerFormData>();
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="url" className="text-sm font-medium">
|
||||
{localize('com_ui_mcp_url')} <span className="text-text-secondary">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="url"
|
||||
autoComplete="off"
|
||||
placeholder={localize('com_ui_mcp_server_url_placeholder')}
|
||||
{...register('url', {
|
||||
required: localize('com_ui_field_required'),
|
||||
validate: (value) => {
|
||||
const normalized = normalizeUrl(value);
|
||||
return isValidUrl(normalized) || localize('com_ui_mcp_invalid_url');
|
||||
},
|
||||
})}
|
||||
className={cn(errors.url && 'border-red-500 focus:border-red-500')}
|
||||
/>
|
||||
{errors.url && <p className="text-xs text-red-500">{errors.url.message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { Label, Radio } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
|
||||
|
||||
export default function TransportSection() {
|
||||
const localize = useLocalize();
|
||||
const { setValue } = useFormContext<MCPServerFormData>();
|
||||
|
||||
const transportType = useWatch<MCPServerFormData, 'type'>({
|
||||
name: 'type',
|
||||
});
|
||||
|
||||
const handleTransportChange = (value: string) => {
|
||||
setValue('type', value as 'streamable-http' | 'sse');
|
||||
};
|
||||
|
||||
const transportOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'streamable-http', label: localize('com_ui_mcp_type_streamable_http') },
|
||||
{ value: 'sse', label: localize('com_ui_mcp_type_sse') },
|
||||
],
|
||||
[localize],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{localize('com_ui_mcp_transport')}</Label>
|
||||
<Radio
|
||||
options={transportOptions}
|
||||
value={transportType}
|
||||
onChange={handleTransportChange}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
import { Checkbox, Label } from '@librechat/client';
|
||||
import { useLocalize, useLocalizedConfig } from '~/hooks';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
|
||||
|
||||
export default function TrustSection() {
|
||||
const localize = useLocalize();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const getLocalizedValue = useLocalizedConfig();
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext<MCPServerFormData>();
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border-light bg-surface-secondary p-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<Controller
|
||||
name="trust"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="trust"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
aria-labelledby="trust-label"
|
||||
aria-describedby="trust-description"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Label
|
||||
id="trust-label"
|
||||
htmlFor="trust"
|
||||
className="flex cursor-pointer flex-col gap-0.5 text-sm"
|
||||
>
|
||||
<span className="font-medium text-text-primary">
|
||||
{startupConfig?.interface?.mcpServers?.trustCheckbox?.label ? (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getLocalizedValue(
|
||||
startupConfig.interface.mcpServers.trustCheckbox.label,
|
||||
localize('com_ui_trust_app'),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
localize('com_ui_trust_app')
|
||||
)}{' '}
|
||||
<span className="text-text-secondary">*</span>
|
||||
</span>
|
||||
<span id="trust-description" className="text-xs font-normal text-text-secondary">
|
||||
{startupConfig?.interface?.mcpServers?.trustCheckbox?.subLabel ? (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getLocalizedValue(
|
||||
startupConfig.interface.mcpServers.trustCheckbox.subLabel,
|
||||
localize('com_agents_mcp_trust_subtext'),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
localize('com_agents_mcp_trust_subtext')
|
||||
)}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
{errors.trust && (
|
||||
<p className="mt-2 text-xs text-red-500">{localize('com_ui_field_required')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* URL parsing and auto-fill utilities for MCP Server Dialog
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extracts a readable server name from a URL
|
||||
* Examples:
|
||||
* "https://api.example.com/mcp" → "Example API"
|
||||
* "https://mcp.github.com" → "Github"
|
||||
* "https://tools.anthropic.com" → "Anthropic Tools"
|
||||
*/
|
||||
export function extractServerNameFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const hostname = parsed.hostname;
|
||||
|
||||
// Remove common prefixes and suffixes
|
||||
let name = hostname
|
||||
.replace(/^(www\.|api\.|mcp\.|tools\.)/, '')
|
||||
.replace(/\.(com|org|io|net|dev|ai|app)$/, '');
|
||||
|
||||
// Split by dots and take the main domain part
|
||||
const parts = name.split('.');
|
||||
name = parts[0] || name;
|
||||
|
||||
// Convert to title case and add context based on subdomain
|
||||
const titleCase = name
|
||||
.split(/[-_]/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
|
||||
// Add suffix based on original subdomain (but not for mcp. prefix)
|
||||
if (hostname.startsWith('api.')) {
|
||||
return `${titleCase} API`;
|
||||
}
|
||||
if (hostname.startsWith('tools.')) {
|
||||
return `${titleCase} Tools`;
|
||||
}
|
||||
|
||||
return titleCase;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a URL format
|
||||
*/
|
||||
export function isValidUrl(url: string): boolean {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if URL uses HTTPS
|
||||
*/
|
||||
export function isHttps(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a URL (adds https:// if missing protocol)
|
||||
*/
|
||||
export function normalizeUrl(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// If no protocol, assume https
|
||||
if (!trimmed.includes('://')) {
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts transport type hint from URL patterns
|
||||
* Some MCP servers use specific URL patterns for SSE vs HTTP
|
||||
*/
|
||||
export function detectTransportFromUrl(url: string): 'streamable-http' | 'sse' | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const pathname = parsed.pathname.toLowerCase();
|
||||
|
||||
// Common SSE patterns
|
||||
if (pathname.includes('/sse') || pathname.includes('/events') || pathname.includes('/stream')) {
|
||||
return 'sse';
|
||||
}
|
||||
|
||||
// Default to null (let user choose or use default)
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,65 +1,44 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { GearIcon, MCPIcon, OGDialogTrigger } from '@librechat/client';
|
||||
import {
|
||||
PermissionBits,
|
||||
PermissionTypes,
|
||||
Permissions,
|
||||
hasPermissions,
|
||||
} from 'librechat-data-provider';
|
||||
import { useLocalize, useHasAccess, MCPServerDefinition } from '~/hooks';
|
||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import MCPServerDialog from './MCPServerDialog';
|
||||
import { MCPIcon } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { MCPServerStatusIconProps } from '~/components/MCP/MCPServerStatusIcon';
|
||||
import type { MCPServerDefinition } from '~/hooks';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import MCPServerCard from './MCPServerCard';
|
||||
|
||||
interface MCPServerListProps {
|
||||
servers: MCPServerDefinition[];
|
||||
getServerStatusIconProps: (
|
||||
serverName: string,
|
||||
) => React.ComponentProps<typeof MCPServerStatusIcon>;
|
||||
getServerStatusIconProps: (serverName: string) => MCPServerStatusIconProps;
|
||||
isFiltered?: boolean;
|
||||
}
|
||||
|
||||
// Self-contained edit button component (follows MemoryViewer pattern)
|
||||
const EditMCPServerButton = ({ server }: { server: MCPServerDefinition }) => {
|
||||
const localize = useLocalize();
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<MCPServerDialog open={open} onOpenChange={setOpen} triggerRef={triggerRef} server={server}>
|
||||
<OGDialogTrigger asChild>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex h-5 w-5 items-center justify-center rounded hover:bg-surface-secondary"
|
||||
aria-label={localize('com_ui_edit')}
|
||||
>
|
||||
<GearIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</OGDialogTrigger>
|
||||
</MCPServerDialog>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a list of MCP server cards with empty state handling
|
||||
*/
|
||||
export default function MCPServerList({
|
||||
servers,
|
||||
getServerStatusIconProps,
|
||||
isFiltered = false,
|
||||
}: MCPServerListProps) {
|
||||
const localize = useLocalize();
|
||||
const canCreateEditMCPs = useHasAccess({
|
||||
permissionType: PermissionTypes.MCP_SERVERS,
|
||||
permission: Permissions.CREATE,
|
||||
});
|
||||
const localize = useLocalize();
|
||||
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border-light bg-transparent p-8 text-center shadow-sm">
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-border-light bg-transparent p-6 text-center">
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-surface-tertiary">
|
||||
<MCPIcon className="size-5 text-text-secondary" aria-hidden="true" />
|
||||
</div>
|
||||
{isFiltered ? (
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_no_mcp_servers_match')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_no_mcp_servers')}</p>
|
||||
<p className="mt-1 text-xs text-text-tertiary">
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_no_mcp_servers')}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-text-secondary">
|
||||
{localize('com_ui_add_first_mcp_server')}
|
||||
</p>
|
||||
</>
|
||||
|
|
@ -69,36 +48,16 @@ export default function MCPServerList({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{servers.map((server) => {
|
||||
const canEditThisServer = hasPermissions(server.effectivePermissions, PermissionBits.EDIT);
|
||||
const displayName = server.config?.title || server.serverName;
|
||||
const serverKey = `key_${server.serverName}`;
|
||||
|
||||
return (
|
||||
<div key={serverKey} className="rounded-lg border border-border-light bg-transparent p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Server Icon */}
|
||||
{server.config?.iconPath ? (
|
||||
<img src={server.config.iconPath} className="h-5 w-5 rounded" alt={displayName} />
|
||||
) : (
|
||||
<MCPIcon className="h-5 w-5" />
|
||||
)}
|
||||
|
||||
{/* Server Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate font-semibold text-text-primary">{displayName}</h3>
|
||||
</div>
|
||||
|
||||
{/* Edit Button - Only for DB servers and when user has CREATE access */}
|
||||
{canCreateEditMCPs && canEditThisServer && <EditMCPServerButton server={server} />}
|
||||
|
||||
{/* Connection Status Icon */}
|
||||
<MCPServerStatusIcon {...getServerStatusIconProps(server.serverName)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-2" role="list" aria-label={localize('com_ui_mcp_servers')}>
|
||||
{servers.map((server) => (
|
||||
<div key={`card_${server.serverName}`} role="listitem">
|
||||
<MCPServerCard
|
||||
server={server}
|
||||
getServerStatusIconProps={getServerStatusIconProps}
|
||||
canCreateEditMCPs={canCreateEditMCPs}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
179
client/src/components/SidePanel/MCPBuilder/MCPStatusBadge.tsx
Normal file
179
client/src/components/SidePanel/MCPBuilder/MCPStatusBadge.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { Check, PlugZap } from 'lucide-react';
|
||||
import { Spinner } from '@librechat/client';
|
||||
import type { MCPServerStatus } from 'librechat-data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface MCPStatusBadgeProps {
|
||||
serverStatus?: MCPServerStatus;
|
||||
isInitializing?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status badge component for MCP servers - used in dialogs where text status is needed.
|
||||
*
|
||||
* Unified color system:
|
||||
* - Green: Connected/Active (success)
|
||||
* - Blue: Connecting/In-progress
|
||||
* - Amber: Needs user action (OAuth required)
|
||||
* - Gray: Disconnected/Inactive (neutral)
|
||||
* - Red: Error
|
||||
*/
|
||||
export default function MCPStatusBadge({
|
||||
serverStatus,
|
||||
isInitializing = false,
|
||||
className,
|
||||
}: MCPStatusBadgeProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const badgeBaseClass = cn(
|
||||
'flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
className,
|
||||
);
|
||||
|
||||
// Initializing/Connecting state - blue
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
badgeBaseClass,
|
||||
'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400',
|
||||
)}
|
||||
>
|
||||
<Spinner className="size-3" />
|
||||
<span>{localize('com_nav_mcp_status_initializing')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!serverStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { connectionState, requiresOAuth } = serverStatus;
|
||||
|
||||
// Connecting state - blue
|
||||
if (connectionState === 'connecting') {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
badgeBaseClass,
|
||||
'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400',
|
||||
)}
|
||||
>
|
||||
<Spinner className="size-3" />
|
||||
<span>{localize('com_nav_mcp_status_connecting')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Disconnected state - check if needs action
|
||||
if (connectionState === 'disconnected') {
|
||||
if (requiresOAuth) {
|
||||
// Needs OAuth - amber (requires action)
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
badgeBaseClass,
|
||||
'bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-400',
|
||||
)}
|
||||
>
|
||||
<PlugZap className="size-3" aria-hidden="true" />
|
||||
<span>{localize('com_nav_mcp_status_needs_auth')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Simply disconnected - gray (neutral)
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
badgeBaseClass,
|
||||
'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
|
||||
)}
|
||||
>
|
||||
<span>{localize('com_nav_mcp_status_disconnected')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state - red
|
||||
if (connectionState === 'error') {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(badgeBaseClass, 'bg-red-50 text-red-600 dark:bg-red-950 dark:text-red-400')}
|
||||
>
|
||||
<span>{localize('com_nav_mcp_status_error')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Connected state - green
|
||||
if (connectionState === 'connected') {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
badgeBaseClass,
|
||||
'bg-green-50 text-green-600 dark:bg-green-950 dark:text-green-400',
|
||||
)}
|
||||
>
|
||||
<Check className="size-3" aria-hidden="true" />
|
||||
<span>{localize('com_nav_mcp_status_connected')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the status dot color class - unified across all MCP components.
|
||||
*
|
||||
* Colors:
|
||||
* - Green: Connected
|
||||
* - Blue: Connecting/Initializing
|
||||
* - Amber: Needs action (OAuth required while disconnected)
|
||||
* - Gray: Disconnected (neutral)
|
||||
* - Red: Error
|
||||
*/
|
||||
export function getStatusDotColor(
|
||||
serverStatus?: MCPServerStatus,
|
||||
isInitializing?: boolean,
|
||||
): string {
|
||||
if (isInitializing) {
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
|
||||
if (!serverStatus) {
|
||||
return 'bg-gray-400';
|
||||
}
|
||||
|
||||
const { connectionState, requiresOAuth } = serverStatus;
|
||||
|
||||
if (connectionState === 'connecting') {
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
|
||||
if (connectionState === 'connected') {
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
if (connectionState === 'error') {
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
if (connectionState === 'disconnected') {
|
||||
// Needs OAuth = amber, otherwise gray
|
||||
return requiresOAuth ? 'bg-amber-500' : 'bg-gray-400';
|
||||
}
|
||||
|
||||
return 'bg-gray-400';
|
||||
}
|
||||
|
|
@ -2,4 +2,7 @@ export { default } from './MCPBuilderPanel';
|
|||
export { default as MCPBuilderPanel } from './MCPBuilderPanel';
|
||||
export { default as MCPServerList } from './MCPServerList';
|
||||
export { default as MCPServerDialog } from './MCPServerDialog';
|
||||
export { default as MCPServerCard } from './MCPServerCard';
|
||||
export { default as MCPStatusBadge, getStatusDotColor } from './MCPStatusBadge';
|
||||
export { default as MCPCardActions } from './MCPCardActions';
|
||||
export { default as MCPAuth } from './MCPAuth';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue