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 } from '~/hooks'; 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; server?: MCPServerDefinition | null; } export default function MCPServerDialog({ open, onOpenChange, children, triggerRef, server, }: MCPServerDialogProps) { const localize = useLocalize(); const { showToast } = useToastContext(); // Mutations const createMutation = useCreateMCPServerMutation(); const updateMutation = useUpdateMCPServerMutation(); const deleteMutation = useDeleteMCPServerMutation(); // Convert McpServer to form data const defaultValues = useMemo(() => { if (server) { // Determine auth type from server config let authType: AuthTypeEnum = AuthTypeEnum.None; if (server.config.oauth) { authType = AuthTypeEnum.OAuth; } else if ('api_key' in server.config) { authType = AuthTypeEnum.ServiceHttp; } 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: '', api_key_authorization_type: AuthorizationTypeEnum.Basic, api_key_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_authorization_type: AuthorizationTypeEnum.Basic, api_key_custom_header: '', oauth_client_id: '', oauth_client_secret: '', oauth_authorization_url: '', oauth_token_url: '', oauth_scope: '', }, trust: false, }; }, [server]); const methods = useForm({ 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(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) => { 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; } } 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) { 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 */} { setShowDeleteConfirm(open); }} > {localize('com_ui_mcp_server_delete_confirm')} } selection={{ selectHandler: handleDelete, selectClasses: 'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80', selectText: isDeleting ? : localize('com_ui_delete'), }} /> {/* Post-creation redirect URI dialog */} { setShowRedirectUriDialog(open); if (!open) { onOpenChange(false); setCreatedServerId(null); } }} > {localize('com_ui_mcp_server_created')}

{localize('com_ui_redirect_uri_instructions')}

{/* Main MCP Server Dialog */} {children}
{/* Icon Picker */}
{/* Title */}
{errors.title && ( {errors.title.type === 'pattern' ? errors.title.message : localize('com_ui_field_required')} )}
{/* Description */}
{/* URL */}
{errors.url && ( {errors.url.type === 'required' ? localize('com_ui_field_required') : errors.url.message} )}
{/* Server Type */}
(
)} />
{/* Authentication */} } /> {/* Trust Checkbox */}
( )} />
{errors.trust && ( {localize('com_ui_field_required')} )}
} footerClassName="sm:justify-between" leftButtons={ server ? (
{shouldShowShareButton && ( )}
) : null } buttons={ } />
); }