🔌 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:
Marco Beretta 2025-12-28 18:20:15 +01:00 committed by GitHub
parent 0b8e0fcede
commit e4870ed0b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2594 additions and 1646 deletions

View file

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

View file

@ -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,
};
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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