浏览代码

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)
tags/v0.20.0
balibabu 3 个月前
父节点
当前提交
d05b405394
没有帐户链接到提交者的电子邮件

+ 27
- 3
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<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],

+ 17
- 0
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<string, any>;
type: string;
url: string;
}

+ 9
- 0
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<string, any>;
timeout?: number;
}

export interface IImportMcpServersRequestBody {
mcpServers: Record<
string,
Pick<IExportedMcpServer, 'type' | 'url' | 'authorization_token'>
>;
}

+ 5
- 0
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',
},
},
};

+ 0
- 1
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<HTMLButtonElement> = useCallback((e) => {
e.stopPropagation();

+ 74
- 0
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<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>
);
}

+ 36
- 0
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<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>
);
}

+ 18
- 4
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 (
<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
- 1
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<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"

+ 3
- 3
web/src/pages/profile-setting/mcp/use-bulk-operate-mcp.tsx 查看文件

@@ -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',

+ 21
- 0
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,
};
}

+ 73
- 0
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,
};
};

+ 1
- 1
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);

正在加载...
取消
保存