From d05b405394b7679e82040c2f4d0decc9710d5084 Mon Sep 17 00:00:00 2001 From: balibabu Date: Fri, 11 Jul 2025 18:18:31 +0800 Subject: [PATCH] Feat: Import and export MCP Server #3221 (#8806) ### 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) --- web/src/hooks/use-mcp-request.ts | 30 +++++++- web/src/interfaces/database/mcp.ts | 17 +++++ web/src/interfaces/request/mcp.ts | 9 +++ web/src/locales/en.ts | 5 ++ .../profile-setting/mcp/edit-mcp-dialog.tsx | 1 - .../mcp/import-mcp-dialog/import-mcp-form.tsx | 74 +++++++++++++++++++ .../mcp/import-mcp-dialog/index.tsx | 36 +++++++++ web/src/pages/profile-setting/mcp/index.tsx | 22 +++++- .../profile-setting/mcp/mcp-dropdown.tsx | 8 +- .../mcp/use-bulk-operate-mcp.tsx | 6 +- .../profile-setting/mcp/use-export-mcp.ts | 21 ++++++ .../profile-setting/mcp/use-import-mcp.ts | 73 ++++++++++++++++++ web/src/utils/common-util.ts | 2 +- 13 files changed, 291 insertions(+), 13 deletions(-) create mode 100644 web/src/pages/profile-setting/mcp/import-mcp-dialog/import-mcp-form.tsx create mode 100644 web/src/pages/profile-setting/mcp/import-mcp-dialog/index.tsx create mode 100644 web/src/pages/profile-setting/mcp/use-export-mcp.ts create mode 100644 web/src/pages/profile-setting/mcp/use-import-mcp.ts diff --git a/web/src/hooks/use-mcp-request.ts b/web/src/hooks/use-mcp-request.ts index bf4dd3a90..9aa8c3a21 100644 --- a/web/src/hooks/use-mcp-request.ts +++ b/web/src/hooks/use-mcp-request.ts @@ -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) => { + 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, 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], diff --git a/web/src/interfaces/database/mcp.ts b/web/src/interfaces/database/mcp.ts index d90699801..6edbf6a06 100644 --- a/web/src/interfaces/database/mcp.ts +++ b/web/src/interfaces/database/mcp.ts @@ -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; + type: string; + url: string; +} diff --git a/web/src/interfaces/request/mcp.ts b/web/src/interfaces/request/mcp.ts index a63d3b0f4..96891ad72 100644 --- a/web/src/interfaces/request/mcp.ts +++ b/web/src/interfaces/request/mcp.ts @@ -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; timeout?: number; } + +export interface IImportMcpServersRequestBody { + mcpServers: Record< + string, + Pick + >; +} diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index dec8c57f0..33538a509 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -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', + }, }, }; diff --git a/web/src/pages/profile-setting/mcp/edit-mcp-dialog.tsx b/web/src/pages/profile-setting/mcp/edit-mcp-dialog.tsx index 6f81e310a..d40578ccd 100644 --- a/web/src/pages/profile-setting/mcp/edit-mcp-dialog.tsx +++ b/web/src/pages/profile-setting/mcp/edit-mcp-dialog.tsx @@ -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 = useCallback((e) => { e.stopPropagation(); diff --git a/web/src/pages/profile-setting/mcp/import-mcp-dialog/import-mcp-form.tsx b/web/src/pages/profile-setting/mcp/import-mcp-dialog/import-mcp-form.tsx new file mode 100644 index 000000000..42f6f4ed4 --- /dev/null +++ b/web/src/pages/profile-setting/mcp/import-mcp-dialog/import-mcp-form.tsx @@ -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) { + 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>({ + resolver: zodResolver(FormSchema), + defaultValues: { platform: Platform.RAGFlow }, + }); + + async function onSubmit(data: z.infer) { + const ret = await onOk?.(data); + if (ret) { + hideModal?.(); + } + } + + return ( +
+ + ( + + {t('common.name')} + + + + + + )} + /> + + + ); +} diff --git a/web/src/pages/profile-setting/mcp/import-mcp-dialog/index.tsx b/web/src/pages/profile-setting/mcp/import-mcp-dialog/index.tsx new file mode 100644 index 000000000..76b35387b --- /dev/null +++ b/web/src/pages/profile-setting/mcp/import-mcp-dialog/index.tsx @@ -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) { + const { t } = useTranslation(); + + return ( + + + + {t('mcp.import')} + + + + + {t('common.save')} + + + + + ); +} diff --git a/web/src/pages/profile-setting/mcp/index.tsx b/web/src/pages/profile-setting/mcp/index.tsx index b213083b6..665f8ad07 100644 --- a/web/src/pages/profile-setting/mcp/index.tsx +++ b/web/src/pages/profile-setting/mcp/index.tsx @@ -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 (
MCP Servers
-
่‡ชๅฎšไน‰ MCP Server ็š„ๅˆ—่กจ
+
+ Customize the list of MCP servers +
-
@@ -54,6 +62,12 @@ export default function McpServer() { id={id} > )} + {importVisible && ( + + )}
); } diff --git a/web/src/pages/profile-setting/mcp/mcp-dropdown.tsx b/web/src/pages/profile-setting/mcp/mcp-dropdown.tsx index d84364dab..33e14970d 100644 --- a/web/src/pages/profile-setting/mcp/mcp-dropdown.tsx +++ b/web/src/pages/profile-setting/mcp/mcp-dropdown.tsx @@ -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 = useCallback(() => { deleteMcpServer([mcpId]); @@ -35,6 +37,10 @@ export function McpDropdown({ {t('common.edit')} + + {t('mcp.export')} + + >([]); 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: , - onClick: handleEnableClick, + onClick: handleExportMcpJson(selectedList), }, { id: 'delete', diff --git a/web/src/pages/profile-setting/mcp/use-export-mcp.ts b/web/src/pages/profile-setting/mcp/use-export-mcp.ts new file mode 100644 index 000000000..636290c91 --- /dev/null +++ b/web/src/pages/profile-setting/mcp/use-export-mcp.ts @@ -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, + }; +} diff --git a/web/src/pages/profile-setting/mcp/use-import-mcp.ts b/web/src/pages/profile-setting/mcp/use-import-mcp.ts new file mode 100644 index 000000000..33e844191 --- /dev/null +++ b/web/src/pages/profile-setting/mcp/use-import-mcp.ts @@ -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, + }; +}; diff --git a/web/src/utils/common-util.ts b/web/src/utils/common-util.ts index c602d9f3d..ededa4121 100644 --- a/web/src/utils/common-util.ts +++ b/web/src/utils/common-util.ts @@ -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);