mcpService.test.ts 6.4 KB

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