Feat: List MCP servers #3221 (#8730)

### What problem does this PR solve?

Feat: List MCP servers #3221
### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu
2025-07-09 09:32:38 +08:00
committed by GitHub
parent 00c954755e
commit f7af0fc71e
13 changed files with 669 additions and 44 deletions

View File

@ -0,0 +1,211 @@
import message from '@/components/ui/message';
import { IMcpServerListResponse } from '@/interfaces/database/mcp';
import i18n from '@/locales/config';
import mcpServerService from '@/services/mcp-server-service';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
export const enum McpApiAction {
ListMcpServer = 'listMcpServer',
GetMcpServer = 'getMcpServer',
CreateMcpServer = 'createMcpServer',
UpdateMcpServer = 'updateMcpServer',
DeleteMcpServer = 'deleteMcpServer',
ImportMcpServer = 'importMcpServer',
ExportMcpServer = 'exportMcpServer',
ListMcpServerTools = 'listMcpServerTools',
TestMcpServerTool = 'testMcpServerTool',
CacheMcpServerTool = 'cacheMcpServerTool',
TestMcpServer = 'testMcpServer',
}
export const useListMcpServer = () => {
const { data, isFetching: loading } = useQuery<IMcpServerListResponse>({
queryKey: [McpApiAction.ListMcpServer],
initialData: { total: 0, mcp_servers: [] },
gcTime: 0,
queryFn: async () => {
const { data } = await mcpServerService.list({});
return data?.data;
},
});
return { data, loading };
};
export const useGetMcpServer = () => {
const [id, setId] = useState('');
const { data, isFetching: loading } = useQuery({
queryKey: [McpApiAction.GetMcpServer, id],
initialData: {},
gcTime: 0,
enabled: !!id,
queryFn: async () => {
const { data } = await mcpServerService.get();
return data?.data ?? {};
},
});
return { data, loading, setId, id };
};
export const useCreateMcpServer = () => {
const queryClient = useQueryClient();
const {
data,
isPending: loading,
mutateAsync,
} = useMutation({
mutationKey: [McpApiAction.CreateMcpServer],
mutationFn: async (params: Record<string, any>) => {
const { data = {} } = await mcpServerService.create(params);
if (data.code === 0) {
message.success(i18n.t(`message.created`));
queryClient.invalidateQueries({
queryKey: [McpApiAction.ListMcpServer],
});
}
return data;
},
});
return { data, loading, createMcpServer: mutateAsync };
};
export const useUpdateMcpServer = () => {
const queryClient = useQueryClient();
const {
data,
isPending: loading,
mutateAsync,
} = useMutation({
mutationKey: [McpApiAction.UpdateMcpServer],
mutationFn: async (params: Record<string, any>) => {
const { data = {} } = await mcpServerService.update(params);
if (data.code === 0) {
message.success(i18n.t(`message.updated`));
queryClient.invalidateQueries({
queryKey: [McpApiAction.ListMcpServer],
});
}
return data;
},
});
return { data, loading, updateMcpServer: mutateAsync };
};
export const useDeleteMcpServer = () => {
const queryClient = useQueryClient();
const {
data,
isPending: loading,
mutateAsync,
} = useMutation({
mutationKey: [McpApiAction.DeleteMcpServer],
mutationFn: async (params: Record<string, any>) => {
const { data = {} } = await mcpServerService.delete(params);
if (data.code === 0) {
message.success(i18n.t(`message.deleted`));
queryClient.invalidateQueries({
queryKey: [McpApiAction.ListMcpServer],
});
}
return data;
},
});
return { data, loading, deleteMcpServer: mutateAsync };
};
export const useImportMcpServer = () => {
const queryClient = useQueryClient();
const {
data,
isPending: loading,
mutateAsync,
} = useMutation({
mutationKey: [McpApiAction.ImportMcpServer],
mutationFn: async (params: Record<string, any>) => {
const { data = {} } = await mcpServerService.import(params);
if (data.code === 0) {
message.success(i18n.t(`message.created`));
queryClient.invalidateQueries({
queryKey: [McpApiAction.ListMcpServer],
});
}
return data;
},
});
return { data, loading, importMcpServer: mutateAsync };
};
export const useListMcpServerTools = () => {
const { data, isFetching: loading } = useQuery({
queryKey: [McpApiAction.ListMcpServerTools],
initialData: [],
gcTime: 0,
queryFn: async () => {
const { data } = await mcpServerService.listTools();
return data?.data ?? [];
},
});
return { data, loading };
};
export const useTestMcpServer = () => {
const {
data,
isPending: loading,
mutateAsync,
} = useMutation({
mutationKey: [McpApiAction.TestMcpServer],
mutationFn: async (params: Record<string, any>) => {
const { data = {} } = await mcpServerService.test(params);
return data;
},
});
return { data, loading, testMcpServer: mutateAsync };
};
export const useCacheMcpServerTool = () => {
const {
data,
isPending: loading,
mutateAsync,
} = useMutation({
mutationKey: [McpApiAction.CacheMcpServerTool],
mutationFn: async (params: Record<string, any>) => {
const { data = {} } = await mcpServerService.cacheTool(params);
return data;
},
});
return { data, loading, cacheMcpServerTool: mutateAsync };
};
export const useTestMcpServerTool = () => {
const {
data,
isPending: loading,
mutateAsync,
} = useMutation({
mutationKey: [McpApiAction.TestMcpServerTool],
mutationFn: async (params: Record<string, any>) => {
const { data = {} } = await mcpServerService.testTool(params);
return data;
},
});
return { data, loading, testMcpServerTool: mutateAsync };
};

