'use client' import React, { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { v4 as uuid } from 'uuid' import { getDomain } from 'tldts' import { RiCloseLine, RiEditLine } from '@remixicon/react' import { Mcp } from '@/app/components/base/icons/src/vender/other' import AppIconPicker from '@/app/components/base/app-icon-picker' import type { AppIconSelection } from '@/app/components/base/app-icon-picker' import AppIcon from '@/app/components/base/app-icon' import Modal from '@/app/components/base/modal' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import HeadersInput from './headers-input' import type { HeaderItem } from './headers-input' import type { AppIconType } from '@/types/app' import type { ToolWithProvider } from '@/app/components/workflow/types' import { noop } from 'lodash-es' import Toast from '@/app/components/base/toast' import { uploadRemoteFileInfo } from '@/service/common' import cn from '@/utils/classnames' import { useHover } from 'ahooks' import { shouldUseMcpIconForAppIcon } from '@/utils/mcp' import TabSlider from '@/app/components/base/tab-slider' import { MCPAuthMethod } from '@/app/components/tools/types' import Switch from '@/app/components/base/switch' import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle' import { API_PREFIX } from '@/config' export type DuplicateAppModalProps = { data?: ToolWithProvider show: boolean onConfirm: (info: { 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 } }) => void onHide: () => void } const DEFAULT_ICON = { type: 'emoji', icon: '🔗', background: '#6366F1' } const extractFileId = (url: string) => { const match = url.match(/files\/(.+?)\/file-preview/) return match ? match[1] : null } const getIcon = (data?: ToolWithProvider) => { if (!data) return DEFAULT_ICON as AppIconSelection if (typeof data.icon === 'string') return { type: 'image', url: data.icon, fileId: extractFileId(data.icon) } as AppIconSelection return { ...data.icon, icon: data.icon.content, type: 'emoji', } as unknown as AppIconSelection } const MCPModal = ({ data, show, onConfirm, onHide, }: DuplicateAppModalProps) => { const { t } = useTranslation() const isCreate = !data const authMethods = [ { text: t('tools.mcp.modal.authentication'), value: MCPAuthMethod.authentication, }, { text: t('tools.mcp.modal.headers'), value: MCPAuthMethod.headers, }, { text: t('tools.mcp.modal.configurations'), value: MCPAuthMethod.configurations, }, ] const originalServerUrl = data?.server_url const originalServerID = data?.server_identifier const [url, setUrl] = React.useState(data?.server_url || '') const [name, setName] = React.useState(data?.name || '') const [appIcon, setAppIcon] = useState(() => getIcon(data)) const [showAppIconPicker, setShowAppIconPicker] = useState(false) const [serverIdentifier, setServerIdentifier] = React.useState(data?.server_identifier || '') const [timeout, setMcpTimeout] = React.useState(data?.timeout || 30) const [sseReadTimeout, setSseReadTimeout] = React.useState(data?.sse_read_timeout || 300) const [headers, setHeaders] = React.useState( Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })), ) const [isFetchingIcon, setIsFetchingIcon] = useState(false) const appIconRef = useRef(null) const isHovering = useHover(appIconRef) const [authMethod, setAuthMethod] = useState(MCPAuthMethod.authentication) const [isDynamicRegistration, setIsDynamicRegistration] = useState(isCreate ? true : data?.is_dynamic_registration) const [clientID, setClientID] = useState(data?.authentication?.client_id || '') const [credentials, setCredentials] = useState(data?.authentication?.client_secret || '') // Update states when data changes (for edit mode) React.useEffect(() => { if (data) { setUrl(data.server_url || '') setName(data.name || '') setServerIdentifier(data.server_identifier || '') setMcpTimeout(data.timeout || 30) setSseReadTimeout(data.sse_read_timeout || 300) setHeaders(Object.entries(data.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value }))) setAppIcon(getIcon(data)) setIsDynamicRegistration(data.is_dynamic_registration) setClientID(data.authentication?.client_id || '') setCredentials(data.authentication?.client_secret || '') } else { // Reset for create mode setUrl('') setName('') setServerIdentifier('') setMcpTimeout(30) setSseReadTimeout(300) setHeaders([]) setAppIcon(DEFAULT_ICON as AppIconSelection) setIsDynamicRegistration(true) setClientID('') setCredentials('') } }, [data]) const isValidUrl = (string: string) => { try { const url = new URL(string) return url.protocol === 'http:' || url.protocol === 'https:' } catch { return false } } const isValidServerID = (str: string) => { return /^[a-z0-9_-]{1,24}$/.test(str) } const handleBlur = async (url: string) => { if (data) return if (!isValidUrl(url)) return const domain = getDomain(url) const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128` setIsFetchingIcon(true) try { const res = await uploadRemoteFileInfo(remoteIcon, undefined, true) setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' }) } catch (e) { let errorMessage = 'Failed to fetch remote icon' const errorData = await (e as Response).json() if (errorData?.code) errorMessage = `Upload failed: ${errorData.code}` console.error('Failed to fetch remote icon:', e) Toast.notify({ type: 'warning', message: errorMessage }) } finally { setIsFetchingIcon(false) } } const submit = async () => { if (!isValidUrl(url)) { Toast.notify({ type: 'error', message: 'invalid server url' }) return } if (!isValidServerID(serverIdentifier.trim())) { Toast.notify({ type: 'error', message: 'invalid server identifier' }) return } const formattedHeaders = headers.reduce((acc, item) => { if (item.key.trim()) acc[item.key.trim()] = item.value return acc }, {} as Record) await onConfirm({ server_url: originalServerUrl === url ? '[__HIDDEN__]' : url.trim(), name, icon_type: appIcon.type, icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId, icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined, server_identifier: serverIdentifier.trim(), headers: Object.keys(formattedHeaders).length > 0 ? formattedHeaders : undefined, is_dynamic_registration: isDynamicRegistration, authentication: { client_id: clientID, client_secret: credentials, }, configuration: { timeout: timeout || 30, sse_read_timeout: sseReadTimeout || 300, }, }) if(isCreate) onHide() } const handleAuthMethodChange = useCallback((value: string) => { setAuthMethod(value as MCPAuthMethod) }, []) return ( <>
{!isCreate ? t('tools.mcp.modal.editTitle') : t('tools.mcp.modal.title')}
{t('tools.mcp.modal.serverUrl')}
setUrl(e.target.value)} onBlur={e => handleBlur(e.target.value.trim())} placeholder={t('tools.mcp.modal.serverUrlPlaceholder')} /> {originalServerUrl && originalServerUrl !== url && (
{t('tools.mcp.modal.serverUrlWarning')}
)}
{t('tools.mcp.modal.name')}
setName(e.target.value)} placeholder={t('tools.mcp.modal.namePlaceholder')} />
: undefined} size='xxl' className='relative cursor-pointer rounded-2xl' coverElement={ isHovering ? (
) : null } onClick={() => { setShowAppIconPicker(true) }} />
{t('tools.mcp.modal.serverIdentifier')}
{t('tools.mcp.modal.serverIdentifierTip')}
setServerIdentifier(e.target.value)} placeholder={t('tools.mcp.modal.serverIdentifierPlaceholder')} /> {originalServerID && originalServerID !== serverIdentifier && (
{t('tools.mcp.modal.serverIdentifierWarning')}
)}
{ return `flex-1 ${isActive && 'text-text-accent-light-mode-only'}` }} value={authMethod} onChange={handleAuthMethodChange} options={authMethods} /> { authMethod === MCPAuthMethod.authentication && ( <>
{t('tools.mcp.modal.useDynamicClientRegistration')}
{!isDynamicRegistration && (
{t('tools.mcp.modal.redirectUrlWarning')}
{`${API_PREFIX}/mcp/oauth/callback`}
)}
{t('tools.mcp.modal.clientID')}
setClientID(e.target.value)} onBlur={e => handleBlur(e.target.value.trim())} placeholder={t('tools.mcp.modal.clientID')} disabled={isDynamicRegistration} />
{t('tools.mcp.modal.clientSecret')}
setCredentials(e.target.value)} onBlur={e => handleBlur(e.target.value.trim())} placeholder={t('tools.mcp.modal.clientSecretPlaceholder')} disabled={isDynamicRegistration} />
) } { authMethod === MCPAuthMethod.headers && (
{t('tools.mcp.modal.headers')}
{t('tools.mcp.modal.headersTip')}
item.key.trim()).length > 0} />
) } { authMethod === MCPAuthMethod.configurations && ( <>
{t('tools.mcp.modal.timeout')}
setMcpTimeout(Number(e.target.value))} onBlur={e => handleBlur(e.target.value.trim())} placeholder={t('tools.mcp.modal.timeoutPlaceholder')} />
{t('tools.mcp.modal.sseReadTimeout')}
setSseReadTimeout(Number(e.target.value))} onBlur={e => handleBlur(e.target.value.trim())} placeholder={t('tools.mcp.modal.timeoutPlaceholder')} />
) }
{showAppIconPicker && { setAppIcon(payload) setShowAppIconPicker(false) }} onClose={() => { setAppIcon(getIcon(data)) setShowAppIconPicker(false) }} />} ) } export default MCPModal