### What problem does this PR solve? Feat: Edit MCP server #3221 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.20.0
| CollapsibleContent, | CollapsibleContent, | ||||
| CollapsibleTrigger, | CollapsibleTrigger, | ||||
| } from '@/components/ui/collapsible'; | } from '@/components/ui/collapsible'; | ||||
| import { CollapsibleProps } from '@radix-ui/react-collapsible'; | |||||
| import { ListCollapse } from 'lucide-react'; | import { ListCollapse } from 'lucide-react'; | ||||
| import { PropsWithChildren, ReactNode } from 'react'; | import { PropsWithChildren, ReactNode } from 'react'; | ||||
| type CollapseProps = { | |||||
| type CollapseProps = Omit<CollapsibleProps, 'title'> & { | |||||
| title?: ReactNode; | title?: ReactNode; | ||||
| rightContent?: ReactNode; | rightContent?: ReactNode; | ||||
| } & PropsWithChildren; | } & PropsWithChildren; | ||||
| export function Collapse({ title, children, rightContent }: CollapseProps) { | |||||
| export function Collapse({ | |||||
| title, | |||||
| children, | |||||
| rightContent, | |||||
| open, | |||||
| defaultOpen = true, | |||||
| onOpenChange, | |||||
| disabled, | |||||
| }: CollapseProps) { | |||||
| return ( | return ( | ||||
| <Collapsible defaultOpen> | |||||
| <Collapsible | |||||
| defaultOpen={defaultOpen} | |||||
| open={open} | |||||
| onOpenChange={onOpenChange} | |||||
| disabled={disabled} | |||||
| > | |||||
| <CollapsibleTrigger className="w-full"> | <CollapsibleTrigger className="w-full"> | ||||
| <section className="flex justify-between items-center pb-2"> | <section className="flex justify-between items-center pb-2"> | ||||
| <div className="flex items-center gap-1"> | <div className="flex items-center gap-1"> | 
| import message from '@/components/ui/message'; | import message from '@/components/ui/message'; | ||||
| import { IMcpServerListResponse, IMCPTool } from '@/interfaces/database/mcp'; | |||||
| import { | |||||
| IMcpServer, | |||||
| IMcpServerListResponse, | |||||
| IMCPTool, | |||||
| } from '@/interfaces/database/mcp'; | |||||
| import { ITestMcpRequestBody } from '@/interfaces/request/mcp'; | import { ITestMcpRequestBody } from '@/interfaces/request/mcp'; | ||||
| import i18n from '@/locales/config'; | import i18n from '@/locales/config'; | ||||
| import mcpServerService from '@/services/mcp-server-service'; | import mcpServerService from '@/services/mcp-server-service'; | ||||
| import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import { useState } from 'react'; | |||||
| export const enum McpApiAction { | export const enum McpApiAction { | ||||
| ListMcpServer = 'listMcpServer', | ListMcpServer = 'listMcpServer', | ||||
| return { data, loading }; | return { data, loading }; | ||||
| }; | }; | ||||
| export const useGetMcpServer = () => { | |||||
| const [id, setId] = useState(''); | |||||
| const { data, isFetching: loading } = useQuery({ | |||||
| export const useGetMcpServer = (id: string) => { | |||||
| const { data, isFetching: loading } = useQuery<IMcpServer>({ | |||||
| queryKey: [McpApiAction.GetMcpServer, id], | queryKey: [McpApiAction.GetMcpServer, id], | ||||
| initialData: {}, | |||||
| initialData: {} as IMcpServer, | |||||
| gcTime: 0, | gcTime: 0, | ||||
| enabled: !!id, | enabled: !!id, | ||||
| queryFn: async () => { | queryFn: async () => { | ||||
| const { data } = await mcpServerService.get(); | |||||
| const { data } = await mcpServerService.get({ mcp_id: id }); | |||||
| return data?.data ?? {}; | return data?.data ?? {}; | ||||
| }, | }, | ||||
| }); | }); | ||||
| return { data, loading, setId, id }; | |||||
| return { data, loading, id }; | |||||
| }; | }; | ||||
| export const useCreateMcpServer = () => { | export const useCreateMcpServer = () => { | 
| DialogHeader, | DialogHeader, | ||||
| DialogTitle, | DialogTitle, | ||||
| } from '@/components/ui/dialog'; | } from '@/components/ui/dialog'; | ||||
| import { useTestMcpServer } from '@/hooks/use-mcp-request'; | |||||
| import { useGetMcpServer, useTestMcpServer } from '@/hooks/use-mcp-request'; | |||||
| import { IModalProps } from '@/interfaces/common'; | import { IModalProps } from '@/interfaces/common'; | ||||
| import { IMCPTool, IMCPToolObject } from '@/interfaces/database/mcp'; | import { IMCPTool, IMCPToolObject } from '@/interfaces/database/mcp'; | ||||
| import { omit } from 'lodash'; | |||||
| import { zodResolver } from '@hookform/resolvers/zod'; | |||||
| import { isEmpty, omit, pick } from 'lodash'; | |||||
| import { RefreshCw } from 'lucide-react'; | import { RefreshCw } from 'lucide-react'; | ||||
| import { MouseEventHandler, useCallback, useState } from 'react'; | |||||
| import { MouseEventHandler, useCallback, useMemo, useState } from 'react'; | |||||
| import { useForm } from 'react-hook-form'; | |||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import { z } from 'zod'; | import { z } from 'zod'; | ||||
| import { EditMcpForm, FormId, useBuildFormSchema } from './edit-mcp-form'; | |||||
| import { | |||||
| EditMcpForm, | |||||
| FormId, | |||||
| ServerType, | |||||
| useBuildFormSchema, | |||||
| } from './edit-mcp-form'; | |||||
| import { McpToolCard } from './tool-card'; | import { McpToolCard } from './tool-card'; | ||||
| function transferToolToObject(tools: IMCPTool[] = []) { | function transferToolToObject(tools: IMCPTool[] = []) { | ||||
| }, {}); | }, {}); | ||||
| } | } | ||||
| export function EditMcpDialog({ hideModal, loading, onOk }: IModalProps<any>) { | |||||
| function transferToolToArray(tools: IMCPToolObject) { | |||||
| return Object.entries(tools).reduce<IMCPTool[]>((pre, [name, tool]) => { | |||||
| pre.push({ ...tool, name }); | |||||
| return pre; | |||||
| }, []); | |||||
| } | |||||
| export function EditMcpDialog({ | |||||
| hideModal, | |||||
| loading, | |||||
| onOk, | |||||
| id, | |||||
| }: IModalProps<any> & { id: string }) { | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const { testMcpServer, data: tools } = useTestMcpServer(); | |||||
| const { | |||||
| testMcpServer, | |||||
| data: tools, | |||||
| loading: testLoading, | |||||
| } = useTestMcpServer(); | |||||
| const [isTriggeredBySaving, setIsTriggeredBySaving] = useState(false); | const [isTriggeredBySaving, setIsTriggeredBySaving] = useState(false); | ||||
| const FormSchema = useBuildFormSchema(); | const FormSchema = useBuildFormSchema(); | ||||
| const [collapseOpen, setCollapseOpen] = useState(true); | |||||
| const { data } = useGetMcpServer(id); | |||||
| const form = useForm<z.infer<typeof FormSchema>>({ | |||||
| resolver: zodResolver(FormSchema), | |||||
| values: isEmpty(data) | |||||
| ? { name: '', server_type: ServerType.SSE, url: '' } | |||||
| : pick(data, ['name', 'server_type', 'url']), | |||||
| }); | |||||
| console.log('🚀 ~ form:', form.formState.dirtyFields); | |||||
| const handleTest: MouseEventHandler<HTMLButtonElement> = useCallback((e) => { | const handleTest: MouseEventHandler<HTMLButtonElement> = useCallback((e) => { | ||||
| e.stopPropagation(); | e.stopPropagation(); | ||||
| } | } | ||||
| }; | }; | ||||
| const nextTools = useMemo(() => { | |||||
| return tools || transferToolToArray(data.variables?.tools || {}); | |||||
| }, [data.variables?.tools, tools]); | |||||
| const dirtyFields = form.formState.dirtyFields; | |||||
| const fieldChanged = 'server_type' in dirtyFields || 'url' in dirtyFields; | |||||
| const disabled = !!!tools?.length || testLoading || fieldChanged; | |||||
| return ( | return ( | ||||
| <Dialog open onOpenChange={hideModal}> | <Dialog open onOpenChange={hideModal}> | ||||
| <DialogContent> | <DialogContent> | ||||
| <DialogHeader> | <DialogHeader> | ||||
| <DialogTitle>Edit profile</DialogTitle> | <DialogTitle>Edit profile</DialogTitle> | ||||
| </DialogHeader> | </DialogHeader> | ||||
| <EditMcpForm onOk={handleOk}></EditMcpForm> | |||||
| <EditMcpForm onOk={handleOk} form={form}></EditMcpForm> | |||||
| <Collapse | <Collapse | ||||
| title={<div>{tools?.length || 0} tools available</div>} | title={<div>{tools?.length || 0} tools available</div>} | ||||
| open={collapseOpen} | |||||
| onOpenChange={setCollapseOpen} | |||||
| rightContent={ | rightContent={ | ||||
| <Button | <Button | ||||
| variant={'ghost'} | variant={'ghost'} | ||||
| </Button> | </Button> | ||||
| } | } | ||||
| > | > | ||||
| <div className="space-y-2.5"> | |||||
| {tools?.map((x) => ( | |||||
| <div className="space-y-2.5 overflow-auto max-h-80"> | |||||
| {nextTools?.map((x) => ( | |||||
| <McpToolCard key={x.name} data={x}></McpToolCard> | <McpToolCard key={x.name} data={x}></McpToolCard> | ||||
| ))} | ))} | ||||
| </div> | </div> | ||||
| form={FormId} | form={FormId} | ||||
| loading={loading} | loading={loading} | ||||
| onClick={handleSave} | onClick={handleSave} | ||||
| disabled={!!!tools?.length} | |||||
| disabled={disabled} | |||||
| > | > | ||||
| {t('common.save')} | {t('common.save')} | ||||
| </ButtonLoading> | </ButtonLoading> | 
| 'use client'; | 'use client'; | ||||
| import { zodResolver } from '@hookform/resolvers/zod'; | |||||
| import { useForm } from 'react-hook-form'; | |||||
| import { UseFormReturn } from 'react-hook-form'; | |||||
| import { z } from 'zod'; | import { z } from 'zod'; | ||||
| import { | import { | ||||
| import { RAGFlowSelect } from '@/components/ui/select'; | import { RAGFlowSelect } from '@/components/ui/select'; | ||||
| import { IModalProps } from '@/interfaces/common'; | import { IModalProps } from '@/interfaces/common'; | ||||
| import { buildOptions } from '@/utils/form'; | import { buildOptions } from '@/utils/form'; | ||||
| import { useEffect } from 'react'; | |||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| export const FormId = 'EditMcpForm'; | export const FormId = 'EditMcpForm'; | ||||
| enum ServerType { | |||||
| export enum ServerType { | |||||
| SSE = 'sse', | SSE = 'sse', | ||||
| StreamableHttp = 'streamable-http', | StreamableHttp = 'streamable-http', | ||||
| } | } | ||||
| } | } | ||||
| export function EditMcpForm({ | export function EditMcpForm({ | ||||
| initialName, | |||||
| form, | |||||
| onOk, | onOk, | ||||
| }: IModalProps<any> & { initialName?: string }) { | |||||
| }: IModalProps<any> & { form: UseFormReturn<any> }) { | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const FormSchema = useBuildFormSchema(); | const FormSchema = useBuildFormSchema(); | ||||
| const form = useForm<z.infer<typeof FormSchema>>({ | |||||
| resolver: zodResolver(FormSchema), | |||||
| defaultValues: { name: '', server_type: ServerType.SSE, url: '' }, | |||||
| }); | |||||
| function onSubmit(data: z.infer<typeof FormSchema>) { | function onSubmit(data: z.infer<typeof FormSchema>) { | ||||
| onOk?.(data); | onOk?.(data); | ||||
| } | } | ||||
| useEffect(() => { | |||||
| if (initialName) { | |||||
| form.setValue('name', initialName); | |||||
| } | |||||
| }, [form, initialName]); | |||||
| return ( | return ( | ||||
| <Form {...form}> | <Form {...form}> | ||||
| <form | <form | 
| export default function McpServer() { | export default function McpServer() { | ||||
| const { data } = useListMcpServer(); | const { data } = useListMcpServer(); | ||||
| const { editVisible, showEditModal, hideEditModal, handleOk } = useEditMcp(); | |||||
| const { editVisible, showEditModal, hideEditModal, handleOk, id } = | |||||
| useEditMcp(); | |||||
| const { list, selectedList, handleSelectChange } = useBulkOperateMCP(); | const { list, selectedList, handleSelectChange } = useBulkOperateMCP(); | ||||
| return ( | return ( | ||||
| data={item} | data={item} | ||||
| selectedList={selectedList} | selectedList={selectedList} | ||||
| handleSelectChange={handleSelectChange} | handleSelectChange={handleSelectChange} | ||||
| showEditModal={showEditModal} | |||||
| ></McpCard> | ></McpCard> | ||||
| ))} | ))} | ||||
| </section> | </section> | ||||
| <EditMcpDialog | <EditMcpDialog | ||||
| hideModal={hideEditModal} | hideModal={hideEditModal} | ||||
| onOk={handleOk} | onOk={handleOk} | ||||
| id={id} | |||||
| ></EditMcpDialog> | ></EditMcpDialog> | ||||
| )} | )} | ||||
| </section> | </section> | 
| import { useMemo } from 'react'; | import { useMemo } from 'react'; | ||||
| import { McpDropdown } from './mcp-dropdown'; | import { McpDropdown } from './mcp-dropdown'; | ||||
| import { UseBulkOperateMCPReturnType } from './use-bulk-operate-mcp'; | import { UseBulkOperateMCPReturnType } from './use-bulk-operate-mcp'; | ||||
| import { UseEditMcpReturnType } from './use-edit-mcp'; | |||||
| export type DatasetCardProps = { | export type DatasetCardProps = { | ||||
| data: IMcpServer; | data: IMcpServer; | ||||
| } & Pick<UseBulkOperateMCPReturnType, 'handleSelectChange' | 'selectedList'>; | |||||
| } & Pick<UseBulkOperateMCPReturnType, 'handleSelectChange' | 'selectedList'> & | |||||
| Pick<UseEditMcpReturnType, 'showEditModal'>; | |||||
| export function McpCard({ | export function McpCard({ | ||||
| data, | data, | ||||
| selectedList, | selectedList, | ||||
| handleSelectChange, | handleSelectChange, | ||||
| showEditModal, | |||||
| }: DatasetCardProps) { | }: DatasetCardProps) { | ||||
| const toolLength = useMemo(() => { | const toolLength = useMemo(() => { | ||||
| const tools = data.variables?.tools; | const tools = data.variables?.tools; | ||||
| <section className="flex justify-between pb-2"> | <section className="flex justify-between pb-2"> | ||||
| <h3 className="text-lg font-semibold line-clamp-1">{data.name}</h3> | <h3 className="text-lg font-semibold line-clamp-1">{data.name}</h3> | ||||
| <div className="space-x-4"> | <div className="space-x-4"> | ||||
| <McpDropdown mcpId={data.id}> | |||||
| <McpDropdown mcpId={data.id} showEditModal={showEditModal}> | |||||
| <MoreButton></MoreButton> | <MoreButton></MoreButton> | ||||
| </McpDropdown> | </McpDropdown> | ||||
| <Checkbox | <Checkbox | 
| import { PenLine, Trash2 } from 'lucide-react'; | import { PenLine, Trash2 } from 'lucide-react'; | ||||
| import { MouseEventHandler, PropsWithChildren, useCallback } from 'react'; | import { MouseEventHandler, PropsWithChildren, useCallback } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import { UseEditMcpReturnType } from './use-edit-mcp'; | |||||
| export function McpDropdown({ | export function McpDropdown({ | ||||
| children, | children, | ||||
| mcpId, | mcpId, | ||||
| }: PropsWithChildren & { mcpId: string }) { | |||||
| showEditModal, | |||||
| }: PropsWithChildren & { mcpId: string } & Pick< | |||||
| UseEditMcpReturnType, | |||||
| 'showEditModal' | |||||
| >) { | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const { deleteMcpServer } = useDeleteMcpServer(); | const { deleteMcpServer } = useDeleteMcpServer(); | ||||
| const handleShowAgentRenameModal: MouseEventHandler<HTMLDivElement> = | |||||
| useCallback((e) => { | |||||
| e.stopPropagation(); | |||||
| }, []); | |||||
| const handleDelete: MouseEventHandler<HTMLDivElement> = useCallback(() => { | const handleDelete: MouseEventHandler<HTMLDivElement> = useCallback(() => { | ||||
| deleteMcpServer([mcpId]); | deleteMcpServer([mcpId]); | ||||
| }, [deleteMcpServer, mcpId]); | }, [deleteMcpServer, mcpId]); | ||||
| <DropdownMenu> | <DropdownMenu> | ||||
| <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> | <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> | ||||
| <DropdownMenuContent> | <DropdownMenuContent> | ||||
| <DropdownMenuItem onClick={handleShowAgentRenameModal}> | |||||
| <DropdownMenuItem onClick={showEditModal(mcpId)}> | |||||
| {t('common.edit')} <PenLine /> | {t('common.edit')} <PenLine /> | ||||
| </DropdownMenuItem> | </DropdownMenuItem> | ||||
| <DropdownMenuSeparator /> | <DropdownMenuSeparator /> | 
| import { useSetModalState } from '@/hooks/common-hooks'; | import { useSetModalState } from '@/hooks/common-hooks'; | ||||
| import { | import { | ||||
| useCreateMcpServer, | useCreateMcpServer, | ||||
| useGetMcpServer, | |||||
| useUpdateMcpServer, | useUpdateMcpServer, | ||||
| } from '@/hooks/use-mcp-request'; | } from '@/hooks/use-mcp-request'; | ||||
| import { useCallback } from 'react'; | |||||
| import { useCallback, useState } from 'react'; | |||||
| export const useEditMcp = () => { | export const useEditMcp = () => { | ||||
| const { | const { | ||||
| showModal: showEditModal, | showModal: showEditModal, | ||||
| } = useSetModalState(); | } = useSetModalState(); | ||||
| const { createMcpServer, loading } = useCreateMcpServer(); | const { createMcpServer, loading } = useCreateMcpServer(); | ||||
| const { data, setId, id } = useGetMcpServer(); | |||||
| const [id, setId] = useState(''); | |||||
| const { updateMcpServer } = useUpdateMcpServer(); | const { updateMcpServer } = useUpdateMcpServer(); | ||||
| const handleShowModal = useCallback( | const handleShowModal = useCallback( | ||||
| (id?: string) => () => { | |||||
| if (id) { | |||||
| setId(id); | |||||
| } | |||||
| (id: string) => () => { | |||||
| setId(id); | |||||
| showEditModal(); | showEditModal(); | ||||
| }, | }, | ||||
| [setId, showEditModal], | [setId, showEditModal], | ||||
| showEditModal: handleShowModal, | showEditModal: handleShowModal, | ||||
| loading, | loading, | ||||
| createMcpServer, | createMcpServer, | ||||
| detail: data, | |||||
| handleOk, | handleOk, | ||||
| id, | |||||
| }; | }; | ||||
| }; | }; | ||||
| export type UseEditMcpReturnType = ReturnType<typeof useEditMcp>; | 
| }, | }, | ||||
| get: { | get: { | ||||
| url: getMcpServer, | url: getMcpServer, | ||||
| method: 'post', | |||||
| method: 'get', | |||||
| }, | }, | ||||
| create: { | create: { | ||||
| url: createMcpServer, | url: createMcpServer, |