### What problem does this PR solve? Feat: Import and export MCP Server #3221 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.20.0
| @@ -1,10 +1,15 @@ | |||
| import message from '@/components/ui/message'; | |||
| import { ResponseType } from '@/interfaces/database/base'; | |||
| import { | |||
| IExportedMcpServers, | |||
| IMcpServer, | |||
| IMcpServerListResponse, | |||
| IMCPTool, | |||
| } from '@/interfaces/database/mcp'; | |||
| import { ITestMcpRequestBody } from '@/interfaces/request/mcp'; | |||
| import { | |||
| IImportMcpServersRequestBody, | |||
| 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'; | |||
| @@ -132,10 +137,10 @@ export const useImportMcpServer = () => { | |||
| mutateAsync, | |||
| } = useMutation({ | |||
| mutationKey: [McpApiAction.ImportMcpServer], | |||
| mutationFn: async (params: Record<string, any>) => { | |||
| mutationFn: async (params: IImportMcpServersRequestBody) => { | |||
| const { data = {} } = await mcpServerService.import(params); | |||
| if (data.code === 0) { | |||
| message.success(i18n.t(`message.created`)); | |||
| message.success(i18n.t(`message.operated`)); | |||
| queryClient.invalidateQueries({ | |||
| queryKey: [McpApiAction.ListMcpServer], | |||
| @@ -148,6 +153,25 @@ export const useImportMcpServer = () => { | |||
| return { data, loading, importMcpServer: mutateAsync }; | |||
| }; | |||
| export const useExportMcpServer = () => { | |||
| const { | |||
| data, | |||
| isPending: loading, | |||
| mutateAsync, | |||
| } = useMutation<ResponseType<IExportedMcpServers>, Error, string[]>({ | |||
| mutationKey: [McpApiAction.ExportMcpServer], | |||
| mutationFn: async (ids) => { | |||
| const { data = {} } = await mcpServerService.export({ mcp_ids: ids }); | |||
| if (data.code === 0) { | |||
| message.success(i18n.t(`message.operated`)); | |||
| } | |||
| return data; | |||
| }, | |||
| }); | |||
| return { data, loading, exportMcpServer: mutateAsync }; | |||
| }; | |||
| export const useListMcpServerTools = () => { | |||
| const { data, isFetching: loading } = useQuery({ | |||
| queryKey: [McpApiAction.ListMcpServerTools], | |||
| @@ -39,3 +39,20 @@ interface ISymbol { | |||
| title: string; | |||
| type: string; | |||
| } | |||
| export interface IExportedMcpServers { | |||
| mcpServers: McpServers; | |||
| } | |||
| interface McpServers { | |||
| fetch_2: IExportedMcpServer; | |||
| github_1: IExportedMcpServer; | |||
| } | |||
| export interface IExportedMcpServer { | |||
| authorization_token: string; | |||
| name: string; | |||
| tool_configuration: Record<string, any>; | |||
| type: string; | |||
| url: string; | |||
| } | |||
| @@ -1,3 +1,5 @@ | |||
| import { IExportedMcpServer } from '@/interfaces/database/mcp'; | |||
| export interface ITestMcpRequestBody { | |||
| server_type: string; | |||
| url: string; | |||
| @@ -5,3 +7,10 @@ export interface ITestMcpRequestBody { | |||
| variables?: Record<string, any>; | |||
| timeout?: number; | |||
| } | |||
| export interface IImportMcpServersRequestBody { | |||
| mcpServers: Record< | |||
| string, | |||
| Pick<IExportedMcpServer, 'type' | 'url' | 'authorization_token'> | |||
| >; | |||
| } | |||
| @@ -1303,5 +1303,10 @@ This delimiter is used to split the input text into several text pieces echo of | |||
| }, | |||
| }, | |||
| }, | |||
| mcp: { | |||
| export: 'Export', | |||
| import: 'Import', | |||
| addMcp: 'Add MCP', | |||
| }, | |||
| }, | |||
| }; | |||
| @@ -62,7 +62,6 @@ export function EditMcpDialog({ | |||
| ? { 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(); | |||
| @@ -0,0 +1,74 @@ | |||
| 'use client'; | |||
| import { zodResolver } from '@hookform/resolvers/zod'; | |||
| import { useForm } from 'react-hook-form'; | |||
| import { z } from 'zod'; | |||
| import { FileUploader } from '@/components/file-uploader'; | |||
| import { | |||
| Form, | |||
| FormControl, | |||
| FormField, | |||
| FormItem, | |||
| FormLabel, | |||
| FormMessage, | |||
| } from '@/components/ui/form'; | |||
| import { FileMimeType, Platform } from '@/constants/common'; | |||
| import { IModalProps } from '@/interfaces/common'; | |||
| import { TagRenameId } from '@/pages/add-knowledge/constant'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| export function ImportMcpForm({ hideModal, onOk }: IModalProps<any>) { | |||
| const { t } = useTranslation(); | |||
| const FormSchema = z.object({ | |||
| platform: z | |||
| .string() | |||
| .min(1, { | |||
| message: t('common.namePlaceholder'), | |||
| }) | |||
| .trim(), | |||
| fileList: z.array(z.instanceof(File)), | |||
| }); | |||
| const form = useForm<z.infer<typeof FormSchema>>({ | |||
| resolver: zodResolver(FormSchema), | |||
| defaultValues: { platform: Platform.RAGFlow }, | |||
| }); | |||
| async function onSubmit(data: z.infer<typeof FormSchema>) { | |||
| const ret = await onOk?.(data); | |||
| if (ret) { | |||
| hideModal?.(); | |||
| } | |||
| } | |||
| return ( | |||
| <Form {...form}> | |||
| <form | |||
| onSubmit={form.handleSubmit(onSubmit)} | |||
| className="space-y-6" | |||
| id={TagRenameId} | |||
| > | |||
| <FormField | |||
| control={form.control} | |||
| name="fileList" | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel>{t('common.name')}</FormLabel> | |||
| <FormControl> | |||
| <FileUploader | |||
| value={field.value} | |||
| onValueChange={field.onChange} | |||
| maxFileCount={1} | |||
| maxSize={4 * 1024 * 1024} | |||
| accept={{ '*.json': [FileMimeType.Json] }} | |||
| /> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| </form> | |||
| </Form> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,36 @@ | |||
| import { | |||
| Dialog, | |||
| DialogContent, | |||
| DialogFooter, | |||
| DialogHeader, | |||
| DialogTitle, | |||
| } from '@/components/ui/dialog'; | |||
| import { LoadingButton } from '@/components/ui/loading-button'; | |||
| import { IModalProps } from '@/interfaces/common'; | |||
| import { TagRenameId } from '@/pages/add-knowledge/constant'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { ImportMcpForm } from './import-mcp-form'; | |||
| export function ImportMcpDialog({ | |||
| hideModal, | |||
| onOk, | |||
| loading, | |||
| }: IModalProps<any>) { | |||
| const { t } = useTranslation(); | |||
| return ( | |||
| <Dialog open onOpenChange={hideModal}> | |||
| <DialogContent> | |||
| <DialogHeader> | |||
| <DialogTitle>{t('mcp.import')}</DialogTitle> | |||
| </DialogHeader> | |||
| <ImportMcpForm hideModal={hideModal} onOk={onOk}></ImportMcpForm> | |||
| <DialogFooter> | |||
| <LoadingButton type="submit" form={TagRenameId} loading={loading}> | |||
| {t('common.save')} | |||
| </LoadingButton> | |||
| </DialogFooter> | |||
| </DialogContent> | |||
| </Dialog> | |||
| ); | |||
| } | |||
| @@ -3,29 +3,37 @@ import { Button } from '@/components/ui/button'; | |||
| import { SearchInput } from '@/components/ui/input'; | |||
| import { useListMcpServer } from '@/hooks/use-mcp-request'; | |||
| import { Import, Plus } from 'lucide-react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { EditMcpDialog } from './edit-mcp-dialog'; | |||
| import { ImportMcpDialog } from './import-mcp-dialog'; | |||
| import { McpCard } from './mcp-card'; | |||
| import { useBulkOperateMCP } from './use-bulk-operate-mcp'; | |||
| import { useEditMcp } from './use-edit-mcp'; | |||
| import { useImportMcp } from './use-import-mcp'; | |||
| export default function McpServer() { | |||
| const { data } = useListMcpServer(); | |||
| const { editVisible, showEditModal, hideEditModal, handleOk, id } = | |||
| useEditMcp(); | |||
| const { list, selectedList, handleSelectChange } = useBulkOperateMCP(); | |||
| const { t } = useTranslation(); | |||
| const { importVisible, showImportModal, hideImportModal, onImportOk } = | |||
| useImportMcp(); | |||
| return ( | |||
| <section className="p-4"> | |||
| <div className="text-text-title text-2xl">MCP Servers</div> | |||
| <section className="flex items-center justify-between pb-5"> | |||
| <div className="text-text-sub-title">自定义 MCP Server 的列表</div> | |||
| <div className="text-text-sub-title"> | |||
| Customize the list of MCP servers | |||
| </div> | |||
| <div className="flex gap-5"> | |||
| <SearchInput className="w-40"></SearchInput> | |||
| <Button variant={'secondary'}> | |||
| <Import /> Import | |||
| <Button variant={'secondary'} onClick={showImportModal}> | |||
| <Import /> {t('mcp.import')} | |||
| </Button> | |||
| <Button onClick={showEditModal('')}> | |||
| <Plus /> Add MCP | |||
| <Plus /> {t('mcp.addMcp')} | |||
| </Button> | |||
| </div> | |||
| </section> | |||
| @@ -54,6 +62,12 @@ export default function McpServer() { | |||
| id={id} | |||
| ></EditMcpDialog> | |||
| )} | |||
| {importVisible && ( | |||
| <ImportMcpDialog | |||
| hideModal={hideImportModal} | |||
| onOk={onImportOk} | |||
| ></ImportMcpDialog> | |||
| )} | |||
| </section> | |||
| ); | |||
| } | |||
| @@ -7,10 +7,11 @@ import { | |||
| DropdownMenuTrigger, | |||
| } from '@/components/ui/dropdown-menu'; | |||
| import { useDeleteMcpServer } from '@/hooks/use-mcp-request'; | |||
| import { PenLine, Trash2 } from 'lucide-react'; | |||
| import { PenLine, Trash2, Upload } from 'lucide-react'; | |||
| import { MouseEventHandler, PropsWithChildren, useCallback } from 'react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { UseEditMcpReturnType } from './use-edit-mcp'; | |||
| import { useExportMcp } from './use-export-mcp'; | |||
| export function McpDropdown({ | |||
| children, | |||
| @@ -22,6 +23,7 @@ export function McpDropdown({ | |||
| >) { | |||
| const { t } = useTranslation(); | |||
| const { deleteMcpServer } = useDeleteMcpServer(); | |||
| const { handleExportMcpJson } = useExportMcp(); | |||
| const handleDelete: MouseEventHandler<HTMLDivElement> = useCallback(() => { | |||
| deleteMcpServer([mcpId]); | |||
| @@ -35,6 +37,10 @@ export function McpDropdown({ | |||
| {t('common.edit')} <PenLine /> | |||
| </DropdownMenuItem> | |||
| <DropdownMenuSeparator /> | |||
| <DropdownMenuItem onClick={handleExportMcpJson([mcpId])}> | |||
| {t('mcp.export')} <Upload /> | |||
| </DropdownMenuItem> | |||
| <DropdownMenuSeparator /> | |||
| <ConfirmDeleteDialog onOk={handleDelete}> | |||
| <DropdownMenuItem | |||
| className="text-text-delete-red" | |||
| @@ -2,13 +2,13 @@ import { useDeleteMcpServer } from '@/hooks/use-mcp-request'; | |||
| import { Trash2, Upload } from 'lucide-react'; | |||
| import { useCallback, useState } from 'react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { useExportMcp } from './use-export-mcp'; | |||
| export function useBulkOperateMCP() { | |||
| const { t } = useTranslation(); | |||
| const [selectedList, setSelectedList] = useState<Array<string>>([]); | |||
| const { deleteMcpServer } = useDeleteMcpServer(); | |||
| const handleEnableClick = useCallback(() => {}, []); | |||
| const { handleExportMcpJson } = useExportMcp(); | |||
| const handleDelete = useCallback(() => { | |||
| deleteMcpServer(selectedList); | |||
| @@ -25,7 +25,7 @@ export function useBulkOperateMCP() { | |||
| id: 'export', | |||
| label: t('mcp.export'), | |||
| icon: <Upload />, | |||
| onClick: handleEnableClick, | |||
| onClick: handleExportMcpJson(selectedList), | |||
| }, | |||
| { | |||
| id: 'delete', | |||
| @@ -0,0 +1,21 @@ | |||
| import { useExportMcpServer } from '@/hooks/use-mcp-request'; | |||
| import { downloadJsonFile } from '@/utils/file-util'; | |||
| import { useCallback } from 'react'; | |||
| export function useExportMcp() { | |||
| const { exportMcpServer } = useExportMcpServer(); | |||
| const handleExportMcpJson = useCallback( | |||
| (ids: string[]) => async () => { | |||
| const data = await exportMcpServer(ids); | |||
| if (data.code === 0) { | |||
| downloadJsonFile(data.data, `mcp.json`); | |||
| } | |||
| }, | |||
| [exportMcpServer], | |||
| ); | |||
| return { | |||
| handleExportMcpJson, | |||
| }; | |||
| } | |||
| @@ -0,0 +1,73 @@ | |||
| import message from '@/components/ui/message'; | |||
| import { FileMimeType } from '@/constants/common'; | |||
| import { useSetModalState } from '@/hooks/common-hooks'; | |||
| import { useImportMcpServer } from '@/hooks/use-mcp-request'; | |||
| import { isEmpty } from 'lodash'; | |||
| import { useCallback } from 'react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { z } from 'zod'; | |||
| const ServerEntrySchema = z.object({ | |||
| authorization_token: z.string().optional(), | |||
| name: z.string().optional(), | |||
| tool_configuration: z.object({}).passthrough().optional(), | |||
| type: z.string(), | |||
| url: z.string().url(), | |||
| }); | |||
| const McpConfigSchema = z.object({ | |||
| mcpServers: z.record(ServerEntrySchema), | |||
| }); | |||
| export const useImportMcp = () => { | |||
| const { | |||
| visible: importVisible, | |||
| hideModal: hideImportModal, | |||
| showModal: showImportModal, | |||
| } = useSetModalState(); | |||
| const { t } = useTranslation(); | |||
| const { importMcpServer, loading } = useImportMcpServer(); | |||
| const onImportOk = useCallback( | |||
| async ({ fileList }: { fileList: File[] }) => { | |||
| if (fileList.length > 0) { | |||
| const file = fileList[0]; | |||
| if (file.type !== FileMimeType.Json) { | |||
| message.error(t('flow.jsonUploadTypeErrorMessage')); | |||
| return; | |||
| } | |||
| const mcpStr = await file.text(); | |||
| const errorMessage = t('flow.jsonUploadContentErrorMessage'); | |||
| try { | |||
| const mcp = JSON.parse(mcpStr); | |||
| try { | |||
| McpConfigSchema.parse(mcp); | |||
| } catch (error) { | |||
| message.error('Incorrect data format'); | |||
| return; | |||
| } | |||
| if (mcpStr && !isEmpty(mcp)) { | |||
| const ret = await importMcpServer(mcp); | |||
| if (ret.code === 0) { | |||
| hideImportModal(); | |||
| } | |||
| } else { | |||
| message.error(errorMessage); | |||
| } | |||
| } catch (error) { | |||
| message.error(errorMessage); | |||
| } | |||
| } | |||
| }, | |||
| [hideImportModal, importMcpServer, t], | |||
| ); | |||
| return { | |||
| importVisible, | |||
| showImportModal, | |||
| hideImportModal, | |||
| onImportOk, | |||
| loading, | |||
| }; | |||
| }; | |||
| @@ -7,7 +7,7 @@ export const isFormData = (data: unknown): data is FormData => { | |||
| return data instanceof FormData; | |||
| }; | |||
| const excludedFields = ['img2txt_id']; | |||
| const excludedFields = ['img2txt_id', 'mcpServers']; | |||
| const isExcludedField = (key: string) => { | |||
| return excludedFields.includes(key); | |||