'use client' import type { FC } from 'react' import type { AppIconSelection } from '@/app/components/base/app-icon-picker' import type { ToolWithProvider } from '@/app/components/workflow/types' import type { AppIconType } from '@/types/app' import { RiCloseLine, RiEditLine } from '@remixicon/react' import { useHover } from 'ahooks' import { noop } from 'es-toolkit/function' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import AppIconPicker from '@/app/components/base/app-icon-picker' import Button from '@/app/components/base/button' import { Mcp } from '@/app/components/base/icons/src/vender/other' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' import TabSlider from '@/app/components/base/tab-slider' import Toast from '@/app/components/base/toast' import { MCPAuthMethod } from '@/app/components/tools/types' import { cn } from '@/utils/classnames' import { shouldUseMcpIconForAppIcon } from '@/utils/mcp' import { isValidServerID, isValidUrl, useMCPModalForm } from './hooks/use-mcp-modal-form' import AuthenticationSection from './sections/authentication-section' import ConfigurationsSection from './sections/configurations-section' import HeadersSection from './sections/headers-section' export type MCPModalConfirmPayload = { name: string server_url: string icon_type: AppIconType icon: string icon_background?: string | null server_identifier: string headers?: Record is_dynamic_registration?: boolean authentication?: { client_id?: string client_secret?: string grant_type?: string } configuration: { timeout: number sse_read_timeout: number } } export type DuplicateAppModalProps = { data?: ToolWithProvider show: boolean onConfirm: (info: MCPModalConfirmPayload) => void onHide: () => void } type MCPModalContentProps = { data?: ToolWithProvider onConfirm: (info: MCPModalConfirmPayload) => void onHide: () => void } const MCPModalContent: FC = ({ data, onConfirm, onHide, }) => { const { t } = useTranslation() const { isCreate, originalServerUrl, originalServerID, appIconRef, state, actions, } = useMCPModalForm(data) const isHovering = useHover(appIconRef) const authMethods = [ { text: t('mcp.modal.authentication', { ns: 'tools' }), value: MCPAuthMethod.authentication }, { text: t('mcp.modal.headers', { ns: 'tools' }), value: MCPAuthMethod.headers }, { text: t('mcp.modal.configurations', { ns: 'tools' }), value: MCPAuthMethod.configurations }, ] const submit = async () => { if (!isValidUrl(state.url)) { Toast.notify({ type: 'error', message: 'invalid server url' }) return } if (!isValidServerID(state.serverIdentifier.trim())) { Toast.notify({ type: 'error', message: 'invalid server identifier' }) return } const formattedHeaders = state.headers.reduce((acc, item) => { if (item.key.trim()) acc[item.key.trim()] = item.value return acc }, {} as Record) await onConfirm({ server_url: originalServerUrl === state.url ? '[__HIDDEN__]' : state.url.trim(), name: state.name, icon_type: state.appIcon.type, icon: state.appIcon.type === 'emoji' ? state.appIcon.icon : state.appIcon.fileId, icon_background: state.appIcon.type === 'emoji' ? state.appIcon.background : undefined, server_identifier: state.serverIdentifier.trim(), headers: Object.keys(formattedHeaders).length > 0 ? formattedHeaders : undefined, is_dynamic_registration: state.isDynamicRegistration, authentication: { client_id: state.clientID, client_secret: state.credentials, }, configuration: { timeout: state.timeout || 30, sse_read_timeout: state.sseReadTimeout || 300, }, }) if (isCreate) onHide() } const handleIconSelect = (payload: AppIconSelection) => { actions.setAppIcon(payload) actions.setShowAppIconPicker(false) } const handleIconClose = () => { actions.resetIcon() actions.setShowAppIconPicker(false) } const isSubmitDisabled = !state.name || !state.url || !state.serverIdentifier || state.isFetchingIcon return ( <>
{!isCreate ? t('mcp.modal.editTitle', { ns: 'tools' }) : t('mcp.modal.title', { ns: 'tools' })}
{/* Server URL */}
{t('mcp.modal.serverUrl', { ns: 'tools' })}
actions.setUrl(e.target.value)} onBlur={e => actions.handleUrlBlur(e.target.value.trim())} placeholder={t('mcp.modal.serverUrlPlaceholder', { ns: 'tools' })} /> {originalServerUrl && originalServerUrl !== state.url && (
{t('mcp.modal.serverUrlWarning', { ns: 'tools' })}
)}
{/* Name and Icon */}
{t('mcp.modal.name', { ns: 'tools' })}
actions.setName(e.target.value)} placeholder={t('mcp.modal.namePlaceholder', { ns: 'tools' })} />
: undefined} size="xxl" className="relative cursor-pointer rounded-2xl" coverElement={ isHovering ? (
) : null } onClick={() => actions.setShowAppIconPicker(true)} />
{/* Server Identifier */}
{t('mcp.modal.serverIdentifier', { ns: 'tools' })}
{t('mcp.modal.serverIdentifierTip', { ns: 'tools' })}
actions.setServerIdentifier(e.target.value)} placeholder={t('mcp.modal.serverIdentifierPlaceholder', { ns: 'tools' })} /> {originalServerID && originalServerID !== state.serverIdentifier && (
{t('mcp.modal.serverIdentifierWarning', { ns: 'tools' })}
)}
{/* Auth Method Tabs */} `flex-1 ${isActive && 'text-text-accent-light-mode-only'}`} value={state.authMethod} onChange={actions.setAuthMethod} options={authMethods} /> {/* Tab Content */} {state.authMethod === MCPAuthMethod.authentication && ( )} {state.authMethod === MCPAuthMethod.headers && ( )} {state.authMethod === MCPAuthMethod.configurations && ( )}
{/* Actions */}
{state.showAppIconPicker && ( )} ) } /** * MCP Modal component for creating and editing MCP server configurations. * * Uses a keyed inner component to ensure form state resets when switching * between create mode and edit mode with different data. */ const MCPModal: FC = ({ data, show, onConfirm, onHide, }) => { // Use data ID as key to reset form state when switching between items const formKey = data?.id ?? 'create' return ( ) } export default MCPModal