mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-30 23:28:52 +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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue