feat: 添加 MCP (Model Context Protocol) 集成支持
实现 MCP 协议集成,允许通过外部服务器扩展工具能力:
核心模块:
- types.ts: MCP 类型定义 (LocalMCPServer, RemoteMCPServer, MCPTool 等)
- config.ts: 配置加载、验证,支持 {env:VAR} 环境变量语法
- transports/stdio.ts: stdio 传输层实现 (JSON-RPC 2.0)
- client.ts: MCP 客户端,处理协议握手和工具调用
- manager.ts: 多服务器生命周期管理
- tool-adapter.ts: 将 MCP 工具转换为内部格式
CLI 命令:
- ai-assist mcp list: 列出服务器状态
- ai-assist mcp tools: 列出可用工具
- ai-assist mcp test <server>: 测试连接
配置支持:
- 用户级 (~/.ai-assist/config.json) 和项目级配置
- JSON/YAML 格式
- 通配符模式工具启用/禁用
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* MCP 工具适配器测试
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MCPToolAdapter, createMCPToolAdapter } from '../../../src/mcp/tool-adapter.js';
|
||||
import type { MCPTool, MCPManager, MCPToolCallResult } from '../../../src/mcp/types.js';
|
||||
|
||||
// 创建 mock MCPManager
|
||||
function createMockManager(): MCPManager {
|
||||
return {
|
||||
callTool: vi.fn(),
|
||||
getTools: vi.fn().mockReturnValue([]),
|
||||
getServerStatuses: vi.fn().mockReturnValue([]),
|
||||
initialize: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
on: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
} as unknown as MCPManager;
|
||||
}
|
||||
|
||||
describe('MCPToolAdapter', () => {
|
||||
let mockManager: MCPManager;
|
||||
let adapter: MCPToolAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockManager = createMockManager();
|
||||
adapter = new MCPToolAdapter(mockManager);
|
||||
});
|
||||
|
||||
describe('adaptToInternalTool', () => {
|
||||
it('应该将 MCP 工具转换为内部格式', () => {
|
||||
const mcpTool: MCPTool = {
|
||||
server: 'filesystem',
|
||||
name: 'filesystem-read_file',
|
||||
originalName: 'read_file',
|
||||
description: 'Read a file from the filesystem',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: 'Path to the file',
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
};
|
||||
|
||||
const internalTool = adapter.adaptToInternalTool(mcpTool);
|
||||
|
||||
expect(internalTool.name).toBe('filesystem-read_file');
|
||||
expect(internalTool.description).toBe('Read a file from the filesystem');
|
||||
expect(internalTool.parameters).toHaveProperty('path');
|
||||
expect(internalTool.parameters.path.type).toBe('string');
|
||||
expect(internalTool.parameters.path.required).toBe(true);
|
||||
expect(internalTool.metadata.category).toBe('agent');
|
||||
expect(internalTool.metadata.keywords).toContain('mcp');
|
||||
expect(internalTool.metadata.keywords).toContain('filesystem');
|
||||
});
|
||||
|
||||
it('应该正确转换参数类型', () => {
|
||||
const mcpTool: MCPTool = {
|
||||
server: 'test',
|
||||
name: 'test-tool',
|
||||
originalName: 'tool',
|
||||
description: 'Test tool',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
stringParam: { type: 'string' },
|
||||
numberParam: { type: 'number' },
|
||||
integerParam: { type: 'integer' },
|
||||
booleanParam: { type: 'boolean' },
|
||||
arrayParam: { type: 'array' },
|
||||
objectParam: { type: 'object' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const internalTool = adapter.adaptToInternalTool(mcpTool);
|
||||
|
||||
expect(internalTool.parameters.stringParam.type).toBe('string');
|
||||
expect(internalTool.parameters.numberParam.type).toBe('number');
|
||||
expect(internalTool.parameters.integerParam.type).toBe('number');
|
||||
expect(internalTool.parameters.booleanParam.type).toBe('boolean');
|
||||
expect(internalTool.parameters.arrayParam.type).toBe('array');
|
||||
expect(internalTool.parameters.objectParam.type).toBe('object');
|
||||
});
|
||||
|
||||
it('应该处理联合类型', () => {
|
||||
const mcpTool: MCPTool = {
|
||||
server: 'test',
|
||||
name: 'test-tool',
|
||||
originalName: 'tool',
|
||||
description: 'Test tool',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nullable: { type: ['string', 'null'] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const internalTool = adapter.adaptToInternalTool(mcpTool);
|
||||
expect(internalTool.parameters.nullable.type).toBe('string');
|
||||
});
|
||||
|
||||
it('应该处理空 inputSchema', () => {
|
||||
const mcpTool: MCPTool = {
|
||||
server: 'test',
|
||||
name: 'test-tool',
|
||||
originalName: 'tool',
|
||||
description: 'Test tool',
|
||||
inputSchema: {},
|
||||
};
|
||||
|
||||
const internalTool = adapter.adaptToInternalTool(mcpTool);
|
||||
expect(internalTool.parameters).toEqual({});
|
||||
});
|
||||
|
||||
it('execute 应该调用 manager.callTool', async () => {
|
||||
const mcpTool: MCPTool = {
|
||||
server: 'test',
|
||||
name: 'test-tool',
|
||||
originalName: 'tool',
|
||||
description: 'Test tool',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
arg: { type: 'string' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockResult: MCPToolCallResult = {
|
||||
success: true,
|
||||
content: [{ type: 'text', text: 'result' }],
|
||||
isError: false,
|
||||
};
|
||||
vi.mocked(mockManager.callTool).mockResolvedValue(mockResult);
|
||||
|
||||
const internalTool = adapter.adaptToInternalTool(mcpTool);
|
||||
const result = await internalTool.execute({ arg: 'value' });
|
||||
|
||||
expect(mockManager.callTool).toHaveBeenCalledWith('test-tool', {
|
||||
arg: 'value',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe('result');
|
||||
});
|
||||
|
||||
it('execute 应该处理错误结果', async () => {
|
||||
const mcpTool: MCPTool = {
|
||||
server: 'test',
|
||||
name: 'test-tool',
|
||||
originalName: 'tool',
|
||||
description: 'Test tool',
|
||||
inputSchema: {},
|
||||
};
|
||||
|
||||
const mockResult: MCPToolCallResult = {
|
||||
success: false,
|
||||
content: [{ type: 'text', text: 'error message' }],
|
||||
isError: true,
|
||||
};
|
||||
vi.mocked(mockManager.callTool).mockResolvedValue(mockResult);
|
||||
|
||||
const internalTool = adapter.adaptToInternalTool(mcpTool);
|
||||
const result = await internalTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('error message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('adaptTools', () => {
|
||||
it('应该批量转换工具', () => {
|
||||
const mcpTools: MCPTool[] = [
|
||||
{
|
||||
server: 'server1',
|
||||
name: 'server1-tool1',
|
||||
originalName: 'tool1',
|
||||
description: 'Tool 1',
|
||||
inputSchema: {},
|
||||
},
|
||||
{
|
||||
server: 'server2',
|
||||
name: 'server2-tool2',
|
||||
originalName: 'tool2',
|
||||
description: 'Tool 2',
|
||||
inputSchema: {},
|
||||
},
|
||||
];
|
||||
|
||||
const internalTools = adapter.adaptTools(mcpTools);
|
||||
|
||||
expect(internalTools).toHaveLength(2);
|
||||
expect(internalTools[0].name).toBe('server1-tool1');
|
||||
expect(internalTools[1].name).toBe('server2-tool2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMCPToolAdapter', () => {
|
||||
it('应该创建适配器实例', () => {
|
||||
const adapter = createMCPToolAdapter(mockManager);
|
||||
expect(adapter).toBeInstanceOf(MCPToolAdapter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('内容转换', () => {
|
||||
it('应该处理图片内容', async () => {
|
||||
const mcpTool: MCPTool = {
|
||||
server: 'test',
|
||||
name: 'test-tool',
|
||||
originalName: 'tool',
|
||||
description: 'Test tool',
|
||||
inputSchema: {},
|
||||
};
|
||||
|
||||
const mockResult: MCPToolCallResult = {
|
||||
success: true,
|
||||
content: [{ type: 'image', data: 'base64data', mimeType: 'image/png' }],
|
||||
isError: false,
|
||||
};
|
||||
vi.mocked(mockManager.callTool).mockResolvedValue(mockResult);
|
||||
|
||||
const internalTool = adapter.adaptToInternalTool(mcpTool);
|
||||
const result = await internalTool.execute({});
|
||||
|
||||
expect(result.output).toBe('[Image: image/png]');
|
||||
});
|
||||
|
||||
it('应该处理资源内容', async () => {
|
||||
const mcpTool: MCPTool = {
|
||||
server: 'test',
|
||||
name: 'test-tool',
|
||||
originalName: 'tool',
|
||||
description: 'Test tool',
|
||||
inputSchema: {},
|
||||
};
|
||||
|
||||
const mockResult: MCPToolCallResult = {
|
||||
success: true,
|
||||
content: [
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'file:///test.txt',
|
||||
text: 'file content',
|
||||
},
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
vi.mocked(mockManager.callTool).mockResolvedValue(mockResult);
|
||||
|
||||
const internalTool = adapter.adaptToInternalTool(mcpTool);
|
||||
const result = await internalTool.execute({});
|
||||
|
||||
expect(result.output).toBe('file content');
|
||||
});
|
||||
|
||||
it('应该处理混合内容', async () => {
|
||||
const mcpTool: MCPTool = {
|
||||
server: 'test',
|
||||
name: 'test-tool',
|
||||
originalName: 'tool',
|
||||
description: 'Test tool',
|
||||
inputSchema: {},
|
||||
};
|
||||
|
||||
const mockResult: MCPToolCallResult = {
|
||||
success: true,
|
||||
content: [
|
||||
{ type: 'text', text: 'line 1' },
|
||||
{ type: 'text', text: 'line 2' },
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
vi.mocked(mockManager.callTool).mockResolvedValue(mockResult);
|
||||
|
||||
const internalTool = adapter.adaptToInternalTool(mcpTool);
|
||||
const result = await internalTool.execute({});
|
||||
|
||||
expect(result.output).toBe('line 1\nline 2');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user