View File

@ -0,0 +1,15 @@
export interface IMcpServer {
create_date: string;
description: null;
id: string;
name: string;
server_type: string;
update_date: string;
url: string;
variables: Record<string, any>;
}
export interface IMcpServerListResponse {
mcp_servers: IMcpServer[];
total: number;
}

View File

@ -0,0 +1,31 @@
import { ButtonLoading } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { IModalProps } from '@/interfaces/common';
import { useTranslation } from 'react-i18next';
import { EditMcpForm, FormId } from './edit-mcp-form';
export function EditMcpDialog({ hideModal, loading, onOk }: IModalProps<any>) {
const { t } = useTranslation();
return (
<Dialog open onOpenChange={hideModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
</DialogHeader>
<EditMcpForm onOk={onOk} hideModal={hideModal}></EditMcpForm>
<DialogFooter>
<ButtonLoading type="submit" form={FormId} loading={loading}>
{t('common.save')}
</ButtonLoading>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,138 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { RAGFlowSelect } from '@/components/ui/select';
import { IModalProps } from '@/interfaces/common';
import { buildOptions } from '@/utils/form';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
export const FormId = 'EditMcpForm';
enum ServerType {
SSE = 'sse',
StreamableHttp = 'streamable-http',
}
const ServerTypeOptions = buildOptions(ServerType);
export function EditMcpForm({
initialName,
hideModal,
onOk,
}: IModalProps<any> & { initialName?: string }) {
const { t } = useTranslation();
const FormSchema = z.object({
name: z
.string()
.min(1, {
message: t('common.namePlaceholder'),
})
.trim(),
url: z
.string()
.min(1, {
message: t('common.namePlaceholder'),
})
.trim(),
server_type: z
.string()
.min(1, {
message: t('common.namePlaceholder'),
})
.trim(),
});
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: { name: '', server_type: ServerType.SSE, url: '' },
});
async function onSubmit(data: z.infer<typeof FormSchema>) {
const ret = await onOk?.(data);
if (ret) {
hideModal?.();
}
}
useEffect(() => {
if (initialName) {
form.setValue('name', initialName);
}
}, [form, initialName]);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
id={FormId}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.name')}</FormLabel>
<FormControl>
<Input
placeholder={t('common.namePlaceholder')}
{...field}
autoComplete="off"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.url')}</FormLabel>
<FormControl>
<Input
placeholder={t('common.namePlaceholder')}
{...field}
autoComplete="off"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="server_type"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.serverType')}</FormLabel>
<FormControl>
<RAGFlowSelect
{...field}
autoComplete="off"
options={ServerTypeOptions}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}

View File

@ -0,0 +1,42 @@
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 { EditMcpDialog } from './edit-mcp-dialog';
import { McpCard } from './mcp-card';
import { useEditMcp } from './use-edit-mcp';
const list = new Array(10).fill('1');
export default function McpServer() {
const { data } = useListMcpServer();
const { editVisible, showEditModal, hideEditModal, handleOk } = useEditMcp();
return (
<section className="p-4">
<div className="text-text-title text-2xl">MCP Servers</div>
<section className="flex items-center justify-between">
<div className="text-text-sub-title"> MCP Server </div>
<div className="flex gap-5">
<SearchInput className="w-40"></SearchInput>
<Button variant={'secondary'}>
<Import /> Import
</Button>
<Button onClick={showEditModal('')}>
<Plus /> Add MCP
</Button>
</div>
</section>
<section className="flex gap-5 flex-wrap pt-5">
{data.mcp_servers.map((item) => (
<McpCard key={item.id} data={item}></McpCard>
))}
</section>
{editVisible && (
<EditMcpDialog
hideModal={hideEditModal}
onOk={handleOk}
></EditMcpDialog>
)}
</section>
);
}

View File

@ -0,0 +1,44 @@
import { MoreButton } from '@/components/more-button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent } from '@/components/ui/card';
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
import { IMcpServer } from '@/interfaces/database/mcp';
import { formatDate } from '@/utils/date';
import { McpDropdown } from './mcp-dropdown';
export type DatasetCardProps = {
data: IMcpServer;
};
export function McpCard({ data }: DatasetCardProps) {
const { navigateToAgent } = useNavigatePage();
return (
<Card key={data.id} className="w-64" onClick={navigateToAgent(data.id)}>
<CardContent className="p-2.5 pt-2 group">
<section className="flex justify-between mb-2">
<div className="flex gap-2 items-center">
<Avatar className="size-6 rounded-lg">
<AvatarImage src={data?.avatar} />
<AvatarFallback className="rounded-lg ">CN</AvatarFallback>
</Avatar>
</div>
<McpDropdown>
<MoreButton></MoreButton>
</McpDropdown>
</section>
<div className="flex justify-between items-end">
<div className="w-full">
<h3 className="text-lg font-semibold mb-2 line-clamp-1">
{data.name}
</h3>
<p className="text-xs text-text-sub-title">{data.description}</p>
<p className="text-xs text-text-sub-title">
{formatDate(data.update_date)}
</p>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,48 @@
import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { PenLine, Trash2 } from 'lucide-react';
import { MouseEventHandler, PropsWithChildren, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export function McpDropdown({ children }: PropsWithChildren) {
const { t } = useTranslation();
const handleShowAgentRenameModal: MouseEventHandler<HTMLDivElement> =
useCallback((e) => {
e.stopPropagation();
}, []);
const handleDelete: MouseEventHandler<HTMLDivElement> =
useCallback(() => {}, []);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={handleShowAgentRenameModal}>
{t('common.rename')} <PenLine />
</DropdownMenuItem>
<DropdownMenuSeparator />
<ConfirmDeleteDialog onOk={handleDelete}>
<DropdownMenuItem
className="text-text-delete-red"
onSelect={(e) => {
e.preventDefault();
}}
onClick={(e) => {
e.stopPropagation();
}}
>
{t('common.delete')} <Trash2 />
</DropdownMenuItem>
</ConfirmDeleteDialog>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,49 @@
import { useSetModalState } from '@/hooks/common-hooks';
import {
useCreateMcpServer,
useGetMcpServer,
useUpdateMcpServer,
} from '@/hooks/use-mcp-request';
import { useCallback } from 'react';
export const useEditMcp = () => {
const {
visible: editVisible,
hideModal: hideEditModal,
showModal: showEditModal,
} = useSetModalState();
const { createMcpServer, loading } = useCreateMcpServer();
const { data, setId, id } = useGetMcpServer();
const { updateMcpServer } = useUpdateMcpServer();
const handleShowModal = useCallback(
(id?: string) => () => {
if (id) {
setId(id);
}
showEditModal();
},
[setId, showEditModal],
);
const handleOk = useCallback(
async (values: any) => {
if (id) {
updateMcpServer(values);
} else {
createMcpServer(values);
}
},
[createMcpServer, id, updateMcpServer],
);
return {
editVisible,
hideEditModal,
showEditModal: handleShowModal,
loading,
createMcpServer,
detail: data,
handleOk,
};
};

View File

@ -1,8 +1,5 @@
import {
ProfileSettingBaseKey,
ProfileSettingRouteKey,
} from '@/constants/setting';
import { useLogout } from '@/hooks/login-hooks'; import { useLogout } from '@/hooks/login-hooks';
import { Routes } from '@/routes';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useNavigate } from 'umi'; import { useNavigate } from 'umi';
@ -11,11 +8,11 @@ export const useHandleMenuClick = () => {
const { logout } = useLogout(); const { logout } = useLogout();
const handleMenuClick = useCallback( const handleMenuClick = useCallback(
(key: ProfileSettingRouteKey) => () => { (key: Routes) => () => {
if (key === ProfileSettingRouteKey.Logout) { if (key === Routes.Logout) {
logout(); logout();
} else { } else {
navigate(`/${ProfileSettingBaseKey}/${key}`); navigate(`${Routes.ProfileSetting}${key}`);
} }
}, },
[logout, navigate], [logout, navigate],

View File

@ -2,10 +2,10 @@ import { useIsDarkTheme, useTheme } from '@/components/theme-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { ProfileSettingRouteKey } from '@/constants/setting';
import { useLogout } from '@/hooks/login-hooks'; import { useLogout } from '@/hooks/login-hooks';
import { useSecondPathName } from '@/hooks/route-hook'; import { useSecondPathName } from '@/hooks/route-hook';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Routes } from '@/routes';
import { import {
AlignEndVertical, AlignEndVertical,
Banknote, Banknote,
@ -22,9 +22,10 @@ const menuItems = [
{ {
section: 'Account & collaboration', section: 'Account & collaboration',
items: [ items: [
{ icon: User, label: 'Profile', key: ProfileSettingRouteKey.Profile }, { icon: User, label: 'Profile', key: Routes.Profile },
{ icon: LayoutGrid, label: 'Team', key: ProfileSettingRouteKey.Team }, { icon: LayoutGrid, label: 'Team', key: Routes.Team },
{ icon: Banknote, label: 'Plan', key: ProfileSettingRouteKey.Plan }, { icon: Banknote, label: 'Plan', key: Routes.Plan },
{ icon: Banknote, label: 'MCP', key: Routes.Mcp },
], ],
}, },
{ {
@ -33,17 +34,17 @@ const menuItems = [
{ {
icon: Box, icon: Box,
label: 'Model management', label: 'Model management',
key: ProfileSettingRouteKey.Model, key: Routes.Model,
}, },
{ {
icon: FileCog, icon: FileCog,
label: 'Prompt management', label: 'Prompt management',
key: ProfileSettingRouteKey.Prompt, key: Routes.Prompt,
}, },
{ {
icon: AlignEndVertical, icon: AlignEndVertical,
label: 'Chunking method', label: 'Chunking method',
key: ProfileSettingRouteKey.Chunk, key: Routes.Chunk,
}, },
], ],
}, },

View File

@ -1,5 +1,6 @@
export enum Routes { export enum Routes {
Login = '/login', Login = '/login',
Logout = '/logout',
Home = '/home', Home = '/home',
Datasets = '/datasets', Datasets = '/datasets',
DatasetBase = '/dataset', DatasetBase = '/dataset',
@ -13,6 +14,18 @@ export enum Routes {
Chat = '/next-chat', Chat = '/next-chat',
Files = '/files', Files = '/files',
ProfileSetting = '/profile-setting', ProfileSetting = '/profile-setting',
Profile = '/profile',
Mcp = '/mcp',
Team = '/team',
Plan = '/plan',
Model = '/model',
Prompt = '/prompt',
ProfileMcp = `${ProfileSetting}${Mcp}`,
ProfileTeam = `${ProfileSetting}${Team}`,
ProfilePlan = `${ProfileSetting}${Plan}`,
ProfileModel = `${ProfileSetting}${Model}`,
ProfilePrompt = `${ProfileSetting}${Prompt}`,
ProfileProfile = `${ProfileSetting}${Profile}`,
DatasetTesting = '/testing', DatasetTesting = '/testing',
DatasetSetting = '/setting', DatasetSetting = '/setting',
Chunk = '/chunk', Chunk = '/chunk',
@ -303,27 +316,31 @@ const routes = [
routes: [ routes: [
{ {
path: Routes.ProfileSetting, path: Routes.ProfileSetting,
redirect: `${Routes.ProfileSetting}/profile`, redirect: `${Routes.ProfileProfile}`,
}, },
{ {
path: `${Routes.ProfileSetting}/profile`, path: `${Routes.ProfileProfile}`,
component: `@/pages${Routes.ProfileSetting}/profile`, component: `@/pages${Routes.ProfileProfile}`,
}, },
{ {
path: `${Routes.ProfileSetting}/team`, path: `${Routes.ProfileTeam}`,
component: `@/pages${Routes.ProfileSetting}/team`, component: `@/pages${Routes.ProfileTeam}`,
}, },
{ {
path: `${Routes.ProfileSetting}/plan`, path: `${Routes.ProfilePlan}`,
component: `@/pages${Routes.ProfileSetting}/plan`, component: `@/pages${Routes.ProfilePlan}`,
}, },
{ {
path: `${Routes.ProfileSetting}/model`, path: `${Routes.ProfileModel}`,
component: `@/pages${Routes.ProfileSetting}/model`, component: `@/pages${Routes.ProfileModel}`,
}, },
{ {
path: `${Routes.ProfileSetting}/prompt`, path: `${Routes.ProfilePrompt}`,
component: `@/pages${Routes.ProfileSetting}/prompt`, component: `@/pages${Routes.ProfilePrompt}`,
},
{
path: Routes.ProfileMcp,
component: `@/pages${Routes.ProfileMcp}`,
}, },
], ],
}, },

View File

@ -3,39 +3,66 @@ import registerServer from '@/utils/register-server';
import request from '@/utils/request'; import request from '@/utils/request';
const { const {
getMcpServerList, listMcpServer,
getMultipleMcpServers,
createMcpServer, createMcpServer,
updateMcpServer, updateMcpServer,
deleteMcpServer, deleteMcpServer,
getMcpServer,
importMcpServer,
exportMcpServer,
listMcpServerTools,
testMcpServerTool,
cacheMcpServerTool,
testMcpServer,
} = api; } = api;
const methods = { const methods = {
get_list: { list: {
url: getMcpServerList, url: listMcpServer,
method: 'get',
},
get_multiple: {
url: getMultipleMcpServers,
method: 'post', method: 'post',
}, },
add: { get: {
url: getMcpServer,
method: 'post',
},
create: {
url: createMcpServer, url: createMcpServer,
method: 'post' method: 'post',
}, },
update: { update: {
url: updateMcpServer, url: updateMcpServer,
method: 'post' method: 'post',
}, },
rm: { delete: {
url: deleteMcpServer, url: deleteMcpServer,
method: 'post' method: 'post',
},
import: {
url: importMcpServer,
method: 'post',
},
export: {
url: exportMcpServer,
method: 'post',
},
listTools: {
url: listMcpServerTools,
method: 'get',
},
testTool: {
url: testMcpServerTool,
method: 'post',
},
cacheTool: {
url: cacheMcpServerTool,
method: 'post',
},
test: {
url: testMcpServer,
method: 'post',
}, },
} as const; } as const;
const mcpServerService = registerServer<keyof typeof methods>(methods, request); const mcpServerService = registerServer<keyof typeof methods>(methods, request);
export const getMcpServer = (serverId: string) =>
request.get(api.getMcpServer(serverId));
export default mcpServerService; export default mcpServerService;

View File

@ -148,10 +148,15 @@ export default {
trace: `${api_host}/canvas/trace`, trace: `${api_host}/canvas/trace`,
// mcp server // mcp server
getMcpServerList: `${api_host}/mcp_server/list`, listMcpServer: `${api_host}/mcp_server/list`,
getMultipleMcpServers: `${api_host}/mcp_server/get_multiple`, getMcpServer: `${api_host}/mcp_server/detail`,
getMcpServer: (serverId: string) => `${api_host}/mcp_server/get/${serverId}`,
createMcpServer: `${api_host}/mcp_server/create`, createMcpServer: `${api_host}/mcp_server/create`,
updateMcpServer: `${api_host}/mcp_server/update`, updateMcpServer: `${api_host}/mcp_server/update`,
deleteMcpServer: `${api_host}/mcp_server/rm`, deleteMcpServer: `${api_host}/mcp_server/rm`,
importMcpServer: `${api_host}/mcp_server/import`,
exportMcpServer: `${api_host}/mcp_server/export`,
listMcpServerTools: `${api_host}/mcp_server/list_tools`,
testMcpServerTool: `${api_host}/mcp_server/test_tool`,
cacheMcpServerTool: `${api_host}/mcp_server/cache_tools`,
testMcpServer: `${api_host}/mcp_server/test_mcp`,
}; };