UNPKG

@lobehub/chat

Version:

Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.

316 lines (249 loc) 9.59 kB
import { TRPCError } from '@trpc/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; // Mock 依赖 vi.mock('@/libs/mcp'); describe('MCPService', () => { let mcpService: any; let mockClient: any; beforeEach(async () => { vi.clearAllMocks(); // 动态导入服务实例 const { mcpService: importedService } = await import('./index'); mcpService = importedService; // 创建 mock 客户端 mockClient = { callTool: vi.fn(), listTools: vi.fn(), }; // Mock getClient 方法返回 mock 客户端 vi.spyOn(mcpService as any, 'getClient').mockResolvedValue(mockClient); }); describe('callTool', () => { const mockParams = { name: 'test-mcp', type: 'stdio' as const, command: 'test-command', args: ['--test'], }; it('should return original data when content array is empty', async () => { mockClient.callTool.mockResolvedValue({ content: [], isError: false, }); const result = await mcpService.callTool(mockParams, 'testTool', '{}'); expect(result).toEqual([]); }); it('should return original data when content is null or undefined', async () => { mockClient.callTool.mockResolvedValue({ content: null, isError: false, }); const result = await mcpService.callTool(mockParams, 'testTool', '{}'); expect(result).toBeNull(); }); it('should return parsed JSON when single element contains valid JSON', async () => { const jsonData = { message: 'Hello World', status: 'success' }; mockClient.callTool.mockResolvedValue({ content: [{ type: 'text', text: JSON.stringify(jsonData) }], isError: false, }); const result = await mcpService.callTool(mockParams, 'testTool', '{}'); expect(result).toEqual(jsonData); }); it('should return plain text when single element contains non-JSON text', async () => { const textData = 'Hello World'; mockClient.callTool.mockResolvedValue({ content: [{ type: 'text', text: textData }], isError: false, }); const result = await mcpService.callTool(mockParams, 'testTool', '{}'); expect(result).toBe(textData); }); it('should return original data when single element has no text', async () => { const contentData = [{ type: 'text', text: '' }]; mockClient.callTool.mockResolvedValue({ content: contentData, isError: false, }); const result = await mcpService.callTool(mockParams, 'testTool', '{}'); expect(result).toEqual(contentData); }); it('should return complete array when content has multiple elements', async () => { const multipleContent = [ { type: 'text', text: 'First message' }, { type: 'text', text: 'Second message' }, { type: 'text', text: '{"json": "data"}' }, ]; mockClient.callTool.mockResolvedValue({ content: multipleContent, isError: false, }); const result = await mcpService.callTool(mockParams, 'testTool', '{}'); // 应该直接返回完整的数组,不进行任何处理 expect(result).toEqual(multipleContent); }); it('should return complete array when content has two elements', async () => { const twoContent = [ { type: 'text', text: 'First message' }, { type: 'text', text: 'Second message' }, ]; mockClient.callTool.mockResolvedValue({ content: twoContent, isError: false, }); const result = await mcpService.callTool(mockParams, 'testTool', '{}'); expect(result).toEqual(twoContent); }); it('should return error result when isError is true', async () => { const errorResult = { content: [{ type: 'text', text: 'Error occurred' }], isError: true, }; mockClient.callTool.mockResolvedValue(errorResult); const result = await mcpService.callTool(mockParams, 'testTool', '{}'); expect(result).toEqual(errorResult); }); it('should throw TRPCError when client throws error', async () => { const error = new Error('MCP client error'); mockClient.callTool.mockRejectedValue(error); await expect(mcpService.callTool(mockParams, 'testTool', '{}')).rejects.toThrow(TRPCError); }); it('should parse args string correctly', async () => { const argsObject = { param1: 'value1', param2: 'value2' }; const argsString = JSON.stringify(argsObject); mockClient.callTool.mockResolvedValue({ content: [{ type: 'text', text: 'result' }], isError: false, }); await mcpService.callTool(mockParams, 'testTool', argsString); expect(mockClient.callTool).toHaveBeenCalledWith('testTool', argsObject); }); }); describe('listTools', () => { const mockParams = { name: 'test-mcp', type: 'stdio' as const, command: 'test-command', args: ['--test'], }; it('should successfully list tools and transform to LobeChatPluginApi format', async () => { const mockTools = [ { name: 'tool1', description: 'First test tool', inputSchema: { type: 'object', properties: { param1: { type: 'string' } }, }, }, { name: 'tool2', description: 'Second test tool', inputSchema: { type: 'object', properties: { param2: { type: 'number' } }, }, }, ]; mockClient.listTools.mockResolvedValue(mockTools); const result = await mcpService.listTools(mockParams); expect(mockClient.listTools).toHaveBeenCalled(); expect(result).toEqual([ { name: 'tool1', description: 'First test tool', parameters: { type: 'object', properties: { param1: { type: 'string' } }, }, }, { name: 'tool2', description: 'Second test tool', parameters: { type: 'object', properties: { param2: { type: 'number' } }, }, }, ]); }); it('should return empty array when no tools available', async () => { mockClient.listTools.mockResolvedValue([]); const result = await mcpService.listTools(mockParams); expect(result).toEqual([]); }); it('should retry with skipCache when NoValidSessionId error occurs (first retry)', async () => { const mockTools = [ { name: 'tool1', description: 'Test tool', inputSchema: { type: 'object' }, }, ]; // First call fails with NoValidSessionId mockClient.listTools.mockRejectedValueOnce(new Error('NoValidSessionId')); // Second call (with skipCache=true) succeeds mockClient.listTools.mockResolvedValueOnce(mockTools); const result = await mcpService.listTools(mockParams); expect(mockClient.listTools).toHaveBeenCalledTimes(2); expect(result).toEqual([ { name: 'tool1', description: 'Test tool', parameters: { type: 'object' }, }, ]); }); it('should retry up to 3 times for NoValidSessionId error', async () => { const mockTools = [ { name: 'tool1', description: 'Test tool', inputSchema: { type: 'object' }, }, ]; // Fail 3 times, succeed on 4th mockClient.listTools .mockRejectedValueOnce(new Error('NoValidSessionId')) .mockRejectedValueOnce(new Error('NoValidSessionId')) .mockRejectedValueOnce(new Error('NoValidSessionId')) .mockResolvedValueOnce(mockTools); const result = await mcpService.listTools(mockParams); expect(mockClient.listTools).toHaveBeenCalledTimes(4); expect(result).toHaveLength(1); }); it('should throw TRPCError when NoValidSessionId retry exceeds limit', async () => { // Fail more than 3 times mockClient.listTools.mockRejectedValue(new Error('NoValidSessionId')); await expect(mcpService.listTools(mockParams)).rejects.toThrow(TRPCError); expect(mockClient.listTools).toHaveBeenCalledTimes(5); // initial + 4 retry attempts (last one fails condition) }); it('should throw TRPCError on other errors without retry', async () => { const error = new Error('Connection failed'); mockClient.listTools.mockRejectedValue(error); await expect(mcpService.listTools(mockParams)).rejects.toThrow(TRPCError); expect(mockClient.listTools).toHaveBeenCalledTimes(1); }); it('should pass skipCache option to getClient', async () => { const mockTools = [ { name: 'tool1', description: 'Test tool', inputSchema: { type: 'object' }, }, ]; mockClient.listTools.mockResolvedValue(mockTools); await mcpService.listTools(mockParams, { skipCache: true }); // Verify getClient was called with skipCache expect(mcpService.getClient).toHaveBeenCalledWith(mockParams, true); }); it('should throw TRPCError with correct error message', async () => { const error = new Error('Custom error message'); mockClient.listTools.mockRejectedValue(error); await expect(mcpService.listTools(mockParams)).rejects.toMatchObject({ message: 'Error listing tools from MCP server: Custom error message', code: 'INTERNAL_SERVER_ERROR', }); }); }); });