This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
'use client'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import {
RiAddCircleFill,
RiArrowRightUpLine,
RiBookOpenLine,
} from '@remixicon/react'
import MCPModal from './modal'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language'
import { useAppContext } from '@/context/app-context'
import { useCreateMCP } from '@/service/use-tools'
import type { ToolWithProvider } from '@/app/components/workflow/types'
type Props = {
handleCreate: (provider: ToolWithProvider) => void
}
const NewMCPCard = ({ handleCreate }: Props) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const { isCurrentWorkspaceManager } = useAppContext()
const { mutateAsync: createMCP } = useCreateMCP()
const create = async (info: any) => {
const provider = await createMCP(info)
handleCreate(provider)
}
const linkUrl = useMemo(() => {
if (language.startsWith('zh_'))
return 'https://docs.dify.ai/zh-hans/guides/tools/mcp'
if (language.startsWith('ja_jp'))
return 'https://docs.dify.ai/ja_jp/guides/tools/mcp'
return 'https://docs.dify.ai/en/guides/tools/mcp'
}, [language])
const [showModal, setShowModal] = useState(false)
return (
<>
{isCurrentWorkspaceManager && (
<div className='col-span-1 flex min-h-[108px] cursor-pointer flex-col rounded-xl bg-background-default-dimmed transition-all duration-200 ease-in-out'>
<div className='group grow rounded-t-xl' onClick={() => setShowModal(true)}>
<div className='flex shrink-0 items-center p-4 pb-3'>
<div className='flex h-10 w-10 items-center justify-center rounded-lg border border-dashed border-divider-deep group-hover:border-solid group-hover:border-state-accent-hover-alt group-hover:bg-state-accent-hover'>
<RiAddCircleFill className='h-4 w-4 text-text-quaternary group-hover:text-text-accent'/>
</div>
<div className='system-md-semibold ml-3 text-text-secondary group-hover:text-text-accent'>{t('tools.mcp.create.cardTitle')}</div>
</div>
</div>
<div className='rounded-b-xl border-t-[0.5px] border-divider-subtle px-4 py-3 text-text-tertiary hover:text-text-accent'>
<a href={linkUrl} target='_blank' rel='noopener noreferrer' className='flex items-center space-x-1'>
<RiBookOpenLine className='h-3 w-3 shrink-0' />
<div className='system-xs-regular grow truncate' title={t('tools.mcp.create.cardLink') || ''}>{t('tools.mcp.create.cardLink')}</div>
<RiArrowRightUpLine className='h-3 w-3 shrink-0' />
</a>
</div>
</div>
)}
{showModal && (
<MCPModal
show={showModal}
onConfirm={create}
onHide={() => setShowModal(false)}
/>
)}
</>
)
}
export default NewMCPCard

View File

