mirror of
https://github.com/infiniflow/ragflow.git
synced 2025-12-08 20:42:30 +08:00
### 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)
This commit is contained in:
@ -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',
|
||||
|
||||
21
web/src/pages/profile-setting/mcp/use-export-mcp.ts
Normal file
21
web/src/pages/profile-setting/mcp/use-export-mcp.ts
Normal file
@ -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
web/src/pages/profile-setting/mcp/use-import-mcp.ts
Normal file
73
web/src/pages/profile-setting/mcp/use-import-mcp.ts
Normal file
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user