@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
text/typescript
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',
});
});
});
});