@@ -0,0 +1,307 @@
'use client'
import React, { useCallback, useEffect } from 'react'
import type { FC } from 'react'
import { useBoolean } from 'ahooks'
import copy from 'copy-to-clipboard'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import {
RiCloseLine,
RiLoader2Line,
RiLoopLeftLine,
} from '@remixicon/react'
import type { ToolWithProvider } from '../../../workflow/types'
import Icon from '@/app/components/plugins/card/base/card-icon'
import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm'
import Indicator from '@/app/components/header/indicator'
import Tooltip from '@/app/components/base/tooltip'
import MCPModal from '../modal'
import OperationDropdown from './operation-dropdown'
import ListLoading from './list-loading'
import ToolItem from './tool-item'
import {
useAuthorizeMCP,
useDeleteMCP,
useInvalidateMCPTools,
useMCPTools,
useUpdateMCP,
useUpdateMCPTools,
} from '@/service/use-tools'
import { openOAuthPopup } from '@/hooks/use-oauth'
import cn from '@/utils/classnames'
type Props = {
detail: ToolWithProvider
onUpdate: (isDelete?: boolean) => void
onHide: () => void
isTriggerAuthorize: boolean
onFirstCreate: () => void
}
const MCPDetailContent: FC<Props> = ({
detail,
onUpdate,
onHide,
isTriggerAuthorize,
onFirstCreate,
}) => {
const { t } = useTranslation()
const { isCurrentWorkspaceManager } = useAppContext()
const { data, isFetching: isGettingTools } = useMCPTools(detail.is_team_authorization ? detail.id : '')
const invalidateMCPTools = useInvalidateMCPTools()
const { mutateAsync: updateTools, isPending: isUpdating } = useUpdateMCPTools()
const { mutateAsync: authorizeMcp, isPending: isAuthorizing } = useAuthorizeMCP()
const toolList = data?.tools || []
const [isShowUpdateConfirm, {
setTrue: showUpdateConfirm,
setFalse: hideUpdateConfirm,
}] = useBoolean(false)
const handleUpdateTools = useCallback(async () => {
hideUpdateConfirm()
if (!detail)
return
await updateTools(detail.id)
invalidateMCPTools(detail.id)
onUpdate()
}, [detail, hideUpdateConfirm, invalidateMCPTools, onUpdate, updateTools])
const { mutateAsync: updateMCP } = useUpdateMCP({})
const { mutateAsync: deleteMCP } = useDeleteMCP({})
const [isShowUpdateModal, {
setTrue: showUpdateModal,
setFalse: hideUpdateModal,
}] = useBoolean(false)
const [isShowDeleteConfirm, {
setTrue: showDeleteConfirm,
setFalse: hideDeleteConfirm,
}] = useBoolean(false)
const [deleting, {
setTrue: showDeleting,
setFalse: hideDeleting,
}] = useBoolean(false)
const handleOAuthCallback = useCallback(() => {
if (!isCurrentWorkspaceManager)
return
if (!detail.id)
return
handleUpdateTools()
}, [detail.id, handleUpdateTools, isCurrentWorkspaceManager])
const handleAuthorize = useCallback(async () => {
onFirstCreate()
if (!isCurrentWorkspaceManager)
return
if (!detail)
return
const res = await authorizeMcp({
provider_id: detail.id,
})
if (res.result === 'success')
handleUpdateTools()
else if (res.authorization_url)
openOAuthPopup(res.authorization_url, handleOAuthCallback)
}, [onFirstCreate, isCurrentWorkspaceManager, detail, authorizeMcp, handleUpdateTools, handleOAuthCallback])
const handleUpdate = useCallback(async (data: any) => {
if (!detail)
return
const res = await updateMCP({
...data,
provider_id: detail.id,
})
if ((res as any)?.result === 'success') {
hideUpdateModal()
onUpdate()
handleAuthorize()
}
}, [detail, updateMCP, hideUpdateModal, onUpdate, handleAuthorize])
const handleDelete = useCallback(async () => {
if (!detail)
return
showDeleting()
const res = await deleteMCP(detail.id)
hideDeleting()
if ((res as any)?.result === 'success') {
hideDeleteConfirm()
onUpdate(true)
}
}, [detail, showDeleting, deleteMCP, hideDeleting, hideDeleteConfirm, onUpdate])
useEffect(() => {
if (isTriggerAuthorize)
handleAuthorize()
}, [])
if (!detail)
return null
return (
<>
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3')}>
<div className='flex'>
<div className='shrink-0 overflow-hidden rounded-xl border border-components-panel-border-subtle'>
<Icon src={detail.icon} />
</div>
<div className='ml-3 w-0 grow'>
<div className='flex h-5 items-center'>
<div className='system-md-semibold truncate text-text-primary' title={detail.name}>{detail.name}</div>
</div>
<div className='mt-0.5 flex items-center gap-1'>
<Tooltip popupContent={t('tools.mcp.identifier')}>
<div className='system-xs-regular shrink-0 cursor-pointer text-text-secondary' onClick={() => copy(detail.server_identifier || '')}>{detail.server_identifier}</div>
</Tooltip>
<div className='system-xs-regular shrink-0 text-text-quaternary'>·</div>
<Tooltip popupContent={t('tools.mcp.modal.serverUrl')}>
<div className='system-xs-regular truncate text-text-secondary'>{detail.server_url}</div>
</Tooltip>
</div>
</div>
<div className='flex gap-1'>
<OperationDropdown
onEdit={showUpdateModal}
onRemove={showDeleteConfirm}
/>
<ActionButton onClick={onHide}>
<RiCloseLine className='h-4 w-4' />
</ActionButton>
</div>
</div>
<div className='mt-5'>
{!isAuthorizing && detail.is_team_authorization && (
<Button
variant='secondary'
className='w-full'
onClick={handleAuthorize}
disabled={!isCurrentWorkspaceManager}
>
<Indicator className='mr-2' color={'green'} />
{t('tools.auth.authorized')}
</Button>
)}
{!detail.is_team_authorization && !isAuthorizing && (
<Button
variant='primary'
className='w-full'
onClick={handleAuthorize}
disabled={!isCurrentWorkspaceManager}
>
{t('tools.mcp.authorize')}
</Button>
)}
{isAuthorizing && (
<Button
variant='primary'
className='w-full'
disabled
>
<RiLoader2Line className={cn('mr-1 h-4 w-4 animate-spin')} />
{t('tools.mcp.authorizing')}
</Button>
)}
</div>
</div>
<div className='flex grow flex-col'>
{((detail.is_team_authorization && isGettingTools) || isUpdating) && (
<>
<div className='flex shrink-0 justify-between gap-2 px-4 pb-1 pt-2'>
<div className='flex h-6 items-center'>
{!isUpdating && <div className='system-sm-semibold-uppercase text-text-secondary'>{t('tools.mcp.gettingTools')}</div>}
{isUpdating && <div className='system-sm-semibold-uppercase text-text-secondary'>{t('tools.mcp.updateTools')}</div>}
</div>
<div></div>
</div>
<div className='flex h-full w-full grow flex-col overflow-hidden px-4 pb-4'>
<ListLoading />
</div>
</>
)}
{!isUpdating && detail.is_team_authorization && !isGettingTools && !toolList.length && (
<div className='flex h-full w-full flex-col items-center justify-center'>
<div className='system-sm-regular mb-3 text-text-tertiary'>{t('tools.mcp.toolsEmpty')}</div>
<Button
variant='primary'
onClick={handleUpdateTools}
>{t('tools.mcp.getTools')}</Button>
</div>
)}
{!isUpdating && !isGettingTools && toolList.length > 0 && (
<>
<div className='flex shrink-0 justify-between gap-2 px-4 pb-1 pt-2'>
<div className='flex h-6 items-center'>
{toolList.length > 1 && <div className='system-sm-semibold-uppercase text-text-secondary'>{t('tools.mcp.toolsNum', { count: toolList.length })}</div>}
{toolList.length === 1 && <div className='system-sm-semibold-uppercase text-text-secondary'>{t('tools.mcp.onlyTool')}</div>}
</div>
<div>
<Button size='small' onClick={showUpdateConfirm}>
<RiLoopLeftLine className='mr-1 h-3.5 w-3.5' />
{t('tools.mcp.update')}
</Button>
</div>
</div>
<div className='flex h-0 w-full grow flex-col gap-2 overflow-y-auto px-4 pb-4'>
{toolList.map(tool => (
<ToolItem
key={`${detail.id}${tool.name}`}
tool={tool}
/>
))}
</div>
</>
)}
{!isUpdating && !detail.is_team_authorization && (
<div className='flex h-full w-full flex-col items-center justify-center'>
{!isAuthorizing && <div className='system-md-medium mb-1 text-text-secondary'>{t('tools.mcp.authorizingRequired')}</div>}
{isAuthorizing && <div className='system-md-medium mb-1 text-text-secondary'>{t('tools.mcp.authorizing')}</div>}
<div className='system-sm-regular text-text-tertiary'>{t('tools.mcp.authorizeTip')}</div>
</div>
)}
</div>
{isShowUpdateModal && (
<MCPModal
data={detail}
show={isShowUpdateModal}
onConfirm={handleUpdate}
onHide={hideUpdateModal}
/>
)}
{isShowDeleteConfirm && (
<Confirm
isShow
title={t('tools.mcp.delete')}
content={
<div>
{t('tools.mcp.deleteConfirmTitle', { mcp: detail.name })}
</div>
}
onCancel={hideDeleteConfirm}
onConfirm={handleDelete}
isLoading={deleting}
isDisabled={deleting}
/>
)}
{isShowUpdateConfirm && (
<Confirm
isShow
title={t('tools.mcp.toolUpdateConfirmTitle')}
content={t('tools.mcp.toolUpdateConfirmContent')}
onCancel={hideUpdateConfirm}
onConfirm={handleUpdateTools}
/>
)}
</>
)
}
export default MCPDetailContent

View File

@@ -0,0 +1,37 @@
'use client'
import React from 'react'
import cn from '@/utils/classnames'
const ListLoading = () => {
return (
<div className={cn('space-y-2')}>
<div className='space-y-3 rounded-xl bg-components-panel-on-panel-item-bg-hover p-4'>
<div className='h-2 w-[180px] rounded-sm bg-text-quaternary opacity-20'></div>
<div className='h-2 rounded-sm bg-text-quaternary opacity-10'></div>
<div className='mr-10 h-2 rounded-sm bg-text-quaternary opacity-10'></div>
</div>
<div className='space-y-3 rounded-xl bg-components-panel-on-panel-item-bg-hover p-4'>
<div className='h-2 w-[148px] rounded-sm bg-text-quaternary opacity-20'></div>
<div className='h-2 rounded-sm bg-text-quaternary opacity-10'></div>
<div className='mr-10 h-2 rounded-sm bg-text-quaternary opacity-10'></div>
</div>
<div className='space-y-3 rounded-xl bg-components-panel-on-panel-item-bg-hover p-4'>
<div className='h-2 w-[196px] rounded-sm bg-text-quaternary opacity-20'></div>
<div className='h-2 rounded-sm bg-text-quaternary opacity-10'></div>
<div className='mr-10 h-2 rounded-sm bg-text-quaternary opacity-10'></div>
</div>
<div className='space-y-3 rounded-xl bg-components-panel-on-panel-item-bg-hover p-4'>
<div className='h-2 w-[148px] rounded-sm bg-text-quaternary opacity-20'></div>
<div className='h-2 rounded-sm bg-text-quaternary opacity-10'></div>
<div className='mr-10 h-2 rounded-sm bg-text-quaternary opacity-10'></div>
</div>
<div className='space-y-3 rounded-xl bg-components-panel-on-panel-item-bg-hover p-4'>
<div className='h-2 w-[180px] rounded-sm bg-text-quaternary opacity-20'></div>
<div className='h-2 rounded-sm bg-text-quaternary opacity-10'></div>
<div className='mr-10 h-2 rounded-sm bg-text-quaternary opacity-10'></div>
</div>
</div>
)
}
export default ListLoading

