### What problem does this PR solve? Feat: Test MCP server #3221 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.20.0
| import message from '@/components/ui/message'; | import message from '@/components/ui/message'; | ||||
| import { IMcpServerListResponse } from '@/interfaces/database/mcp'; | |||||
| import { IMcpServerListResponse, IMCPTool } from '@/interfaces/database/mcp'; | |||||
| import { ITestMcpRequestBody } from '@/interfaces/request/mcp'; | |||||
| import i18n from '@/locales/config'; | import i18n from '@/locales/config'; | ||||
| import mcpServerService from '@/services/mcp-server-service'; | import mcpServerService from '@/services/mcp-server-service'; | ||||
| import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| data, | data, | ||||
| isPending: loading, | isPending: loading, | ||||
| mutateAsync, | mutateAsync, | ||||
| } = useMutation({ | |||||
| } = useMutation<IMCPTool[], Error, ITestMcpRequestBody>({ | |||||
| mutationKey: [McpApiAction.TestMcpServer], | mutationKey: [McpApiAction.TestMcpServer], | ||||
| mutationFn: async (params: Record<string, any>) => { | |||||
| const { data = {} } = await mcpServerService.test(params); | |||||
| mutationFn: async (params) => { | |||||
| const { data } = await mcpServerService.test(params); | |||||
| return data; | |||||
| return data?.data || []; | |||||
| }, | }, | ||||
| }); | }); | ||||
| server_type: string; | server_type: string; | ||||
| update_date: string; | update_date: string; | ||||
| url: string; | url: string; | ||||
| variables: Record<string, any>; | |||||
| variables: Record<string, any> & { tools?: IMCPToolObject }; | |||||
| } | } | ||||
| export type IMCPToolObject = Record<string, Omit<IMCPTool, 'name'>>; | |||||
| export interface IMcpServerListResponse { | export interface IMcpServerListResponse { | ||||
| mcp_servers: IMcpServer[]; | mcp_servers: IMcpServer[]; | ||||
| total: number; | total: number; | ||||
| } | } | ||||
| export interface IMCPTool { | |||||
| annotations: null; | |||||
| description: string; | |||||
| enabled: boolean; | |||||
| inputSchema: InputSchema; | |||||
| name: string; | |||||
| } | |||||
| interface InputSchema { | |||||
| properties: Properties; | |||||
| required: string[]; | |||||
| title: string; | |||||
| type: string; | |||||
| } | |||||
| interface Properties { | |||||
| symbol: ISymbol; | |||||
| } | |||||
| interface ISymbol { | |||||
| title: string; | |||||
| type: string; | |||||
| } |
| export interface ITestMcpRequestBody { | |||||
| server_type: string; | |||||
| url: string; | |||||
| headers?: Record<string, any>; | |||||
| variables?: Record<string, any>; | |||||
| timeout?: number; | |||||
| } |
| import { ButtonLoading } from '@/components/ui/button'; | |||||
| import { Collapse } from '@/components/collapse'; | |||||
| import { Button, ButtonLoading } from '@/components/ui/button'; | |||||
| import { | import { | ||||
| Dialog, | Dialog, | ||||
| DialogContent, | DialogContent, | ||||
| DialogHeader, | DialogHeader, | ||||
| DialogTitle, | DialogTitle, | ||||
| } from '@/components/ui/dialog'; | } from '@/components/ui/dialog'; | ||||
| import { useTestMcpServer } from '@/hooks/use-mcp-request'; | |||||
| import { IModalProps } from '@/interfaces/common'; | import { IModalProps } from '@/interfaces/common'; | ||||
| import { IMCPTool, IMCPToolObject } from '@/interfaces/database/mcp'; | |||||
| import { omit } from 'lodash'; | |||||
| import { RefreshCw } from 'lucide-react'; | |||||
| import { MouseEventHandler, useCallback, useState } from 'react'; | |||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import { EditMcpForm, FormId } from './edit-mcp-form'; | |||||
| import { z } from 'zod'; | |||||
| import { EditMcpForm, FormId, useBuildFormSchema } from './edit-mcp-form'; | |||||
| import { McpToolCard } from './tool-card'; | |||||
| function transferToolToObject(tools: IMCPTool[] = []) { | |||||
| return tools.reduce<IMCPToolObject>((pre, tool) => { | |||||
| pre[tool.name] = omit(tool, 'name'); | |||||
| return pre; | |||||
| }, {}); | |||||
| } | |||||
| export function EditMcpDialog({ hideModal, loading, onOk }: IModalProps<any>) { | export function EditMcpDialog({ hideModal, loading, onOk }: IModalProps<any>) { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const { testMcpServer, data: tools } = useTestMcpServer(); | |||||
| const [isTriggeredBySaving, setIsTriggeredBySaving] = useState(false); | |||||
| const FormSchema = useBuildFormSchema(); | |||||
| const handleTest: MouseEventHandler<HTMLButtonElement> = useCallback((e) => { | |||||
| e.stopPropagation(); | |||||
| setIsTriggeredBySaving(false); | |||||
| }, []); | |||||
| const handleSave: MouseEventHandler<HTMLButtonElement> = useCallback(() => { | |||||
| setIsTriggeredBySaving(true); | |||||
| }, []); | |||||
| const handleOk = async (values: z.infer<typeof FormSchema>) => { | |||||
| if (isTriggeredBySaving) { | |||||
| onOk?.({ | |||||
| ...values, | |||||
| variables: { | |||||
| ...(values?.variables || {}), | |||||
| tools: transferToolToObject(tools), | |||||
| }, | |||||
| }); | |||||
| } else { | |||||
| testMcpServer(values); | |||||
| } | |||||
| }; | |||||
| return ( | return ( | ||||
| <Dialog open onOpenChange={hideModal}> | <Dialog open onOpenChange={hideModal}> | ||||
| <DialogHeader> | <DialogHeader> | ||||
| <DialogTitle>Edit profile</DialogTitle> | <DialogTitle>Edit profile</DialogTitle> | ||||
| </DialogHeader> | </DialogHeader> | ||||
| <EditMcpForm onOk={onOk}></EditMcpForm> | |||||
| <EditMcpForm onOk={handleOk}></EditMcpForm> | |||||
| <Collapse | |||||
| title={<div>{tools?.length || 0} tools available</div>} | |||||
| rightContent={ | |||||
| <Button | |||||
| variant={'ghost'} | |||||
| form={FormId} | |||||
| type="submit" | |||||
| onClick={handleTest} | |||||
| > | |||||
| <RefreshCw className="text-background-checked" /> | |||||
| </Button> | |||||
| } | |||||
| > | |||||
| <div className="space-y-2.5"> | |||||
| {tools?.map((x) => ( | |||||
| <McpToolCard key={x.name} data={x}></McpToolCard> | |||||
| ))} | |||||
| </div> | |||||
| </Collapse> | |||||
| <DialogFooter> | <DialogFooter> | ||||
| <ButtonLoading type="submit" form={FormId} loading={loading}> | |||||
| <ButtonLoading | |||||
| type="submit" | |||||
| form={FormId} | |||||
| loading={loading} | |||||
| onClick={handleSave} | |||||
| disabled={!!!tools?.length} | |||||
| > | |||||
| {t('common.save')} | {t('common.save')} | ||||
| </ButtonLoading> | </ButtonLoading> | ||||
| </DialogFooter> | </DialogFooter> |
| const ServerTypeOptions = buildOptions(ServerType); | const ServerTypeOptions = buildOptions(ServerType); | ||||
| export function EditMcpForm({ | |||||
| initialName, | |||||
| onOk, | |||||
| }: IModalProps<any> & { initialName?: string }) { | |||||
| export function useBuildFormSchema() { | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const FormSchema = z.object({ | const FormSchema = z.object({ | ||||
| message: t('common.namePlaceholder'), | message: t('common.namePlaceholder'), | ||||
| }) | }) | ||||
| .trim(), | .trim(), | ||||
| variables: z.object({}).optional(), | |||||
| }); | }); | ||||
| return FormSchema; | |||||
| } | |||||
| export function EditMcpForm({ | |||||
| initialName, | |||||
| onOk, | |||||
| }: IModalProps<any> & { initialName?: string }) { | |||||
| const { t } = useTranslation(); | |||||
| const FormSchema = useBuildFormSchema(); | |||||
| const form = useForm<z.infer<typeof FormSchema>>({ | const form = useForm<z.infer<typeof FormSchema>>({ | ||||
| resolver: zodResolver(FormSchema), | resolver: zodResolver(FormSchema), | ||||
| defaultValues: { name: '', server_type: ServerType.SSE, url: '' }, | defaultValues: { name: '', server_type: ServerType.SSE, url: '' }, |
| import { Checkbox } from '@/components/ui/checkbox'; | import { Checkbox } from '@/components/ui/checkbox'; | ||||
| import { IMcpServer } from '@/interfaces/database/mcp'; | import { IMcpServer } from '@/interfaces/database/mcp'; | ||||
| import { formatDate } from '@/utils/date'; | import { formatDate } from '@/utils/date'; | ||||
| import { isPlainObject } from 'lodash'; | |||||
| import { useMemo } from 'react'; | |||||
| import { McpDropdown } from './mcp-dropdown'; | import { McpDropdown } from './mcp-dropdown'; | ||||
| import { UseBulkOperateMCPReturnType } from './use-bulk-operate-mcp'; | import { UseBulkOperateMCPReturnType } from './use-bulk-operate-mcp'; | ||||
| selectedList, | selectedList, | ||||
| handleSelectChange, | handleSelectChange, | ||||
| }: DatasetCardProps) { | }: DatasetCardProps) { | ||||
| const toolLength = useMemo(() => { | |||||
| const tools = data.variables?.tools; | |||||
| if (isPlainObject(tools)) { | |||||
| return Object.keys(tools || {}).length; | |||||
| } | |||||
| return 0; | |||||
| }, [data.variables?.tools]); | |||||
| const onCheckedChange = (checked: boolean) => { | |||||
| if (typeof checked === 'boolean') { | |||||
| handleSelectChange(data.id, checked); | |||||
| } | |||||
| }; | |||||
| return ( | return ( | ||||
| <Card key={data.id} className="w-64"> | <Card key={data.id} className="w-64"> | ||||
| <CardContent className="p-2.5 pt-2 group"> | <CardContent className="p-2.5 pt-2 group"> | ||||
| </McpDropdown> | </McpDropdown> | ||||
| <Checkbox | <Checkbox | ||||
| checked={selectedList.includes(data.id)} | checked={selectedList.includes(data.id)} | ||||
| onCheckedChange={(checked) => { | |||||
| if (typeof checked === 'boolean') { | |||||
| handleSelectChange(data.id, checked); | |||||
| } | |||||
| }} | |||||
| onCheckedChange={onCheckedChange} | |||||
| onClick={(e) => { | onClick={(e) => { | ||||
| e.stopPropagation(); | e.stopPropagation(); | ||||
| }} | }} | ||||
| <div className="flex justify-between items-end"> | <div className="flex justify-between items-end"> | ||||
| <div className="w-full"> | <div className="w-full"> | ||||
| <div className="text-base font-semibold mb-3 line-clamp-1 text-text-sub-title"> | <div className="text-base font-semibold mb-3 line-clamp-1 text-text-sub-title"> | ||||
| 20 cached tools | |||||
| {toolLength} cached tools | |||||
| </div> | </div> | ||||
| <p className="text-sm text-text-sub-title"> | <p className="text-sm text-text-sub-title"> | ||||
| {formatDate(data.update_date)} | {formatDate(data.update_date)} |
| import { Card, CardContent } from '@/components/ui/card'; | |||||
| import { IMCPTool } from '@/interfaces/database/mcp'; | |||||
| export type McpToolCardProps = { | |||||
| data: IMCPTool; | |||||
| }; | |||||
| export function McpToolCard({ data }: McpToolCardProps) { | |||||
| return ( | |||||
| <Card> | |||||
| <CardContent className="p-2.5 pt-2 group"> | |||||
| <h3 className="text-sm font-semibold line-clamp-1 pb-2">{data.name}</h3> | |||||
| <div className="text-xs font-normal mb-3 text-text-sub-title"> | |||||
| {data.description} | |||||
| </div> | |||||
| </CardContent> | |||||
| </Card> | |||||
| ); | |||||
| } |