mcpService.test.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import { beforeEach, describe, expect, it, vi } from 'vitest';
  2. import { McpService, type McpServerInput } from './mcpService';
  3. import type { JsonValue } from './codexRuntime';
  4. /** 内存版 runtime:只实现 McpService 用到的四个方法 */
  5. function makeRuntime(
  6. statuses: Array<{
  7. name: string;
  8. connected: boolean;
  9. toolCount: number;
  10. authStatus: string;
  11. tools?: Array<{ name: string; description: string }>;
  12. }> = [],
  13. ) {
  14. const servers: Record<string, JsonValue> = {};
  15. return {
  16. servers,
  17. readConfig: vi.fn(async () => ({ mcp_servers: structuredClone(servers) })),
  18. writeConfigValue: vi.fn(async (keyPath: string, value: JsonValue) => {
  19. const id = keyPath.replace(/^mcp_servers\./u, '');
  20. if (value === null) delete servers[id];
  21. else servers[id] = value;
  22. }),
  23. reloadMcpServers: vi.fn(async () => undefined),
  24. // 调用方只写关心的字段,tools 缺省补空数组以对齐 CodexMcpServerStatus
  25. listMcpServerStatuses: vi.fn(async () => statuses.map((item) => ({ ...item, tools: item.tools ?? [] }))),
  26. };
  27. }
  28. const stdioInput: McpServerInput = {
  29. id: 'demo-tools',
  30. transport: 'stdio',
  31. command: 'npx',
  32. args: ['-y', '@example/mcp'],
  33. enabled: true,
  34. };
  35. let runtime: ReturnType<typeof makeRuntime>;
  36. let service: McpService;
  37. beforeEach(() => {
  38. runtime = makeRuntime();
  39. service = new McpService(runtime);
  40. });
  41. describe('save', () => {
  42. it('stdio 写成 command + args,并热重载', async () => {
  43. await service.save(stdioInput);
  44. expect(runtime.writeConfigValue).toHaveBeenCalledWith('mcp_servers.demo-tools', {
  45. enabled: true,
  46. command: 'npx',
  47. args: ['-y', '@example/mcp'],
  48. });
  49. expect(runtime.reloadMcpServers).toHaveBeenCalledOnce();
  50. });
  51. it('http 写成 url + bearer_token_env_var,不接受明文 token', async () => {
  52. await service.save({
  53. id: 'remote',
  54. transport: 'http',
  55. url: 'https://mcp.example.com/v1',
  56. enabled: false,
  57. bearerTokenEnvVar: 'ZSJZ_MCP_TOKEN',
  58. });
  59. expect(runtime.writeConfigValue).toHaveBeenCalledWith('mcp_servers.remote', {
  60. enabled: false,
  61. url: 'https://mcp.example.com/v1',
  62. bearer_token_env_var: 'ZSJZ_MCP_TOKEN',
  63. });
  64. });
  65. it('非法 id / 传输方式 / enabled 被拒', async () => {
  66. await expect(service.save({ ...stdioInput, id: 'has space' })).rejects.toThrow(/MCP ID/);
  67. await expect(service.save({ ...stdioInput, id: '中文名' })).rejects.toThrow(/MCP ID/);
  68. await expect(
  69. service.save({ ...stdioInput, transport: 'ws' as unknown as 'stdio' }),
  70. ).rejects.toThrow(/stdio 或 http/);
  71. await expect(
  72. service.save({ ...stdioInput, enabled: 'yes' as unknown as boolean }),
  73. ).rejects.toThrow(/布尔值/);
  74. expect(runtime.writeConfigValue).not.toHaveBeenCalled();
  75. });
  76. it('stdio 必须有命令,参数数量与长度受限', async () => {
  77. await expect(service.save({ ...stdioInput, command: ' ' })).rejects.toThrow(/有效命令/);
  78. await expect(service.save({ ...stdioInput, args: new Array(65).fill('a') })).rejects.toThrow(
  79. /参数列表无效/,
  80. );
  81. await expect(service.save({ ...stdioInput, args: ['a'.repeat(2001)] })).rejects.toThrow(
  82. /参数列表无效/,
  83. );
  84. await expect(service.save({ ...stdioInput, args: ['a\0b'] })).rejects.toThrow(/参数列表无效/);
  85. });
  86. it('http URL 必须合法:禁凭据、禁片段、远程必须 https', async () => {
  87. await expect(
  88. service.save({ id: 'a', transport: 'http', url: 'https://user:pass@example.com/v1', enabled: true }),
  89. ).rejects.toThrow(/不得包含凭据/);
  90. await expect(
  91. service.save({ id: 'a', transport: 'http', url: 'https://example.com/v1#frag', enabled: true }),
  92. ).rejects.toThrow(/不得包含凭据/);
  93. await expect(
  94. service.save({ id: 'a', transport: 'http', url: 'http://10.0.0.8:3000/v1', enabled: true }),
  95. ).rejects.toThrow(/HTTPS/);
  96. // 本机 http 允许(本地模型/本地 MCP 常见形态)
  97. await expect(
  98. service.save({ id: 'a', transport: 'http', url: 'http://127.0.0.1:3000/v1', enabled: true }),
  99. ).resolves.toBeTruthy();
  100. });
  101. it('bearer token 只接受环境变量名', async () => {
  102. await expect(
  103. service.save({
  104. id: 'a',
  105. transport: 'http',
  106. url: 'https://example.com/v1',
  107. enabled: true,
  108. bearerTokenEnvVar: 'sk-actual-secret-value',
  109. }),
  110. ).rejects.toThrow(/环境变量名无效/);
  111. });
  112. });
  113. describe('remove', () => {
  114. it('写 null 删除该叶子并重载', async () => {
  115. await service.save(stdioInput);
  116. await service.remove('demo-tools');
  117. expect(runtime.writeConfigValue).toHaveBeenLastCalledWith('mcp_servers.demo-tools', null);
  118. expect(runtime.servers['demo-tools']).toBeUndefined();
  119. expect(runtime.reloadMcpServers).toHaveBeenCalledTimes(2);
  120. });
  121. it('拒绝非法 id', async () => {
  122. await expect(service.remove('../etc')).rejects.toThrow(/MCP ID/);
  123. });
  124. });
  125. describe('list', () => {
  126. it('忽略非法 id 的残留条目', async () => {
  127. runtime.servers['demo-tools'] = { enabled: true, command: 'npx', args: ['-y', '@example/mcp'] };
  128. runtime.servers['bad id'] = { enabled: true, command: 'x', args: [] };
  129. const items = await service.list();
  130. expect(items.map((item) => item.id)).toEqual(['demo-tools']);
  131. expect(items[0]).toMatchObject({
  132. transport: 'stdio',
  133. command: 'npx',
  134. enabled: true,
  135. connected: false,
  136. toolCount: 0,
  137. tools: [],
  138. authStatus: 'unknown',
  139. });
  140. });
  141. it('合并 app-server 返回的连接状态', async () => {
  142. const statusRuntime = makeRuntime([
  143. {
  144. name: 'demo-tools',
  145. connected: true,
  146. toolCount: 2,
  147. authStatus: 'authorized',
  148. tools: [
  149. { name: 'query_case', description: '按条件查询案件数据' },
  150. { name: 'list_tables', description: '' },
  151. ],
  152. },
  153. ]);
  154. statusRuntime.servers['demo-tools'] = { enabled: true, command: 'npx', args: [] };
  155. const items = await new McpService(statusRuntime).list();
  156. expect(items[0]).toMatchObject({ connected: true, toolCount: 2, authStatus: 'authorized' });
  157. expect(items[0].tools).toEqual([
  158. { name: 'query_case', description: '按条件查询案件数据' },
  159. { name: 'list_tables', description: '' },
  160. ]);
  161. });
  162. it('listMcpServerStatuses 失败时仍能列出配置', async () => {
  163. const brokenRuntime = makeRuntime();
  164. brokenRuntime.listMcpServerStatuses.mockRejectedValue(new Error('app-server 未就绪'));
  165. brokenRuntime.servers['demo-tools'] = { enabled: true, command: 'npx', args: [] };
  166. const items = await new McpService(brokenRuntime).list();
  167. expect(items[0]).toMatchObject({ id: 'demo-tools', connected: false });
  168. });
  169. it('url 存在时识别为 http 传输', async () => {
  170. runtime.servers['remote'] = { enabled: false, url: 'https://mcp.example.com/v1' };
  171. const items = await service.list();
  172. expect(items[0]).toMatchObject({ transport: 'http', enabled: false, url: 'https://mcp.example.com/v1' });
  173. });
  174. });