View File

@@ -0,0 +1,88 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiDeleteBinLine,
RiEditLine,
RiMoreFill,
} from '@remixicon/react'
import ActionButton from '@/app/components/base/action-button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import cn from '@/utils/classnames'
type Props = {
inCard?: boolean
onOpenChange?: (open: boolean) => void
onEdit: () => void
onRemove: () => void
}
const OperationDropdown: FC<Props> = ({
inCard,
onOpenChange,
onEdit,
onRemove,
}) => {
const { t } = useTranslation()
const [open, doSetOpen] = useState(false)
const openRef = useRef(open)
const setOpen = useCallback((v: boolean) => {
doSetOpen(v)
openRef.current = v
onOpenChange?.(v)
}, [doSetOpen])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: !inCard ? -12 : 0,
crossAxis: !inCard ? 36 : 0,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div>
<ActionButton size={inCard ? 'l' : 'm'} className={cn(open && 'bg-state-base-hover')}>
<RiMoreFill className={cn('h-4 w-4', inCard && 'h-5 w-5')} />
</ActionButton>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-50'>
<div className='w-[160px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm'>
<div
className='flex cursor-pointer items-center rounded-lg px-3 py-1.5 hover:bg-state-base-hover'
onClick={() => {
onEdit()
handleTrigger()
}}
>
<RiEditLine className='h-4 w-4 text-text-tertiary' />
<div className='system-md-regular ml-2 text-text-secondary'>{t('tools.mcp.operation.edit')}</div>
</div>
<div
className='group flex cursor-pointer items-center rounded-lg px-3 py-1.5 hover:bg-state-destructive-hover'
onClick={() => {
onRemove()
handleTrigger()
}}
>
<RiDeleteBinLine className='h-4 w-4 text-text-tertiary group-hover:text-text-destructive-secondary' />
<div className='system-md-regular ml-2 text-text-secondary group-hover:text-text-destructive'>{t('tools.mcp.operation.remove')}</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(OperationDropdown)

View File

@@ -0,0 +1,56 @@
'use client'
import React from 'react'
import type { FC } from 'react'
import Drawer from '@/app/components/base/drawer'
import MCPDetailContent from './content'
import type { ToolWithProvider } from '../../../workflow/types'
import cn from '@/utils/classnames'
type Props = {
detail?: ToolWithProvider
onUpdate: () => void
onHide: () => void
isTriggerAuthorize: boolean
onFirstCreate: () => void
}
const MCPDetailPanel: FC<Props> = ({
detail,
onUpdate,
onHide,
isTriggerAuthorize,
onFirstCreate,
}) => {
const handleUpdate = (isDelete = false) => {
if (isDelete)
onHide()
onUpdate()
}
if (!detail)
return null
return (
<Drawer
isOpen={!!detail}
clickOutsideNotOpen={false}
onClose={onHide}
footer={null}
mask={false}
positionCenter={false}
panelClassName={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')}
>
{detail && (
<MCPDetailContent
detail={detail}
onHide={onHide}
onUpdate={handleUpdate}
isTriggerAuthorize={isTriggerAuthorize}
onFirstCreate={onFirstCreate}
/>
)}
</Drawer>
)
}
export default MCPDetailPanel

View File

@@ -0,0 +1,69 @@
'use client'
import React from 'react'
import { useContext } from 'use-context-selector'
import type { Tool } from '@/app/components/tools/types'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language'
import Tooltip from '@/app/components/base/tooltip'
import cn from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
type Props = {
tool: Tool
}
const MCPToolItem = ({
tool,
}: Props) => {
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const { t } = useTranslation()
const renderParameters = () => {
const parameters = tool.parameters
if (parameters.length === 0)
return null
return (
<div className='mt-2'>
<div className='title-xs-semi-bold mb-1 text-text-primary'>{t('tools.mcp.toolItem.parameters')}:</div>
<ul className='space-y-1'>
{parameters.map((parameter) => {
const descriptionContent = parameter.human_description[language] || t('tools.mcp.toolItem.noDescription')
return (
<li key={parameter.name} className='pl-2'>
<span className='system-xs-regular font-bold text-text-secondary'>{parameter.name}</span>
<span className='system-xs-regular mr-1 text-text-tertiary'>({parameter.type}):</span>
<span className='system-xs-regular text-text-tertiary'>{descriptionContent}</span>
</li>
)
})}
</ul>
</div>
)
}
return (
<Tooltip
key={tool.name}
position='left'
popupClassName='!p-0 !px-4 !py-3.5 !w-[360px] !border-[0.5px] !border-components-panel-border !rounded-xl !shadow-lg'
popupContent={(
<div>
<div className='title-xs-semi-bold mb-1 text-text-primary'>{tool.label[language]}</div>
<div className='body-xs-regular text-text-secondary'>{tool.description[language]}</div>
{renderParameters()}
</div>
)}
>
<div
className={cn('bg-components-panel-item-bg cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover')}
>
<div className='system-md-semibold pb-0.5 text-text-secondary'>{tool.label[language]}</div>
<div className='system-xs-regular line-clamp-2 text-text-tertiary' title={tool.description[language]}>{tool.description[language]}</div>
</div>
</Tooltip>
)
}
export default MCPToolItem

View File

@@ -0,0 +1,133 @@
'use client'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { v4 as uuid } from 'uuid'
import { RiAddLine, RiDeleteBinLine } from '@remixicon/react'
import Input from '@/app/components/base/input'
import Button from '@/app/components/base/button'
import ActionButton from '@/app/components/base/action-button'
import cn from '@/utils/classnames'
export type HeaderItem = {
id: string
key: string
value: string
}
type Props = {
headersItems: HeaderItem[]
onChange: (headerItems: HeaderItem[]) => void
readonly?: boolean
isMasked?: boolean
}
const HeadersInput = ({
headersItems,
onChange,
readonly = false,
isMasked = false,
}: Props) => {
const { t } = useTranslation()
const handleItemChange = (index: number, field: 'key' | 'value', value: string) => {
const newItems = [...headersItems]
newItems[index] = { ...newItems[index], [field]: value }
onChange(newItems)
}
const handleRemoveItem = (index: number) => {
const newItems = headersItems.filter((_, i) => i !== index)
onChange(newItems)
}
const handleAddItem = () => {
const newItems = [...headersItems, { id: uuid(), key: '', value: '' }]
onChange(newItems)
}
if (headersItems.length === 0) {
return (
<div className='space-y-2'>
<div className='body-xs-regular text-text-tertiary'>
{t('tools.mcp.modal.noHeaders')}
</div>
{!readonly && (
<Button
variant='secondary'
size='small'
onClick={handleAddItem}
className='w-full'
>
<RiAddLine className='mr-1 h-4 w-4' />
{t('tools.mcp.modal.addHeader')}
</Button>
)}
</div>
)
}
return (
<div className='space-y-2'>
{isMasked && (
<div className='body-xs-regular text-text-tertiary'>
{t('tools.mcp.modal.maskedHeadersTip')}
</div>
)}
<div className='overflow-hidden rounded-lg border border-divider-regular'>
<div className='system-xs-medium-uppercase bg-background-secondary flex h-7 items-center leading-7 text-text-tertiary'>
<div className='h-full w-1/2 border-r border-divider-regular pl-3'>{t('tools.mcp.modal.headerKey')}</div>
<div className='h-full w-1/2 pl-3 pr-1'>{t('tools.mcp.modal.headerValue')}</div>
</div>
{headersItems.map((item, index) => (
<div key={item.id} className={cn(
'flex items-center border-divider-regular',
index < headersItems.length - 1 && 'border-b',
)}>
<div className='w-1/2 border-r border-divider-regular'>
<Input
value={item.key}
onChange={e => handleItemChange(index, 'key', e.target.value)}
placeholder={t('tools.mcp.modal.headerKeyPlaceholder')}
className='rounded-none border-0'
readOnly={readonly}
/>
</div>
<div className='flex w-1/2 items-center'>
<Input
value={item.value}
onChange={e => handleItemChange(index, 'value', e.target.value)}
placeholder={t('tools.mcp.modal.headerValuePlaceholder')}
className='flex-1 rounded-none border-0'
readOnly={readonly}
/>
{!readonly && !!headersItems.length && (
<ActionButton
onClick={() => handleRemoveItem(index)}
className='mr-2'
>
<RiDeleteBinLine className='h-4 w-4 text-text-destructive' />
</ActionButton>
)}
</div>
</div>
))}
</div>
{!readonly && (
<Button
variant='secondary'
size='small'
onClick={handleAddItem}
className='w-full'
>
<RiAddLine className='mr-1 h-4 w-4' />
{t('tools.mcp.modal.addHeader')}
</Button>
)}
</div>
)
}
export default React.memo(HeadersInput)

View File

@@ -0,0 +1,98 @@
'use client'
import { useMemo, useState } from 'react'
import NewMCPCard from './create-card'
import MCPCard from './provider-card'
import MCPDetailPanel from './detail/provider-detail'
import {
useAllToolProviders,
} from '@/service/use-tools'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
type Props = {
searchText: string
}
function renderDefaultCard() {
const defaultCards = Array.from({ length: 36 }, (_, index) => (
<div
key={index}
className={cn(
'inline-flex h-[111px] rounded-xl bg-background-default-lighter opacity-10',
index < 4 && 'opacity-60',
index >= 4 && index < 8 && 'opacity-50',
index >= 8 && index < 12 && 'opacity-40',
index >= 12 && index < 16 && 'opacity-30',
index >= 16 && index < 20 && 'opacity-25',
index >= 20 && index < 24 && 'opacity-20',
)}
></div>
))
return defaultCards
}
const MCPList = ({
searchText,
}: Props) => {
const { data: list = [] as ToolWithProvider[], refetch } = useAllToolProviders()
const [isTriggerAuthorize, setIsTriggerAuthorize] = useState<boolean>(false)
const filteredList = useMemo(() => {
return list.filter((collection) => {
if (searchText)
return Object.values(collection.name).some(value => (value as string).toLowerCase().includes(searchText.toLowerCase()))
return collection.type === 'mcp'
}) as ToolWithProvider[]
}, [list, searchText])
const [currentProviderID, setCurrentProviderID] = useState<string>()
const currentProvider = useMemo(() => {
return list.find(provider => provider.id === currentProviderID)
}, [list, currentProviderID])
const handleCreate = async (provider: ToolWithProvider) => {
await refetch() // update list
setCurrentProviderID(provider.id)
setIsTriggerAuthorize(true)
}
const handleUpdate = async (providerID: string) => {
await refetch() // update list
setCurrentProviderID(providerID)
setIsTriggerAuthorize(true)
}
return (
<>
<div
className={cn(
'relative grid shrink-0 grid-cols-1 content-start gap-4 px-12 pb-4 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6',
!list.length && 'h-[calc(100vh_-_136px)] overflow-hidden',
)}
>
<NewMCPCard handleCreate={handleCreate} />
{filteredList.map(provider => (
<MCPCard
key={provider.id}
data={provider}
currentProvider={currentProvider as ToolWithProvider}
handleSelect={setCurrentProviderID}
onUpdate={handleUpdate}
onDeleted={refetch}
/>
))}
{!list.length && renderDefaultCard()}
</div>
{currentProvider && (
<MCPDetailPanel
detail={currentProvider as ToolWithProvider}
onHide={() => setCurrentProviderID(undefined)}
onUpdate={refetch}
isTriggerAuthorize={isTriggerAuthorize}
onFirstCreate={() => setIsTriggerAuthorize(false)}
/>
)}
</>
)
}
export default MCPList

View File

@@ -0,0 +1,143 @@
'use client'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Textarea from '@/app/components/base/textarea'
import Divider from '@/app/components/base/divider'
import MCPServerParamItem from '@/app/components/tools/mcp/mcp-server-param-item'
import type {
MCPServerDetail,
} from '@/app/components/tools/types'
import {
useCreateMCPServer,
useInvalidateMCPServerDetail,
useUpdateMCPServer,
} from '@/service/use-tools'
import cn from '@/utils/classnames'
export type ModalProps = {
appID: string
latestParams?: any[]
data?: MCPServerDetail
show: boolean
onHide: () => void
appInfo?: any
}
const MCPServerModal = ({
appID,
latestParams = [],
data,
show,
onHide,
appInfo,
}: ModalProps) => {
const { t } = useTranslation()
const { mutateAsync: createMCPServer, isPending: creating } = useCreateMCPServer()
const { mutateAsync: updateMCPServer, isPending: updating } = useUpdateMCPServer()
const invalidateMCPServerDetail = useInvalidateMCPServerDetail()
const defaultDescription = data?.description || appInfo?.description || ''
const [description, setDescription] = React.useState(defaultDescription)
const [params, setParams] = React.useState(data?.parameters || {})
const handleParamChange = (variable: string, value: string) => {
setParams(prev => ({
...prev,
[variable]: value,
}))
}
const getParamValue = () => {
const res = {} as any
latestParams.map((param) => {
res[param.variable] = params[param.variable]
return param
})
return res
}
const submit = async () => {
if (!data) {
const payload: any = {
appID,
parameters: getParamValue(),
}
if (description.trim())
payload.description = description
await createMCPServer(payload)
invalidateMCPServerDetail(appID)
onHide()
}
else {
const payload: any = {
appID,
id: data.id,
parameters: getParamValue(),
}
payload.description = description
await updateMCPServer(payload)
invalidateMCPServerDetail(appID)
onHide()
}
}
return (
<Modal
isShow={show}
onClose={onHide}
className={cn('relative !max-w-[520px] !p-0')}
>
<div className='absolute right-5 top-5 z-10 cursor-pointer p-1.5' onClick={onHide}>
<RiCloseLine className='h-5 w-5 text-text-tertiary' />
</div>
<div className='title-2xl-semi-bold relative p-6 pb-3 text-xl text-text-primary'>
{!data ? t('tools.mcp.server.modal.addTitle') : t('tools.mcp.server.modal.editTitle')}
</div>
<div className='space-y-5 px-6 py-3'>
<div className='space-y-0.5'>
<div className='flex h-6 items-center gap-1'>
<div className='system-sm-medium text-text-secondary'>{t('tools.mcp.server.modal.description')}</div>
<div className='system-xs-regular text-text-destructive-secondary'>*</div>
</div>
<Textarea
className='h-[96px] resize-none'
value={description}
placeholder={t('tools.mcp.server.modal.descriptionPlaceholder')}
onChange={e => setDescription(e.target.value)}
></Textarea>
</div>
{latestParams.length > 0 && (
<div>
<div className='mb-1 flex items-center gap-2'>
<div className='system-xs-medium-uppercase shrink-0 text-text-primary'>{t('tools.mcp.server.modal.parameters')}</div>
<Divider type='horizontal' className='!m-0 !h-px grow bg-divider-subtle' />
</div>
<div className='body-xs-regular mb-2 text-text-tertiary'>{t('tools.mcp.server.modal.parametersTip')}</div>
<div className='space-y-3'>
{latestParams.map(paramItem => (
<MCPServerParamItem
key={paramItem.variable}
data={paramItem}
value={params[paramItem.variable] || ''}
onChange={value => handleParamChange(paramItem.variable, value)}
/>
))}
</div>
</div>
)}
</div>
<div className='flex flex-row-reverse p-6 pt-5'>
<Button disabled={!description || creating || updating} className='ml-2' variant='primary' onClick={submit}>{data ? t('tools.mcp.modal.save') : t('tools.mcp.server.modal.confirm')}</Button>
<Button onClick={onHide}>{t('tools.mcp.modal.cancel')}</Button>
</div>
</Modal>
)
}
export default MCPServerModal

View File

@@ -0,0 +1,37 @@
'use client'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
type Props = {
data?: any
value: string
onChange: (value: string) => void
}
const MCPServerParamItem = ({
data,
value,
onChange,
}: Props) => {
const { t } = useTranslation()
return (
<div className='space-y-0.5'>
<div className='flex h-6 items-center gap-2'>
<div className='system-xs-medium text-text-secondary'>{data.label}</div>
<div className='system-xs-medium text-text-quaternary'>·</div>
<div className='system-xs-medium text-text-secondary'>{data.variable}</div>
<div className='system-xs-medium text-text-tertiary'>{data.type}</div>
</div>
<Textarea
className='h-8 resize-none'
value={value}
placeholder={t('tools.mcp.server.modal.parametersPlaceholder')}
onChange={e => onChange(e.target.value)}
></Textarea>
</div>
)
}
export default MCPServerParamItem

View File

@@ -0,0 +1,294 @@
'use client'
import React, { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiEditLine, RiLoopLeftLine } from '@remixicon/react'
import {
Mcp,
} from '@/app/components/base/icons/src/vender/other'
import Button from '@/app/components/base/button'
import Tooltip from '@/app/components/base/tooltip'
import Switch from '@/app/components/base/switch'
import Divider from '@/app/components/base/divider'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Confirm from '@/app/components/base/confirm'
import type { AppDetailResponse } from '@/models/app'
import { useAppContext } from '@/context/app-context'
import { AppModeEnum, type AppSSO } from '@/types/app'
import Indicator from '@/app/components/header/indicator'
import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal'
import { useAppWorkflow } from '@/service/use-workflow'
import {
useInvalidateMCPServerDetail,
useMCPServerDetail,
useRefreshMCPServerCode,
useUpdateMCPServer,
} from '@/service/use-tools'
import { BlockEnum } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
import { fetchAppDetail } from '@/service/apps'
import { useDocLink } from '@/context/i18n'
export type IAppCardProps = {
appInfo: AppDetailResponse & Partial<AppSSO>
triggerModeDisabled?: boolean // align with Trigger Node vs User Input exclusivity
triggerModeMessage?: React.ReactNode // display-only message explaining the trigger restriction
}
function MCPServiceCard({
appInfo,
triggerModeDisabled = false,
triggerModeMessage = '',
}: IAppCardProps) {
const { t } = useTranslation()
const docLink = useDocLink()
const appId = appInfo.id
const { mutateAsync: updateMCPServer } = useUpdateMCPServer()
const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode()
const invalidateMCPServerDetail = useInvalidateMCPServerDetail()
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showMCPServerModal, setShowMCPServerModal] = useState(false)
const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW
const isBasicApp = !isAdvancedApp
const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '')
const [basicAppConfig, setBasicAppConfig] = useState<any>({})
const basicAppInputForm = useMemo(() => {
if(!isBasicApp || !basicAppConfig?.user_input_form)
return []
return basicAppConfig.user_input_form.map((item: any) => {
const type = Object.keys(item)[0]
return {
...item[type],
type: type || 'text-input',
}
})
}, [basicAppConfig.user_input_form, isBasicApp])
useEffect(() => {
if(isBasicApp && appId) {
(async () => {
const res = await fetchAppDetail({ url: '/apps', id: appId })
setBasicAppConfig(res?.model_config || {})
})()
}
}, [appId, isBasicApp])
const { data: detail } = useMCPServerDetail(appId)
const { id, status, server_code } = detail ?? {}
const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at
const serverPublished = !!id
const serverActivated = status === 'active'
const serverURL = serverPublished ? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp` : '***********'
const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start)
const missingStartNode = isWorkflowApp && !hasStartNode
const hasInsufficientPermissions = !isCurrentWorkspaceEditor
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
const isMinimalState = appUnpublished || missingStartNode
const [activated, setActivated] = useState(serverActivated)
const latestParams = useMemo(() => {
if(isAdvancedApp) {
if (!currentWorkflow?.graph)
return []
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as any
return startNode?.data.variables as any[] || []
}
return basicAppInputForm
}, [currentWorkflow, basicAppInputForm, isAdvancedApp])
const onGenCode = async () => {
await refreshMCPServerCode(detail?.id || '')
invalidateMCPServerDetail(appId)
}
const onChangeStatus = async (state: boolean) => {
setActivated(state)
if (state) {
if (!serverPublished) {
setShowMCPServerModal(true)
return
}
await updateMCPServer({
appID: appId,
id: id || '',
description: detail?.description || '',
parameters: detail?.parameters || {},
status: 'active',
})
invalidateMCPServerDetail(appId)
}
else {
await updateMCPServer({
appID: appId,
id: id || '',
description: detail?.description || '',
parameters: detail?.parameters || {},
status: 'inactive',
})
invalidateMCPServerDetail(appId)
}
}
const handleServerModalHide = () => {
setShowMCPServerModal(false)
if (!serverActivated)
setActivated(false)
}
useEffect(() => {
setActivated(serverActivated)
}, [serverActivated])
if (!currentWorkflow && isAdvancedApp)
return null
return (
<>
<div className={cn('w-full max-w-full rounded-xl border-l-[0.5px] border-t border-effects-highlight', isMinimalState && 'h-12')}>
<div className={cn('relative rounded-xl bg-background-default', triggerModeDisabled && 'opacity-60')}>
{triggerModeDisabled && (
triggerModeMessage ? (
<Tooltip
popupContent={triggerModeMessage}
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
position="right"
>
<div className='absolute inset-0 z-10 cursor-not-allowed rounded-xl' aria-hidden="true"></div>
</Tooltip>
) : <div className='absolute inset-0 z-10 cursor-not-allowed rounded-xl' aria-hidden="true"></div>
)}
<div className={cn('flex w-full flex-col items-start justify-center gap-3 self-stretch p-3', isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle')}>
<div className='flex w-full items-center gap-3 self-stretch'>
<div className='flex grow items-center'>
<div className='mr-2 shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md'>
<Mcp className='h-4 w-4 text-text-primary-on-surface' />
</div>
<div className="group w-full">
<div className="system-md-semibold min-w-0 overflow-hidden text-ellipsis break-normal text-text-secondary group-hover:text-text-primary">
{t('tools.mcp.server.title')}
</div>
</div>
</div>
<div className='flex items-center gap-1'>
<Indicator color={serverActivated ? 'green' : 'yellow'} />
<div className={`${serverActivated ? 'text-text-success' : 'text-text-warning'} system-xs-semibold-uppercase`}>
{serverActivated
? t('appOverview.overview.status.running')
: t('appOverview.overview.status.disable')}
</div>
</div>
<Tooltip
popupContent={
toggleDisabled ? (
appUnpublished ? (
t('tools.mcp.server.publishTip')
) : missingStartNode ? (
<>
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('appOverview.overview.appInfo.enableTooltip.description')}
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
onClick={() => window.open(docLink('/guides/workflow/node/user-input'), '_blank')}
>
{t('appOverview.overview.appInfo.enableTooltip.learnMore')}
</div>
</>
) : triggerModeMessage || ''
) : ''
}
position="right"
popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg"
offset={24}
>
<div>
<Switch defaultValue={activated} onChange={onChangeStatus} disabled={toggleDisabled} />
</div>
</Tooltip>
</div>
{!isMinimalState && (
<div className='flex flex-col items-start justify-center self-stretch'>
<div className="system-xs-medium pb-1 text-text-tertiary">
{t('tools.mcp.server.url')}
</div>
<div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2">
<div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1">
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary">
{serverURL}
</div>
</div>
{serverPublished && (
<>
<CopyFeedback
content={serverURL}
className={'!size-6'}
/>
<Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" />
{isCurrentWorkspaceManager && (
<Tooltip
popupContent={t('appOverview.overview.appInfo.regenerate') || ''}
>
<div
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
onClick={() => setShowConfirmDelete(true)}
>
<RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')}/>
</div>
</Tooltip>
)}
</>
)}
</div>
</div>
)}
</div>
{!isMinimalState && (
<div className='flex items-center gap-1 self-stretch p-3'>
<Button
disabled={toggleDisabled}
size='small'
variant='ghost'
onClick={() => setShowMCPServerModal(true)}
>
<div className="flex items-center justify-center gap-[1px]">
<RiEditLine className="h-3.5 w-3.5" />
<div className="system-xs-medium px-[3px] text-text-tertiary">{serverPublished ? t('tools.mcp.server.edit') : t('tools.mcp.server.addDescription')}</div>
</div>
</Button>
</div>
)}
</div>
</div>
{showMCPServerModal && (
<MCPServerModal
show={showMCPServerModal}
appID={appId}
data={serverPublished ? detail : undefined}
latestParams={latestParams}
onHide={handleServerModalHide}
appInfo={appInfo}
/>
)}
{/* button copy link/ button regenerate */}
{showConfirmDelete && (
<Confirm
type='warning'
title={t('appOverview.overview.appInfo.regenerate')}
content={t('tools.mcp.server.reGen')}
isShow={showConfirmDelete}
onConfirm={() => {
onGenCode()
setShowConfirmDelete(false)
}}
onCancel={() => setShowConfirmDelete(false)}
/>
)}
</>
)
}
export default MCPServiceCard

View File

@@ -0,0 +1,154 @@
const tools = [
{
author: 'Novice',
name: 'NOTION_ADD_PAGE_CONTENT',
label: {
en_US: 'NOTION_ADD_PAGE_CONTENT',
zh_Hans: 'NOTION_ADD_PAGE_CONTENT',
pt_BR: 'NOTION_ADD_PAGE_CONTENT',
ja_JP: 'NOTION_ADD_PAGE_CONTENT',
},
description: {
en_US: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)',
zh_Hans: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)',
pt_BR: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)',
ja_JP: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)',
},
parameters: [
{
name: 'after',
label: {
en_US: 'after',
zh_Hans: 'after',
pt_BR: 'after',
ja_JP: 'after',
},
placeholder: null,
scope: null,
auto_generate: null,
template: null,
required: false,
default: null,
min: null,
max: null,
precision: null,
options: [],
type: 'string',
human_description: {
en_US: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
zh_Hans: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
pt_BR: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
ja_JP: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
},
form: 'llm',
llm_description: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
},
{
name: 'content_block',
label: {
en_US: 'content_block',
zh_Hans: 'content_block',
pt_BR: 'content_block',
ja_JP: 'content_block',
},
placeholder: null,
scope: null,
auto_generate: null,
template: null,
required: false,
default: null,
min: null,
max: null,
precision: null,
options: [],
type: 'string',
human_description: {
en_US: 'Child content to append to a page.',
zh_Hans: 'Child content to append to a page.',
pt_BR: 'Child content to append to a page.',
ja_JP: 'Child content to append to a page.',
},
form: 'llm',
llm_description: 'Child content to append to a page.',
},
{
name: 'parent_block_id',
label: {
en_US: 'parent_block_id',
zh_Hans: 'parent_block_id',
pt_BR: 'parent_block_id',
ja_JP: 'parent_block_id',
},
placeholder: null,
scope: null,
auto_generate: null,
template: null,
required: false,
default: null,
min: null,
max: null,
precision: null,
options: [],
type: 'string',
human_description: {
en_US: 'The ID of the page which the children will be added.',
zh_Hans: 'The ID of the page which the children will be added.',
pt_BR: 'The ID of the page which the children will be added.',
ja_JP: 'The ID of the page which the children will be added.',
},
form: 'llm',
llm_description: 'The ID of the page which the children will be added.',
},
],
labels: [],
output_schema: null,
},
]
export const listData = [
{
id: 'fdjklajfkljadslf111',
author: 'KVOJJJin',
name: 'GOGOGO',
icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US',
server_url: 'https://mcp.composio.dev/notion/****/abc',
type: 'mcp',
is_team_authorization: true,
tools,
update_elapsed_time: 1744793369,
label: {
en_US: 'GOGOGO',
zh_Hans: 'GOGOGO',
},
},
{
id: 'fdjklajfkljadslf222',
author: 'KVOJJJin',
name: 'GOGOGO2',
icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US',
server_url: 'https://mcp.composio.dev/notion/****/abc',
type: 'mcp',
is_team_authorization: false,
tools: [],
update_elapsed_time: 1744793369,
label: {
en_US: 'GOGOGO2',
zh_Hans: 'GOGOGO2',
},
},
{
id: 'fdjklajfkljadslf333',
author: 'KVOJJJin',
name: 'GOGOGO3',
icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US',
server_url: 'https://mcp.composio.dev/notion/****/abc',
type: 'mcp',
is_team_authorization: true,
tools,
update_elapsed_time: 1744793369,
label: {
en_US: 'GOGOGO3',
zh_Hans: 'GOGOGO3',
},
},
]

View File

@@ -0,0 +1,424 @@
'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<string, string>
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<AppIconSelection>(() => 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<HeaderItem[]>(
Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })),
)
const [isFetchingIcon, setIsFetchingIcon] = useState(false)
const appIconRef = useRef<HTMLDivElement>(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<string, string>)
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 (
<>
<Modal
isShow={show}
onClose={noop}
className={cn('relative !max-w-[520px]', 'p-6')}
>
<div className='absolute right-5 top-5 z-10 cursor-pointer p-1.5' onClick={onHide}>
<RiCloseLine className='h-5 w-5 text-text-tertiary' />
</div>
<div className='title-2xl-semi-bold relative pb-3 text-xl text-text-primary'>{!isCreate ? t('tools.mcp.modal.editTitle') : t('tools.mcp.modal.title')}</div>
<div className='space-y-5 py-3'>
<div>
<div className='mb-1 flex h-6 items-center'>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.serverUrl')}</span>
</div>
<Input
value={url}
onChange={e => setUrl(e.target.value)}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('tools.mcp.modal.serverUrlPlaceholder')}
/>
{originalServerUrl && originalServerUrl !== url && (
<div className='mt-1 flex h-5 items-center'>
<span className='body-xs-regular text-text-warning'>{t('tools.mcp.modal.serverUrlWarning')}</span>
</div>
)}
</div>
<div className='flex space-x-3'>
<div className='grow pb-1'>
<div className='mb-1 flex h-6 items-center'>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.name')}</span>
</div>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('tools.mcp.modal.namePlaceholder')}
/>
</div>
<div className='pt-2' ref={appIconRef}>
<AppIcon
iconType={appIcon.type}
icon={appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId}
background={appIcon.type === 'emoji' ? appIcon.background : undefined}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
innerIcon={shouldUseMcpIconForAppIcon(appIcon.type, appIcon.type === 'emoji' ? appIcon.icon : '') ? <Mcp className='h-8 w-8 text-text-primary-on-surface' /> : undefined}
size='xxl'
className='relative cursor-pointer rounded-2xl'
coverElement={
isHovering
? (<div className='absolute inset-0 flex items-center justify-center overflow-hidden rounded-2xl bg-background-overlay-alt'>
<RiEditLine className='size-6 text-text-primary-on-surface' />
</div>) : null
}
onClick={() => { setShowAppIconPicker(true) }}
/>
</div>
</div>
<div>
<div className='flex h-6 items-center'>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.serverIdentifier')}</span>
</div>
<div className='body-xs-regular mb-1 text-text-tertiary'>{t('tools.mcp.modal.serverIdentifierTip')}</div>
<Input
value={serverIdentifier}
onChange={e => setServerIdentifier(e.target.value)}
placeholder={t('tools.mcp.modal.serverIdentifierPlaceholder')}
/>
{originalServerID && originalServerID !== serverIdentifier && (
<div className='mt-1 flex h-5 items-center'>
<span className='body-xs-regular text-text-warning'>{t('tools.mcp.modal.serverIdentifierWarning')}</span>
</div>
)}
</div>
<TabSlider
className='w-full'
itemClassName={(isActive) => {
return `flex-1 ${isActive && 'text-text-accent-light-mode-only'}`
}}
value={authMethod}
onChange={handleAuthMethodChange}
options={authMethods}
/>
{
authMethod === MCPAuthMethod.authentication && (
<>
<div>
<div className='mb-1 flex h-6 items-center'>
<Switch
className='mr-2'
defaultValue={isDynamicRegistration}
onChange={setIsDynamicRegistration}
/>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.useDynamicClientRegistration')}</span>
</div>
{!isDynamicRegistration && (
<div className='mt-2 flex gap-2 rounded-lg bg-state-warning-hover p-3'>
<AlertTriangle className='mt-0.5 h-4 w-4 shrink-0 text-text-warning' />
<div className='system-xs-regular text-text-secondary'>
<div className='mb-1'>{t('tools.mcp.modal.redirectUrlWarning')}</div>
<code className='system-xs-medium block break-all rounded bg-state-warning-active px-2 py-1 text-text-secondary'>
{`${API_PREFIX}/mcp/oauth/callback`}
</code>
</div>
</div>
)}
</div>
<div>
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.clientID')}</span>
</div>
<Input
value={clientID}
onChange={e => setClientID(e.target.value)}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('tools.mcp.modal.clientID')}
disabled={isDynamicRegistration}
/>
</div>
<div>
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.clientSecret')}</span>
</div>
<Input
value={credentials}
onChange={e => setCredentials(e.target.value)}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('tools.mcp.modal.clientSecretPlaceholder')}
disabled={isDynamicRegistration}
/>
</div>
</>
)
}
{
authMethod === MCPAuthMethod.headers && (
<div>
<div className='mb-1 flex h-6 items-center'>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.headers')}</span>
</div>
<div className='body-xs-regular mb-2 text-text-tertiary'>{t('tools.mcp.modal.headersTip')}</div>
<HeadersInput
headersItems={headers}
onChange={setHeaders}
readonly={false}
isMasked={!isCreate && headers.filter(item => item.key.trim()).length > 0}
/>
</div>
)
}
{
authMethod === MCPAuthMethod.configurations && (
<>
<div>
<div className='mb-1 flex h-6 items-center'>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.timeout')}</span>
</div>
<Input
type='number'
value={timeout}
onChange={e => setMcpTimeout(Number(e.target.value))}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('tools.mcp.modal.timeoutPlaceholder')}
/>
</div>
<div>
<div className='mb-1 flex h-6 items-center'>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.sseReadTimeout')}</span>
</div>
<Input
type='number'
value={sseReadTimeout}
onChange={e => setSseReadTimeout(Number(e.target.value))}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('tools.mcp.modal.timeoutPlaceholder')}
/>
</div>
</>
)
}
</div>
<div className='flex flex-row-reverse pt-5'>
<Button disabled={!name || !url || !serverIdentifier || isFetchingIcon} className='ml-2' variant='primary' onClick={submit}>{data ? t('tools.mcp.modal.save') : t('tools.mcp.modal.confirm')}</Button>
<Button onClick={onHide}>{t('tools.mcp.modal.cancel')}</Button>
</div>
</Modal>
{showAppIconPicker && <AppIconPicker
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setAppIcon(getIcon(data))
setShowAppIconPicker(false)
}}
/>}
</>
)
}
export default MCPModal

View File

@@ -0,0 +1,152 @@
'use client'
import { useCallback, useState } from 'react'
import { useBoolean } from 'ahooks'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { RiHammerFill } from '@remixicon/react'
import Indicator from '@/app/components/header/indicator'
import Icon from '@/app/components/plugins/card/base/card-icon'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import type { ToolWithProvider } from '../../workflow/types'
import Confirm from '@/app/components/base/confirm'
import MCPModal from './modal'
import OperationDropdown from './detail/operation-dropdown'
import { useDeleteMCP, useUpdateMCP } from '@/service/use-tools'
import cn from '@/utils/classnames'
type Props = {
currentProvider?: ToolWithProvider
data: ToolWithProvider
handleSelect: (providerID: string) => void
onUpdate: (providerID: string) => void
onDeleted: () => void
}
const MCPCard = ({
currentProvider,
data,
onUpdate,
handleSelect,
onDeleted,
}: Props) => {
const { t } = useTranslation()
const { formatTimeFromNow } = useFormatTimeFromNow()
const { isCurrentWorkspaceManager } = useAppContext()
const { mutateAsync: updateMCP } = useUpdateMCP({})
const { mutateAsync: deleteMCP } = useDeleteMCP({})
const [isOperationShow, setIsOperationShow] = useState(false)
const [isShowUpdateModal, {
setTrue: showUpdateModal,
setFalse: hideUpdateModal,
}] = useBoolean(false)
const [isShowDeleteConfirm, {
setTrue: showDeleteConfirm,
setFalse: hideDeleteConfirm,
}] = useBoolean(false)
const [deleting, {
setTrue: showDeleting,
setFalse: hideDeleting,
}] = useBoolean(false)
const handleUpdate = useCallback(async (form: any) => {
const res = await updateMCP({
...form,
provider_id: data.id,
})
if ((res as any)?.result === 'success') {
hideUpdateModal()
onUpdate(data.id)
}
}, [data, updateMCP, hideUpdateModal, onUpdate])
const handleDelete = useCallback(async () => {
showDeleting()
const res = await deleteMCP(data.id)
hideDeleting()
if ((res as any)?.result === 'success') {
hideDeleteConfirm()
onDeleted()
}
}, [showDeleting, deleteMCP, data.id, hideDeleting, hideDeleteConfirm, onDeleted])
return (
<div
onClick={() => handleSelect(data.id)}
className={cn(
'group relative flex cursor-pointer flex-col rounded-xl border-[1.5px] border-transparent bg-components-card-bg shadow-xs hover:bg-components-card-bg-alt hover:shadow-md',
currentProvider?.id === data.id && 'border-components-option-card-option-selected-border bg-components-card-bg-alt',
)}
>
<div className='flex grow items-center gap-3 rounded-t-xl p-4'>
<div className='shrink-0 overflow-hidden rounded-xl border border-components-panel-border-subtle'>
<Icon src={data.icon} />
</div>
<div className='grow'>
<div className='system-md-semibold mb-1 truncate text-text-secondary' title={data.name}>{data.name}</div>
<div className='system-xs-regular text-text-tertiary'>{data.server_identifier}</div>
</div>
</div>
<div className='flex items-center gap-1 rounded-b-xl pb-2.5 pl-4 pr-2.5 pt-1.5'>
<div className='flex w-0 grow items-center gap-2'>
<div className='flex items-center gap-1'>
<RiHammerFill className='h-3 w-3 shrink-0 text-text-quaternary' />
{data.tools.length > 0 && (
<div className='system-xs-regular shrink-0 text-text-tertiary'>{t('tools.mcp.toolsCount', { count: data.tools.length })}</div>
)}
{!data.tools.length && (
<div className='system-xs-regular shrink-0 text-text-tertiary'>{t('tools.mcp.noTools')}</div>
)}
</div>
<div className={cn('system-xs-regular text-divider-deep', (!data.is_team_authorization || !data.tools.length) && 'sm:hidden')}>/</div>
<div className={cn('system-xs-regular truncate text-text-tertiary', (!data.is_team_authorization || !data.tools.length) && ' sm:hidden')} title={`${t('tools.mcp.updateTime')} ${formatTimeFromNow(data.updated_at! * 1000)}`}>{`${t('tools.mcp.updateTime')} ${formatTimeFromNow(data.updated_at! * 1000)}`}</div>
</div>
{data.is_team_authorization && data.tools.length > 0 && <Indicator color='green' className='shrink-0' />}
{(!data.is_team_authorization || !data.tools.length) && (
<div className='system-xs-medium flex shrink-0 items-center gap-1 rounded-md border border-util-colors-red-red-500 bg-components-badge-bg-red-soft px-1.5 py-0.5 text-util-colors-red-red-500'>
{t('tools.mcp.noConfigured')}
<Indicator color='red' />
</div>
)}
</div>
{isCurrentWorkspaceManager && (
<div className={cn('absolute right-2.5 top-2.5 hidden group-hover:block', isOperationShow && 'block')} onClick={e => e.stopPropagation()}>
<OperationDropdown
inCard
onOpenChange={setIsOperationShow}
onEdit={showUpdateModal}
onRemove={showDeleteConfirm}
/>
</div>
)}
{isShowUpdateModal && (
<MCPModal
data={data}
show={isShowUpdateModal}
onConfirm={handleUpdate}
onHide={hideUpdateModal}
/>
)}
{isShowDeleteConfirm && (
<Confirm
isShow
title={t('tools.mcp.delete')}
content={
<div>
{t('tools.mcp.deleteConfirmTitle', { mcp: data.name })}
</div>
}
onCancel={hideDeleteConfirm}
onConfirm={handleDelete}
isLoading={deleting}
isDisabled={deleting}
/>
)}
</div>
)
}
export default MCPCard