'use client' import type { HeaderItem } from './headers-input' 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/compat' import * as React from 'react' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { getDomain } from 'tldts' import { v4 as uuid } from 'uuid' 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 AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' import Switch from '@/app/components/base/switch' import TabSlider from '@/app/components/base/tab-slider' import Toast from '@/app/components/base/toast' import { MCPAuthMethod } from '@/app/components/tools/types' import { API_PREFIX } from '@/config' import { uploadRemoteFileInfo } from '@/service/common' import { cn } from '@/utils/classnames' import { shouldUseMcpIconForAppIcon } from '@/utils/mcp' import HeadersInput from './headers-input' 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('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 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?.configuration?.timeout || 30) const [sseReadTimeout, setSseReadTimeout] = React.useState(data?.configuration?.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.configuration?.timeout || 30) setSseReadTimeout(data.configuration?.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('mcp.modal.editTitle', { ns: 'tools' }) : t('mcp.modal.title', { ns: 'tools' })}
{t('mcp.modal.serverUrl', { ns: 'tools' })}
setUrl(e.target.value)} onBlur={e => handleBlur(e.target.value.trim())} placeholder={t('mcp.modal.serverUrlPlaceholder', { ns: 'tools' })} /> {originalServerUrl && originalServerUrl !== url && (
{t('mcp.modal.serverUrlWarning', { ns: 'tools' })}
)}
{t('mcp.modal.name', { ns: 'tools' })}
setName(e.target.value)} placeholder={t('mcp.modal.namePlaceholder', { ns: 'tools' })} />
: undefined} size="xxl" className="relative cursor-pointer rounded-2xl" coverElement={ isHovering ? (
) : null } onClick={() => { setShowAppIconPicker(true) }} />
{t('mcp.modal.serverIdentifier', { ns: 'tools' })}
{t('mcp.modal.serverIdentifierTip', { ns: 'tools' })}
setServerIdentifier(e.target.value)} placeholder={t('mcp.modal.serverIdentifierPlaceholder', { ns: 'tools' })} /> {originalServerID && originalServerID !== serverIdentifier && (
{t('mcp.modal.serverIdentifierWarning', { ns: 'tools' })}
)}
{ return `flex-1 ${isActive && 'text-text-accent-light-mode-only'}` }} value={authMethod} onChange={handleAuthMethodChange} options={authMethods} /> { authMethod === MCPAuthMethod.authentication && ( <>
{t('mcp.modal.useDynamicClientRegistration', { ns: 'tools' })}
{!isDynamicRegistration && (
{t('mcp.modal.redirectUrlWarning', { ns: 'tools' })}
{`${API_PREFIX}/mcp/oauth/callback`}
)}
{t('mcp.modal.clientID', { ns: 'tools' })}
setClientID(e.target.value)} onBlur={e => handleBlur(e.target.value.trim())} placeholder={t('mcp.modal.clientID', { ns: 'tools' })} disabled={isDynamicRegistration} />
{t('mcp.modal.clientSecret', { ns: 'tools' })}
setCredentials(e.target.value)} onBlur={e => handleBlur(e.target.value.trim())} placeholder={t('mcp.modal.clientSecretPlaceholder', { ns: 'tools' })} disabled={isDynamicRegistration} />
) } { authMethod === MCPAuthMethod.headers && (
{t('mcp.modal.headers', { ns: 'tools' })}
{t('mcp.modal.headersTip', { ns: 'tools' })}
item.key.trim()).length > 0} />
) } { authMethod === MCPAuthMethod.configurations && ( <>
{t('mcp.modal.timeout', { ns: 'tools' })}
setMcpTimeout(Number(e.target.value))} onBlur={e => handleBlur(e.target.value.trim())} placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })} />
{t('mcp.modal.sseReadTimeout', { ns: 'tools' })}
setSseReadTimeout(Number(e.target.value))} onBlur={e => handleBlur(e.target.value.trim())} placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })} />
) }
{showAppIconPicker && ( { setAppIcon(payload) setShowAppIconPicker(false) }} onClose={() => { setAppIcon(getIcon(data)) setShowAppIconPicker(false) }} /> )} ) } export default MCPModal