From f7af0fc71e6cff7a2e31147f626ec9393b1c4445 Mon Sep 17 00:00:00 2001 From: balibabu Date: Wed, 9 Jul 2025 09:32:38 +0800 Subject: [PATCH] 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) --- web/src/hooks/use-mcp-request.ts | 211 ++++++++++++++++++ web/src/interfaces/database/mcp.ts | 15 ++ .../profile-setting/mcp/edit-mcp-dialog.tsx | 31 +++ .../profile-setting/mcp/edit-mcp-form.tsx | 138 ++++++++++++ web/src/pages/profile-setting/mcp/index.tsx | 42 ++++ .../pages/profile-setting/mcp/mcp-card.tsx | 44 ++++ .../profile-setting/mcp/mcp-dropdown.tsx | 48 ++++ .../pages/profile-setting/mcp/use-edit-mcp.ts | 49 ++++ .../pages/profile-setting/sidebar/hooks.tsx | 11 +- .../pages/profile-setting/sidebar/index.tsx | 15 +- web/src/routes.ts | 39 +++- web/src/services/mcp-server-service.ts | 59 +++-- web/src/utils/api.ts | 11 +- 13 files changed, 669 insertions(+), 44 deletions(-) create mode 100644 web/src/hooks/use-mcp-request.ts create mode 100644 web/src/interfaces/database/mcp.ts create mode 100644 web/src/pages/profile-setting/mcp/edit-mcp-dialog.tsx create mode 100644 web/src/pages/profile-setting/mcp/edit-mcp-form.tsx create mode 100644 web/src/pages/profile-setting/mcp/index.tsx create mode 100644 web/src/pages/profile-setting/mcp/mcp-card.tsx create mode 100644 web/src/pages/profile-setting/mcp/mcp-dropdown.tsx create mode 100644 web/src/pages/profile-setting/mcp/use-edit-mcp.ts diff --git a/web/src/hooks/use-mcp-request.ts b/web/src/hooks/use-mcp-request.ts new file mode 100644 index 000000000..aaf5fe4e1 --- /dev/null +++ b/web/src/hooks/use-mcp-request.ts @@ -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({ + 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + const { data = {} } = await mcpServerService.testTool(params); + + return data; + }, + }); + + return { data, loading, testMcpServerTool: mutateAsync }; +}; diff --git a/web/src/interfaces/database/mcp.ts b/web/src/interfaces/database/mcp.ts new file mode 100644 index 000000000..7bec45cc5 --- /dev/null +++ b/web/src/interfaces/database/mcp.ts @@ -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; +} + +export interface IMcpServerListResponse { + mcp_servers: IMcpServer[]; + total: number; +} diff --git a/web/src/pages/profile-setting/mcp/edit-mcp-dialog.tsx b/web/src/pages/profile-setting/mcp/edit-mcp-dialog.tsx new file mode 100644 index 000000000..f149d502f --- /dev/null +++ b/web/src/pages/profile-setting/mcp/edit-mcp-dialog.tsx @@ -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) { + const { t } = useTranslation(); + + return ( + + + + Edit profile + + + + + {t('common.save')} + + + + + ); +} diff --git a/web/src/pages/profile-setting/mcp/edit-mcp-form.tsx b/web/src/pages/profile-setting/mcp/edit-mcp-form.tsx new file mode 100644 index 000000000..a017dbf85 --- /dev/null +++ b/web/src/pages/profile-setting/mcp/edit-mcp-form.tsx @@ -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 & { 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>({ + resolver: zodResolver(FormSchema), + defaultValues: { name: '', server_type: ServerType.SSE, url: '' }, + }); + + async function onSubmit(data: z.infer) { + const ret = await onOk?.(data); + if (ret) { + hideModal?.(); + } + } + + useEffect(() => { + if (initialName) { + form.setValue('name', initialName); + } + }, [form, initialName]); + + return ( +
+ + ( + + {t('common.name')} + + + + + + )} + /> + ( + + {t('common.url')} + + + + + + )} + /> + ( + + {t('common.serverType')} + + + + + + )} + /> + + + ); +} diff --git a/web/src/pages/profile-setting/mcp/index.tsx b/web/src/pages/profile-setting/mcp/index.tsx new file mode 100644 index 000000000..c596cca4f --- /dev/null +++ b/web/src/pages/profile-setting/mcp/index.tsx @@ -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 ( +
+
MCP Servers
+
+
自定义 MCP Server 的列表
+
+ + + +
+
+
+ {data.mcp_servers.map((item) => ( + + ))} +
+ {editVisible && ( + + )} +
+ ); +} diff --git a/web/src/pages/profile-setting/mcp/mcp-card.tsx b/web/src/pages/profile-setting/mcp/mcp-card.tsx new file mode 100644 index 000000000..e255e3238 --- /dev/null +++ b/web/src/pages/profile-setting/mcp/mcp-card.tsx @@ -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 ( + + +
+
+ + + CN + +
+ + + +
+
+
+

+ {data.name} +

+

{data.description}

+

+ {formatDate(data.update_date)} +

+
+
+
+
+ ); +} diff --git a/web/src/pages/profile-setting/mcp/mcp-dropdown.tsx b/web/src/pages/profile-setting/mcp/mcp-dropdown.tsx new file mode 100644 index 000000000..d75aacaa3 --- /dev/null +++ b/web/src/pages/profile-setting/mcp/mcp-dropdown.tsx @@ -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 = + useCallback((e) => { + e.stopPropagation(); + }, []); + + const handleDelete: MouseEventHandler = + useCallback(() => {}, []); + + return ( + + {children} + + + {t('common.rename')} + + + + { + e.preventDefault(); + }} + onClick={(e) => { + e.stopPropagation(); + }} + > + {t('common.delete')} + + + + + ); +} diff --git a/web/src/pages/profile-setting/mcp/use-edit-mcp.ts b/web/src/pages/profile-setting/mcp/use-edit-mcp.ts new file mode 100644 index 000000000..0e21b0a4e --- /dev/null +++ b/web/src/pages/profile-setting/mcp/use-edit-mcp.ts @@ -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, + }; +}; diff --git a/web/src/pages/profile-setting/sidebar/hooks.tsx b/web/src/pages/profile-setting/sidebar/hooks.tsx index 9acc1d831..260df587c 100644 --- a/web/src/pages/profile-setting/sidebar/hooks.tsx +++ b/web/src/pages/profile-setting/sidebar/hooks.tsx @@ -1,8 +1,5 @@ -import { - ProfileSettingBaseKey, - ProfileSettingRouteKey, -} from '@/constants/setting'; import { useLogout } from '@/hooks/login-hooks'; +import { Routes } from '@/routes'; import { useCallback } from 'react'; import { useNavigate } from 'umi'; @@ -11,11 +8,11 @@ export const useHandleMenuClick = () => { const { logout } = useLogout(); const handleMenuClick = useCallback( - (key: ProfileSettingRouteKey) => () => { - if (key === ProfileSettingRouteKey.Logout) { + (key: Routes) => () => { + if (key === Routes.Logout) { logout(); } else { - navigate(`/${ProfileSettingBaseKey}/${key}`); + navigate(`${Routes.ProfileSetting}${key}`); } }, [logout, navigate], diff --git a/web/src/pages/profile-setting/sidebar/index.tsx b/web/src/pages/profile-setting/sidebar/index.tsx index d7fb3d423..b3de5fbbe 100644 --- a/web/src/pages/profile-setting/sidebar/index.tsx +++ b/web/src/pages/profile-setting/sidebar/index.tsx @@ -2,10 +2,10 @@ import { useIsDarkTheme, useTheme } from '@/components/theme-provider'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; -import { ProfileSettingRouteKey } from '@/constants/setting'; import { useLogout } from '@/hooks/login-hooks'; import { useSecondPathName } from '@/hooks/route-hook'; import { cn } from '@/lib/utils'; +import { Routes } from '@/routes'; import { AlignEndVertical, Banknote, @@ -22,9 +22,10 @@ const menuItems = [ { section: 'Account & collaboration', items: [ - { icon: User, label: 'Profile', key: ProfileSettingRouteKey.Profile }, - { icon: LayoutGrid, label: 'Team', key: ProfileSettingRouteKey.Team }, - { icon: Banknote, label: 'Plan', key: ProfileSettingRouteKey.Plan }, + { icon: User, label: 'Profile', key: Routes.Profile }, + { icon: LayoutGrid, label: 'Team', key: Routes.Team }, + { icon: Banknote, label: 'Plan', key: Routes.Plan }, + { icon: Banknote, label: 'MCP', key: Routes.Mcp }, ], }, { @@ -33,17 +34,17 @@ const menuItems = [ { icon: Box, label: 'Model management', - key: ProfileSettingRouteKey.Model, + key: Routes.Model, }, { icon: FileCog, label: 'Prompt management', - key: ProfileSettingRouteKey.Prompt, + key: Routes.Prompt, }, { icon: AlignEndVertical, label: 'Chunking method', - key: ProfileSettingRouteKey.Chunk, + key: Routes.Chunk, }, ], }, diff --git a/web/src/routes.ts b/web/src/routes.ts index 842160cf7..3f9649eb9 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -1,5 +1,6 @@ export enum Routes { Login = '/login', + Logout = '/logout', Home = '/home', Datasets = '/datasets', DatasetBase = '/dataset', @@ -13,6 +14,18 @@ export enum Routes { Chat = '/next-chat', Files = '/files', 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', DatasetSetting = '/setting', Chunk = '/chunk', @@ -303,27 +316,31 @@ const routes = [ routes: [ { path: Routes.ProfileSetting, - redirect: `${Routes.ProfileSetting}/profile`, + redirect: `${Routes.ProfileProfile}`, }, { - path: `${Routes.ProfileSetting}/profile`, - component: `@/pages${Routes.ProfileSetting}/profile`, + path: `${Routes.ProfileProfile}`, + component: `@/pages${Routes.ProfileProfile}`, }, { - path: `${Routes.ProfileSetting}/team`, - component: `@/pages${Routes.ProfileSetting}/team`, + path: `${Routes.ProfileTeam}`, + component: `@/pages${Routes.ProfileTeam}`, }, { - path: `${Routes.ProfileSetting}/plan`, - component: `@/pages${Routes.ProfileSetting}/plan`, + path: `${Routes.ProfilePlan}`, + component: `@/pages${Routes.ProfilePlan}`, }, { - path: `${Routes.ProfileSetting}/model`, - component: `@/pages${Routes.ProfileSetting}/model`, + path: `${Routes.ProfileModel}`, + component: `@/pages${Routes.ProfileModel}`, }, { - path: `${Routes.ProfileSetting}/prompt`, - component: `@/pages${Routes.ProfileSetting}/prompt`, + path: `${Routes.ProfilePrompt}`, + component: `@/pages${Routes.ProfilePrompt}`, + }, + { + path: Routes.ProfileMcp, + component: `@/pages${Routes.ProfileMcp}`, }, ], }, diff --git a/web/src/services/mcp-server-service.ts b/web/src/services/mcp-server-service.ts index a90fa0961..df4232813 100644 --- a/web/src/services/mcp-server-service.ts +++ b/web/src/services/mcp-server-service.ts @@ -3,39 +3,66 @@ import registerServer from '@/utils/register-server'; import request from '@/utils/request'; const { - getMcpServerList, - getMultipleMcpServers, + listMcpServer, createMcpServer, updateMcpServer, deleteMcpServer, + getMcpServer, + importMcpServer, + exportMcpServer, + listMcpServerTools, + testMcpServerTool, + cacheMcpServerTool, + testMcpServer, } = api; const methods = { - get_list: { - url: getMcpServerList, - method: 'get', - }, - get_multiple: { - url: getMultipleMcpServers, + list: { + url: listMcpServer, method: 'post', }, - add: { + get: { + url: getMcpServer, + method: 'post', + }, + create: { url: createMcpServer, - method: 'post' + method: 'post', }, update: { url: updateMcpServer, - method: 'post' + method: 'post', }, - rm: { + delete: { 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; const mcpServerService = registerServer(methods, request); -export const getMcpServer = (serverId: string) => - request.get(api.getMcpServer(serverId)); - export default mcpServerService; diff --git a/web/src/utils/api.ts b/web/src/utils/api.ts index e809ff1a3..42a098f89 100644 --- a/web/src/utils/api.ts +++ b/web/src/utils/api.ts @@ -148,10 +148,15 @@ export default { trace: `${api_host}/canvas/trace`, // mcp server - getMcpServerList: `${api_host}/mcp_server/list`, - getMultipleMcpServers: `${api_host}/mcp_server/get_multiple`, - getMcpServer: (serverId: string) => `${api_host}/mcp_server/get/${serverId}`, + listMcpServer: `${api_host}/mcp_server/list`, + getMcpServer: `${api_host}/mcp_server/detail`, createMcpServer: `${api_host}/mcp_server/create`, updateMcpServer: `${api_host}/mcp_server/update`, 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`, };