### 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
| import message from '@/components/ui/message'; | import message from '@/components/ui/message'; | ||||
| import { ResponseType } from '@/interfaces/database/base'; | |||||
| import { | import { | ||||
| IExportedMcpServers, | |||||
| IMcpServer, | IMcpServer, | ||||
| IMcpServerListResponse, | IMcpServerListResponse, | ||||
| IMCPTool, | IMCPTool, | ||||
| } from '@/interfaces/database/mcp'; | } from '@/interfaces/database/mcp'; | ||||
| import { ITestMcpRequestBody } from '@/interfaces/request/mcp'; | |||||
| import { | |||||
| IImportMcpServersRequestBody, | |||||
| 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'; | ||||
| mutateAsync, | mutateAsync, | ||||
| } = useMutation({ | } = useMutation({ | ||||
| mutationKey: [McpApiAction.ImportMcpServer], | mutationKey: [McpApiAction.ImportMcpServer], | ||||
| mutationFn: async (params: Record<string, any>) => { | |||||
| mutationFn: async (params: IImportMcpServersRequestBody) => { | |||||
| const { data = {} } = await mcpServerService.import(params); | const { data = {} } = await mcpServerService.import(params); | ||||
| if (data.code === 0) { | if (data.code === 0) { | ||||
| message.success(i18n.t(`message.created`)); | |||||
| message.success(i18n.t(`message.operated`)); | |||||
| queryClient.invalidateQueries({ | queryClient.invalidateQueries({ | ||||
| queryKey: [McpApiAction.ListMcpServer], | queryKey: [McpApiAction.ListMcpServer], | ||||
| return { data, loading, importMcpServer: mutateAsync }; | 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 = () => { | export const useListMcpServerTools = () => { | ||||
| const { data, isFetching: loading } = useQuery({ | const { data, isFetching: loading } = useQuery({ | ||||
| queryKey: [McpApiAction.ListMcpServerTools], | queryKey: [McpApiAction.ListMcpServerTools], |
| title: string; | title: string; | ||||
| type: 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; | |||||
| } |
| import { IExportedMcpServer } from '@/interfaces/database/mcp'; | |||||
| export interface ITestMcpRequestBody { | export interface ITestMcpRequestBody { | ||||
| server_type: string; | server_type: string; | ||||
| url: string; | url: string; | ||||
| variables?: Record<string, any>; | variables?: Record<string, any>; | ||||
| timeout?: number; | timeout?: number; | ||||
| } | } | ||||
| export interface IImportMcpServersRequestBody { | |||||
| mcpServers: Record< | |||||
| string, | |||||
| Pick<IExportedMcpServer, 'type' | 'url' | 'authorization_token'> | |||||
| >; | |||||
| } |
| }, | }, | ||||
| }, | }, | ||||
| }, | }, | ||||
| mcp: { | |||||
| export: 'Export', | |||||
| import: 'Import', | |||||
| addMcp: 'Add MCP', | |||||
| }, | |||||
| }, | }, | ||||
| }; | }; |
| ? { name: '', server_type: ServerType.SSE, url: '' } | ? { name: '', server_type: ServerType.SSE, url: '' } | ||||
| : pick(data, ['name', 'server_type', '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(); |
| '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> | |||||
| ); | |||||
| } |
| 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> | |||||
| ); | |||||
| } |
| import { SearchInput } from '@/components/ui/input'; | import { SearchInput } from '@/components/ui/input'; | ||||
| import { useListMcpServer } from '@/hooks/use-mcp-request'; | import { useListMcpServer } from '@/hooks/use-mcp-request'; | ||||
| import { Import, Plus } from 'lucide-react'; | import { Import, Plus } from 'lucide-react'; | ||||
| import { useTranslation } from 'react-i18next'; | |||||
| import { EditMcpDialog } from './edit-mcp-dialog'; | import { EditMcpDialog } from './edit-mcp-dialog'; | ||||
| import { ImportMcpDialog } from './import-mcp-dialog'; | |||||
| import { McpCard } from './mcp-card'; | import { McpCard } from './mcp-card'; | ||||
| import { useBulkOperateMCP } from './use-bulk-operate-mcp'; | import { useBulkOperateMCP } from './use-bulk-operate-mcp'; | ||||
| import { useEditMcp } from './use-edit-mcp'; | import { useEditMcp } from './use-edit-mcp'; | ||||
| import { useImportMcp } from './use-import-mcp'; | |||||
| export default function McpServer() { | export default function McpServer() { | ||||
| const { data } = useListMcpServer(); | const { data } = useListMcpServer(); | ||||
| const { editVisible, showEditModal, hideEditModal, handleOk, id } = | const { editVisible, showEditModal, hideEditModal, handleOk, id } = | ||||
| useEditMcp(); | useEditMcp(); | ||||
| const { list, selectedList, handleSelectChange } = useBulkOperateMCP(); | const { list, selectedList, handleSelectChange } = useBulkOperateMCP(); | ||||
| const { t } = useTranslation(); | |||||
| const { importVisible, showImportModal, hideImportModal, onImportOk } = | |||||
| useImportMcp(); | |||||
| return ( | return ( | ||||
| <section className="p-4"> | <section className="p-4"> | ||||
| <div className="text-text-title text-2xl">MCP Servers</div> | <div className="text-text-title text-2xl">MCP Servers</div> | ||||
| <section className="flex items-center justify-between pb-5"> | <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"> | <div className="flex gap-5"> | ||||
| <SearchInput className="w-40"></SearchInput> | <SearchInput className="w-40"></SearchInput> | ||||
| <Button variant={'secondary'}> | |||||
| <Import /> Import | |||||
| <Button variant={'secondary'} onClick={showImportModal}> | |||||
| <Import /> {t('mcp.import')} | |||||
| </Button> | </Button> | ||||
| <Button onClick={showEditModal('')}> | <Button onClick={showEditModal('')}> | ||||
| <Plus /> Add MCP | |||||
| <Plus /> {t('mcp.addMcp')} | |||||
| </Button> | </Button> | ||||
| </div> | </div> | ||||
| </section> | </section> | ||||
| id={id} | id={id} | ||||
| ></EditMcpDialog> | ></EditMcpDialog> | ||||
| )} | )} | ||||
| {importVisible && ( | |||||
| <ImportMcpDialog | |||||
| hideModal={hideImportModal} | |||||
| onOk={onImportOk} | |||||
| ></ImportMcpDialog> | |||||
| )} | |||||
| </section> | </section> | ||||
| ); | ); | ||||
| } | } |
| DropdownMenuTrigger, | DropdownMenuTrigger, | ||||
| } from '@/components/ui/dropdown-menu'; | } from '@/components/ui/dropdown-menu'; | ||||
| import { useDeleteMcpServer } from '@/hooks/use-mcp-request'; | 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 { MouseEventHandler, PropsWithChildren, useCallback } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import { UseEditMcpReturnType } from './use-edit-mcp'; | import { UseEditMcpReturnType } from './use-edit-mcp'; | ||||
| import { useExportMcp } from './use-export-mcp'; | |||||
| export function McpDropdown({ | export function McpDropdown({ | ||||
| children, | children, | ||||
| >) { | >) { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const { deleteMcpServer } = useDeleteMcpServer(); | const { deleteMcpServer } = useDeleteMcpServer(); | ||||
| const { handleExportMcpJson } = useExportMcp(); | |||||
| const handleDelete: MouseEventHandler<HTMLDivElement> = useCallback(() => { | const handleDelete: MouseEventHandler<HTMLDivElement> = useCallback(() => { | ||||
| deleteMcpServer([mcpId]); | deleteMcpServer([mcpId]); | ||||
| {t('common.edit')} <PenLine /> | {t('common.edit')} <PenLine /> | ||||
| </DropdownMenuItem> | </DropdownMenuItem> | ||||
| <DropdownMenuSeparator /> | <DropdownMenuSeparator /> | ||||
| <DropdownMenuItem onClick={handleExportMcpJson([mcpId])}> | |||||
| {t('mcp.export')} <Upload /> | |||||
| </DropdownMenuItem> | |||||
| <DropdownMenuSeparator /> | |||||
| <ConfirmDeleteDialog onOk={handleDelete}> | <ConfirmDeleteDialog onOk={handleDelete}> | ||||
| <DropdownMenuItem | <DropdownMenuItem | ||||
| className="text-text-delete-red" | className="text-text-delete-red" |
| import { Trash2, Upload } from 'lucide-react'; | import { Trash2, Upload } from 'lucide-react'; | ||||
| import { useCallback, useState } from 'react'; | import { useCallback, useState } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import { useExportMcp } from './use-export-mcp'; | |||||
| export function useBulkOperateMCP() { | export function useBulkOperateMCP() { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const [selectedList, setSelectedList] = useState<Array<string>>([]); | const [selectedList, setSelectedList] = useState<Array<string>>([]); | ||||
| const { deleteMcpServer } = useDeleteMcpServer(); | const { deleteMcpServer } = useDeleteMcpServer(); | ||||
| const handleEnableClick = useCallback(() => {}, []); | |||||
| const { handleExportMcpJson } = useExportMcp(); | |||||
| const handleDelete = useCallback(() => { | const handleDelete = useCallback(() => { | ||||
| deleteMcpServer(selectedList); | deleteMcpServer(selectedList); | ||||
| id: 'export', | id: 'export', | ||||
| label: t('mcp.export'), | label: t('mcp.export'), | ||||
| icon: <Upload />, | icon: <Upload />, | ||||
| onClick: handleEnableClick, | |||||
| onClick: handleExportMcpJson(selectedList), | |||||
| }, | }, | ||||
| { | { | ||||
| id: 'delete', | id: 'delete', |
| 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, | |||||
| }; | |||||
| } |
| 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, | |||||
| }; | |||||
| }; |
| return data instanceof FormData; | return data instanceof FormData; | ||||
| }; | }; | ||||
| const excludedFields = ['img2txt_id']; | |||||
| const excludedFields = ['img2txt_id', 'mcpServers']; | |||||
| const isExcludedField = (key: string) => { | const isExcludedField = (key: string) => { | ||||
| return excludedFields.includes(key); | return excludedFields.includes(key); |