### 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
| @@ -1,5 +1,6 @@ | |||
| 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 mcpServerService from '@/services/mcp-server-service'; | |||
| import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | |||
| @@ -164,12 +165,12 @@ export const useTestMcpServer = () => { | |||
| data, | |||
| isPending: loading, | |||
| mutateAsync, | |||
| } = useMutation({ | |||
| } = useMutation<IMCPTool[], Error, ITestMcpRequestBody>({ | |||
| 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 || []; | |||
| }, | |||
| }); | |||
| @@ -6,10 +6,36 @@ export interface IMcpServer { | |||
| server_type: string; | |||
| update_date: string; | |||
| url: string; | |||
| variables: Record<string, any>; | |||
| variables: Record<string, any> & { tools?: IMCPToolObject }; | |||
| } | |||
| export type IMCPToolObject = Record<string, Omit<IMCPTool, 'name'>>; | |||
| export interface IMcpServerListResponse { | |||
| mcp_servers: IMcpServer[]; | |||
| 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; | |||
| } | |||
| @@ -0,0 +1,7 @@ | |||
| export interface ITestMcpRequestBody { | |||
| server_type: string; | |||
| url: string; | |||
| headers?: Record<string, any>; | |||
| variables?: Record<string, any>; | |||
| timeout?: number; | |||
| } | |||
| @@ -1,4 +1,5 @@ | |||
| import { ButtonLoading } from '@/components/ui/button'; | |||
| import { Collapse } from '@/components/collapse'; | |||
| import { Button, ButtonLoading } from '@/components/ui/button'; | |||
| import { | |||
| Dialog, | |||
| DialogContent, | |||
| @@ -6,12 +7,52 @@ import { | |||
| DialogHeader, | |||
| DialogTitle, | |||
| } from '@/components/ui/dialog'; | |||
| import { useTestMcpServer } from '@/hooks/use-mcp-request'; | |||
| 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 { 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>) { | |||
| 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 ( | |||
| <Dialog open onOpenChange={hideModal}> | |||
| @@ -19,9 +60,34 @@ export function EditMcpDialog({ hideModal, loading, onOk }: IModalProps<any>) { | |||
| <DialogHeader> | |||
| <DialogTitle>Edit profile</DialogTitle> | |||
| </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> | |||
| <ButtonLoading type="submit" form={FormId} loading={loading}> | |||
| <ButtonLoading | |||
| type="submit" | |||
| form={FormId} | |||
| loading={loading} | |||
| onClick={handleSave} | |||
| disabled={!!!tools?.length} | |||
| > | |||
| {t('common.save')} | |||
| </ButtonLoading> | |||
| </DialogFooter> | |||
| @@ -28,10 +28,7 @@ enum ServerType { | |||
| const ServerTypeOptions = buildOptions(ServerType); | |||
| export function EditMcpForm({ | |||
| initialName, | |||
| onOk, | |||
| }: IModalProps<any> & { initialName?: string }) { | |||
| export function useBuildFormSchema() { | |||
| const { t } = useTranslation(); | |||
| const FormSchema = z.object({ | |||
| @@ -53,8 +50,20 @@ export function EditMcpForm({ | |||
| message: t('common.namePlaceholder'), | |||
| }) | |||
| .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>>({ | |||
| resolver: zodResolver(FormSchema), | |||
| defaultValues: { name: '', server_type: ServerType.SSE, url: '' }, | |||
| @@ -3,6 +3,8 @@ import { Card, CardContent } from '@/components/ui/card'; | |||
| import { Checkbox } from '@/components/ui/checkbox'; | |||
| import { IMcpServer } from '@/interfaces/database/mcp'; | |||
| import { formatDate } from '@/utils/date'; | |||
| import { isPlainObject } from 'lodash'; | |||
| import { useMemo } from 'react'; | |||
| import { McpDropdown } from './mcp-dropdown'; | |||
| import { UseBulkOperateMCPReturnType } from './use-bulk-operate-mcp'; | |||
| @@ -15,6 +17,18 @@ export function McpCard({ | |||
| selectedList, | |||
| handleSelectChange, | |||
| }: 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 ( | |||
| <Card key={data.id} className="w-64"> | |||
| <CardContent className="p-2.5 pt-2 group"> | |||
| @@ -26,11 +40,7 @@ export function McpCard({ | |||
| </McpDropdown> | |||
| <Checkbox | |||
| checked={selectedList.includes(data.id)} | |||
| onCheckedChange={(checked) => { | |||
| if (typeof checked === 'boolean') { | |||
| handleSelectChange(data.id, checked); | |||
| } | |||
| }} | |||
| onCheckedChange={onCheckedChange} | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| }} | |||
| @@ -40,7 +50,7 @@ export function McpCard({ | |||
| <div className="flex justify-between items-end"> | |||
| <div className="w-full"> | |||
| <div className="text-base font-semibold mb-3 line-clamp-1 text-text-sub-title"> | |||
| 20 cached tools | |||
| {toolLength} cached tools | |||
| </div> | |||
| <p className="text-sm text-text-sub-title"> | |||
| {formatDate(data.update_date)} | |||
| @@ -0,0 +1,19 @@ | |||
| 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> | |||
| ); | |||
| } | |||