### 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
| @@ -3,17 +3,31 @@ import { | |||
| CollapsibleContent, | |||
| CollapsibleTrigger, | |||
| } from '@/components/ui/collapsible'; | |||
| import { CollapsibleProps } from '@radix-ui/react-collapsible'; | |||
| import { ListCollapse } from 'lucide-react'; | |||
| import { PropsWithChildren, ReactNode } from 'react'; | |||
| type CollapseProps = { | |||
| type CollapseProps = Omit<CollapsibleProps, 'title'> & { | |||
| title?: ReactNode; | |||
| rightContent?: ReactNode; | |||
| } & PropsWithChildren; | |||
| export function Collapse({ title, children, rightContent }: CollapseProps) { | |||
| export function Collapse({ | |||
| title, | |||
| children, | |||
| rightContent, | |||
| open, | |||
| defaultOpen = true, | |||
| onOpenChange, | |||
| disabled, | |||
| }: CollapseProps) { | |||
| return ( | |||
| <Collapsible defaultOpen> | |||
| <Collapsible | |||
| defaultOpen={defaultOpen} | |||
| open={open} | |||
| onOpenChange={onOpenChange} | |||
| disabled={disabled} | |||
| > | |||
| <CollapsibleTrigger className="w-full"> | |||
| <section className="flex justify-between items-center pb-2"> | |||
| <div className="flex items-center gap-1"> | |||
| @@ -1,10 +1,13 @@ | |||
| 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 i18n from '@/locales/config'; | |||
| import mcpServerService from '@/services/mcp-server-service'; | |||
| import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | |||
| import { useState } from 'react'; | |||
| export const enum McpApiAction { | |||
| ListMcpServer = 'listMcpServer', | |||
| @@ -34,20 +37,19 @@ export const useListMcpServer = () => { | |||
| 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], | |||
| initialData: {}, | |||
| initialData: {} as IMcpServer, | |||
| gcTime: 0, | |||
| enabled: !!id, | |||
| queryFn: async () => { | |||
| const { data } = await mcpServerService.get(); | |||
| const { data } = await mcpServerService.get({ mcp_id: id }); | |||
| return data?.data ?? {}; | |||
| }, | |||
| }); | |||
| return { data, loading, setId, id }; | |||
| return { data, loading, id }; | |||
| }; | |||
| export const useCreateMcpServer = () => { | |||
| @@ -7,15 +7,22 @@ import { | |||
| DialogHeader, | |||
| DialogTitle, | |||
| } 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 { 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 { MouseEventHandler, useCallback, useState } from 'react'; | |||
| import { MouseEventHandler, useCallback, useMemo, useState } from 'react'; | |||
| import { useForm } from 'react-hook-form'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| 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'; | |||
| function transferToolToObject(tools: IMCPTool[] = []) { | |||
| @@ -25,11 +32,37 @@ 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 { testMcpServer, data: tools } = useTestMcpServer(); | |||
| const { | |||
| testMcpServer, | |||
| data: tools, | |||
| loading: testLoading, | |||
| } = useTestMcpServer(); | |||
| const [isTriggeredBySaving, setIsTriggeredBySaving] = useState(false); | |||
| 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) => { | |||
| e.stopPropagation(); | |||
| @@ -54,15 +87,25 @@ export function EditMcpDialog({ hideModal, loading, onOk }: IModalProps<any>) { | |||
| } | |||
| }; | |||
| 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 ( | |||
| <Dialog open onOpenChange={hideModal}> | |||
| <DialogContent> | |||
| <DialogHeader> | |||
| <DialogTitle>Edit profile</DialogTitle> | |||
| </DialogHeader> | |||
| <EditMcpForm onOk={handleOk}></EditMcpForm> | |||
| <EditMcpForm onOk={handleOk} form={form}></EditMcpForm> | |||
| <Collapse | |||
| title={<div>{tools?.length || 0} tools available</div>} | |||
| open={collapseOpen} | |||
| onOpenChange={setCollapseOpen} | |||
| rightContent={ | |||
| <Button | |||
| variant={'ghost'} | |||
| @@ -74,8 +117,8 @@ export function EditMcpDialog({ hideModal, loading, onOk }: IModalProps<any>) { | |||
| </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> | |||
| ))} | |||
| </div> | |||
| @@ -86,7 +129,7 @@ export function EditMcpDialog({ hideModal, loading, onOk }: IModalProps<any>) { | |||
| form={FormId} | |||
| loading={loading} | |||
| onClick={handleSave} | |||
| disabled={!!!tools?.length} | |||
| disabled={disabled} | |||
| > | |||
| {t('common.save')} | |||
| </ButtonLoading> | |||
| @@ -1,7 +1,6 @@ | |||
| '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 { | |||
| @@ -16,12 +15,11 @@ import { Input } from '@/components/ui/input'; | |||
| import { RAGFlowSelect } from '@/components/ui/select'; | |||
| import { IModalProps } from '@/interfaces/common'; | |||
| import { buildOptions } from '@/utils/form'; | |||
| import { useEffect } from 'react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| export const FormId = 'EditMcpForm'; | |||
| enum ServerType { | |||
| export enum ServerType { | |||
| SSE = 'sse', | |||
| StreamableHttp = 'streamable-http', | |||
| } | |||
| @@ -57,28 +55,16 @@ export function useBuildFormSchema() { | |||
| } | |||
| export function EditMcpForm({ | |||
| initialName, | |||
| form, | |||
| onOk, | |||
| }: IModalProps<any> & { initialName?: string }) { | |||
| }: IModalProps<any> & { form: UseFormReturn<any> }) { | |||
| const { t } = useTranslation(); | |||
| 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>) { | |||
| onOk?.(data); | |||
| } | |||
| useEffect(() => { | |||
| if (initialName) { | |||
| form.setValue('name', initialName); | |||
| } | |||
| }, [form, initialName]); | |||
| return ( | |||
| <Form {...form}> | |||
| <form | |||
| @@ -10,7 +10,8 @@ import { useEditMcp } from './use-edit-mcp'; | |||
| export default function McpServer() { | |||
| const { data } = useListMcpServer(); | |||
| const { editVisible, showEditModal, hideEditModal, handleOk } = useEditMcp(); | |||
| const { editVisible, showEditModal, hideEditModal, handleOk, id } = | |||
| useEditMcp(); | |||
| const { list, selectedList, handleSelectChange } = useBulkOperateMCP(); | |||
| return ( | |||
| @@ -42,6 +43,7 @@ export default function McpServer() { | |||
| data={item} | |||
| selectedList={selectedList} | |||
| handleSelectChange={handleSelectChange} | |||
| showEditModal={showEditModal} | |||
| ></McpCard> | |||
| ))} | |||
| </section> | |||
| @@ -49,6 +51,7 @@ export default function McpServer() { | |||
| <EditMcpDialog | |||
| hideModal={hideEditModal} | |||
| onOk={handleOk} | |||
| id={id} | |||
| ></EditMcpDialog> | |||
| )} | |||
| </section> | |||
| @@ -7,15 +7,18 @@ import { isPlainObject } from 'lodash'; | |||
| import { useMemo } from 'react'; | |||
| import { McpDropdown } from './mcp-dropdown'; | |||
| import { UseBulkOperateMCPReturnType } from './use-bulk-operate-mcp'; | |||
| import { UseEditMcpReturnType } from './use-edit-mcp'; | |||
| export type DatasetCardProps = { | |||
| data: IMcpServer; | |||
| } & Pick<UseBulkOperateMCPReturnType, 'handleSelectChange' | 'selectedList'>; | |||
| } & Pick<UseBulkOperateMCPReturnType, 'handleSelectChange' | 'selectedList'> & | |||
| Pick<UseEditMcpReturnType, 'showEditModal'>; | |||
| export function McpCard({ | |||
| data, | |||
| selectedList, | |||
| handleSelectChange, | |||
| showEditModal, | |||
| }: DatasetCardProps) { | |||
| const toolLength = useMemo(() => { | |||
| const tools = data.variables?.tools; | |||
| @@ -35,7 +38,7 @@ export function McpCard({ | |||
| <section className="flex justify-between pb-2"> | |||
| <h3 className="text-lg font-semibold line-clamp-1">{data.name}</h3> | |||
| <div className="space-x-4"> | |||
| <McpDropdown mcpId={data.id}> | |||
| <McpDropdown mcpId={data.id} showEditModal={showEditModal}> | |||
| <MoreButton></MoreButton> | |||
| </McpDropdown> | |||
| <Checkbox | |||
| @@ -10,19 +10,19 @@ import { useDeleteMcpServer } from '@/hooks/use-mcp-request'; | |||
| import { PenLine, Trash2 } from 'lucide-react'; | |||
| import { MouseEventHandler, PropsWithChildren, useCallback } from 'react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { UseEditMcpReturnType } from './use-edit-mcp'; | |||
| export function McpDropdown({ | |||
| children, | |||
| mcpId, | |||
| }: PropsWithChildren & { mcpId: string }) { | |||
| showEditModal, | |||
| }: PropsWithChildren & { mcpId: string } & Pick< | |||
| UseEditMcpReturnType, | |||
| 'showEditModal' | |||
| >) { | |||
| const { t } = useTranslation(); | |||
| const { deleteMcpServer } = useDeleteMcpServer(); | |||
| const handleShowAgentRenameModal: MouseEventHandler<HTMLDivElement> = | |||
| useCallback((e) => { | |||
| e.stopPropagation(); | |||
| }, []); | |||
| const handleDelete: MouseEventHandler<HTMLDivElement> = useCallback(() => { | |||
| deleteMcpServer([mcpId]); | |||
| }, [deleteMcpServer, mcpId]); | |||
| @@ -31,7 +31,7 @@ export function McpDropdown({ | |||
| <DropdownMenu> | |||
| <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> | |||
| <DropdownMenuContent> | |||
| <DropdownMenuItem onClick={handleShowAgentRenameModal}> | |||
| <DropdownMenuItem onClick={showEditModal(mcpId)}> | |||
| {t('common.edit')} <PenLine /> | |||
| </DropdownMenuItem> | |||
| <DropdownMenuSeparator /> | |||
| @@ -1,10 +1,9 @@ | |||
| import { useSetModalState } from '@/hooks/common-hooks'; | |||
| import { | |||
| useCreateMcpServer, | |||
| useGetMcpServer, | |||
| useUpdateMcpServer, | |||
| } from '@/hooks/use-mcp-request'; | |||
| import { useCallback } from 'react'; | |||
| import { useCallback, useState } from 'react'; | |||
| export const useEditMcp = () => { | |||
| const { | |||
| @@ -13,14 +12,13 @@ export const useEditMcp = () => { | |||
| showModal: showEditModal, | |||
| } = useSetModalState(); | |||
| const { createMcpServer, loading } = useCreateMcpServer(); | |||
| const { data, setId, id } = useGetMcpServer(); | |||
| const [id, setId] = useState(''); | |||
| const { updateMcpServer } = useUpdateMcpServer(); | |||
| const handleShowModal = useCallback( | |||
| (id?: string) => () => { | |||
| if (id) { | |||
| setId(id); | |||
| } | |||
| (id: string) => () => { | |||
| setId(id); | |||
| showEditModal(); | |||
| }, | |||
| [setId, showEditModal], | |||
| @@ -47,7 +45,9 @@ export const useEditMcp = () => { | |||
| showEditModal: handleShowModal, | |||
| loading, | |||
| createMcpServer, | |||
| detail: data, | |||
| handleOk, | |||
| id, | |||
| }; | |||
| }; | |||
| export type UseEditMcpReturnType = ReturnType<typeof useEditMcp>; | |||
| @@ -23,7 +23,7 @@ const methods = { | |||
| }, | |||
| get: { | |||
| url: getMcpServer, | |||
| method: 'post', | |||
| method: 'get', | |||
| }, | |||
| create: { | |||
| url: createMcpServer, | |||