| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193 |
- 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<string, JsonValue> = {};
- 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<typeof makeRuntime>;
- 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' });
- });
- });
|