import { beforeEach, describe, expect, it, vi } from 'vitest'; import { McpService, type McpServerInput } from './mcpService'; import type { JsonValue } from './codexRuntime'; /** 内存版 runtime:只实现 McpService 用到的四个方法 */ function makeRuntime( statuses: Array<{ name: string; connected: boolean; toolCount: number; authStatus: string; tools?: Array<{ name: string; description: string }>; }> = [], ) { const servers: Record = {}; return { servers, readConfig: vi.fn(async () => ({ mcp_servers: structuredClone(servers) })), writeConfigValue: vi.fn(async (keyPath: string, value: JsonValue) => { const id = keyPath.replace(/^mcp_servers\./u, ''); if (value === null) delete servers[id]; else servers[id] = value; }), reloadMcpServers: vi.fn(async () => undefined), // 调用方只写关心的字段,tools 缺省补空数组以对齐 CodexMcpServerStatus listMcpServerStatuses: vi.fn(async () => statuses.map((item) => ({ ...item, tools: item.tools ?? [] }))), }; } const stdioInput: McpServerInput = { id: 'demo-tools', transport: 'stdio', command: 'npx', args: ['-y', '@example/mcp'], enabled: true, }; let runtime: ReturnType; let service: McpService; beforeEach(() => { runtime = makeRuntime(); service = new McpService(runtime); }); describe('save', () => { it('stdio 写成 command + args,并热重载', async () => { await service.save(stdioInput); expect(runtime.writeConfigValue).toHaveBeenCalledWith('mcp_servers.demo-tools', { enabled: true, command: 'npx', args: ['-y', '@example/mcp'], }); expect(runtime.reloadMcpServers).toHaveBeenCalledOnce(); }); it('http 写成 url + bearer_token_env_var,不接受明文 token', async () => { await service.save({ id: 'remote', transport: 'http', url: 'https://mcp.example.com/v1', enabled: false, bearerTokenEnvVar: 'ZSJZ_MCP_TOKEN', }); expect(runtime.writeConfigValue).toHaveBeenCalledWith('mcp_servers.remote', { enabled: false, url: 'https://mcp.example.com/v1', bearer_token_env_var: 'ZSJZ_MCP_TOKEN', }); }); it('非法 id / 传输方式 / enabled 被拒', async () => { await expect(service.save({ ...stdioInput, id: 'has space' })).rejects.toThrow(/MCP ID/); await expect(service.save({ ...stdioInput, id: '中文名' })).rejects.toThrow(/MCP ID/); await expect( service.save({ ...stdioInput, transport: 'ws' as unknown as 'stdio' }), ).rejects.toThrow(/stdio 或 http/); await expect( service.save({ ...stdioInput, enabled: 'yes' as unknown as boolean }), ).rejects.toThrow(/布尔值/); expect(runtime.writeConfigValue).not.toHaveBeenCalled(); }); it('stdio 必须有命令,参数数量与长度受限', async () => { await expect(service.save({ ...stdioInput, command: ' ' })).rejects.toThrow(/有效命令/); await expect(service.save({ ...stdioInput, args: new Array(65).fill('a') })).rejects.toThrow( /参数列表无效/, ); await expect(service.save({ ...stdioInput, args: ['a'.repeat(2001)] })).rejects.toThrow( /参数列表无效/, ); await expect(service.save({ ...stdioInput, args: ['a\0b'] })).rejects.toThrow(/参数列表无效/); }); it('http URL 必须合法:禁凭据、禁片段、远程必须 https', async () => { await expect( service.save({ id: 'a', transport: 'http', url: 'https://user:pass@example.com/v1', enabled: true }), ).rejects.toThrow(/不得包含凭据/); await expect( service.save({ id: 'a', transport: 'http', url: 'https://example.com/v1#frag', enabled: true }), ).rejects.toThrow(/不得包含凭据/); await expect( service.save({ id: 'a', transport: 'http', url: 'http://10.0.0.8:3000/v1', enabled: true }), ).rejects.toThrow(/HTTPS/); // 本机 http 允许(本地模型/本地 MCP 常见形态) await expect( service.save({ id: 'a', transport: 'http', url: 'http://127.0.0.1:3000/v1', enabled: true }), ).resolves.toBeTruthy(); }); it('bearer token 只接受环境变量名', async () => { await expect( service.save({ id: 'a', transport: 'http', url: 'https://example.com/v1', enabled: true, bearerTokenEnvVar: 'sk-actual-secret-value', }), ).rejects.toThrow(/环境变量名无效/); }); }); describe('remove', () => { it('写 null 删除该叶子并重载', async () => { await service.save(stdioInput); await service.remove('demo-tools'); expect(runtime.writeConfigValue).toHaveBeenLastCalledWith('mcp_servers.demo-tools', null); expect(runtime.servers['demo-tools']).toBeUndefined(); expect(runtime.reloadMcpServers).toHaveBeenCalledTimes(2); }); it('拒绝非法 id', async () => { await expect(service.remove('../etc')).rejects.toThrow(/MCP ID/); }); }); describe('list', () => { it('忽略非法 id 的残留条目', async () => { runtime.servers['demo-tools'] = { enabled: true, command: 'npx', args: ['-y', '@example/mcp'] }; runtime.servers['bad id'] = { enabled: true, command: 'x', args: [] }; const items = await service.list(); expect(items.map((item) => item.id)).toEqual(['demo-tools']); expect(items[0]).toMatchObject({ transport: 'stdio', command: 'npx', enabled: true, connected: false, toolCount: 0, tools: [], authStatus: 'unknown', }); }); it('合并 app-server 返回的连接状态', async () => { const statusRuntime = makeRuntime([ { name: 'demo-tools', connected: true, toolCount: 2, authStatus: 'authorized', tools: [ { name: 'query_case', description: '按条件查询案件数据' }, { name: 'list_tables', description: '' }, ], }, ]); statusRuntime.servers['demo-tools'] = { enabled: true, command: 'npx', args: [] }; const items = await new McpService(statusRuntime).list(); expect(items[0]).toMatchObject({ connected: true, toolCount: 2, authStatus: 'authorized' }); expect(items[0].tools).toEqual([ { name: 'query_case', description: '按条件查询案件数据' }, { name: 'list_tables', description: '' }, ]); }); it('listMcpServerStatuses 失败时仍能列出配置', async () => { const brokenRuntime = makeRuntime(); brokenRuntime.listMcpServerStatuses.mockRejectedValue(new Error('app-server 未就绪')); brokenRuntime.servers['demo-tools'] = { enabled: true, command: 'npx', args: [] }; const items = await new McpService(brokenRuntime).list(); expect(items[0]).toMatchObject({ id: 'demo-tools', connected: false }); }); it('url 存在时识别为 http 传输', async () => { runtime.servers['remote'] = { enabled: false, url: 'https://mcp.example.com/v1' }; const items = await service.list(); expect(items[0]).toMatchObject({ transport: 'http', enabled: false, url: 'https://mcp.example.com/v1' }); }); });