mcp-credentials.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. // MCP 凭据(docs/design/20-mcp-credentials.md):服务引用凭据、适用地址的前缀匹配、修改后按新值连接、保存为凭据
  2. import http from 'node:http'
  3. import type { AddressInfo } from 'node:net'
  4. import pino from 'pino'
  5. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  6. import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
  7. import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
  8. import { z } from 'zod'
  9. import type { McpCredentialInput, McpServerInput } from '@agent-server/shared'
  10. import { createApp, type App } from '../src/app'
  11. import { auditLogs } from '../src/db/schema'
  12. import { matchesPrefix, normalizePrefix } from '../src/services/mcp-credentials'
  13. let app: App
  14. let upstream: http.Server
  15. let origin: string
  16. /** 上游接受的令牌(模拟更换令牌) */
  17. let accepted = 'Bearer tok-1'
  18. beforeAll(async () => {
  19. app = createApp(pino({ level: 'silent' }), { providers: {} })
  20. upstream = http.createServer(async (req, res) => {
  21. if (req.headers.authorization !== accepted) return res.writeHead(401).end()
  22. const mcp = new McpServer({ name: 'fixture', version: '1.0.0' })
  23. mcp.registerTool('echo', { description: '原样返回', inputSchema: { text: z.string() }, annotations: { readOnlyHint: true } }, async ({ text }) => ({ content: [{ type: 'text', text }] }))
  24. const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
  25. res.on('close', () => { void t.close(); void mcp.close() })
  26. await mcp.connect(t)
  27. let body = ''
  28. req.on('data', c => (body += c))
  29. req.on('end', () => void t.handleRequest(req, res, body ? JSON.parse(body) : undefined))
  30. })
  31. origin = await new Promise(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}`)))
  32. })
  33. afterAll(async () => {
  34. await app.mcpUpstream.closeAll()
  35. upstream.close()
  36. })
  37. const server = (over: Partial<McpServerInput> = {}): McpServerInput => ({
  38. name: 'orders', title: '订单', description: '', transport: 'http', enabled: true, url: `${origin}/mcp/orders`,
  39. headers: [], command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [], credentialId: null, ...over,
  40. })
  41. const cred = (over: Partial<McpCredentialInput> = {}): McpCredentialInput => ({
  42. name: '业务后台', description: '', kind: 'http', values: [{ name: 'Authorization', value: 'Bearer tok-1' }], urlPrefixes: [], ...over,
  43. })
  44. describe('适用地址:前缀匹配', () => {
  45. it('协议、主机、端口完全相同,路径按段匹配', () => {
  46. expect(matchesPrefix('https://a.com/mcp/x', 'https://a.com')).toBe(true)
  47. expect(matchesPrefix('https://a.com/mcp', 'https://a.com/mcp')).toBe(true)
  48. expect(matchesPrefix('https://a.com/mcp/orders', 'https://a.com/mcp')).toBe(true)
  49. expect(matchesPrefix('https://a.com/mcp/orders', 'https://a.com/mcp/')).toBe(true)
  50. expect(matchesPrefix('https://a.com/mcp2', 'https://a.com/mcp')).toBe(false)
  51. expect(matchesPrefix('https://a.com.evil.com/mcp', 'https://a.com')).toBe(false)
  52. expect(matchesPrefix('http://a.com/mcp', 'https://a.com')).toBe(false)
  53. expect(matchesPrefix('https://a.com:8443/mcp', 'https://a.com')).toBe(false)
  54. expect(matchesPrefix('https://A.COM/mcp', 'https://a.com')).toBe(true)
  55. })
  56. it('规范化:协议与主机小写;不接受查询参数、非 http 地址', () => {
  57. expect(normalizePrefix(' HTTPS://API.Example.com ')).toBe('https://api.example.com')
  58. expect(normalizePrefix('https://api.example.com/mcp/')).toBe('https://api.example.com/mcp/')
  59. expect(() => normalizePrefix('https://a.com/mcp?x=1')).toThrow(/查询参数/)
  60. expect(() => normalizePrefix('ftp://a.com')).toThrow(/http/)
  61. expect(() => normalizePrefix('a.com')).toThrow(/URL/)
  62. })
  63. })
  64. describe('MCP 凭据', () => {
  65. it('服务引用凭据:连接时补充请求头,凭据显示被哪些服务使用;值不返回、不进入审计', async () => {
  66. const c = app.mcpCredentials.create(cred({ urlPrefixes: [`${origin}/mcp`] }))
  67. expect(c.values).toEqual([{ name: 'Authorization', value: null, isSet: true }])
  68. expect(c.urlPrefixes).toEqual([`${origin}/mcp`])
  69. // 没有凭据时连接被拒绝;选择凭据后可以连接
  70. expect((await app.mcpServers.test(server(), null)).ok).toBe(false)
  71. expect(await app.mcpServers.test(server({ credentialId: c.id }), null)).toMatchObject({ ok: true })
  72. const s = app.mcpServers.create(server({ credentialId: c.id }))
  73. expect(s.credentialId).toBe(c.id)
  74. expect(s.headers).toEqual([])
  75. expect(app.mcpServers.rows([s.id])[0]!.headers).toEqual({ Authorization: 'Bearer tok-1' })
  76. expect((await app.mcpServers.refresh(s.id)).status).toBe('connected')
  77. expect(app.mcpCredentials.get(c.id).usedBy).toEqual([{ id: s.id, name: 'orders', title: '订单' }])
  78. // 服务自己的同名请求头优先
  79. const own = app.mcpServers.update(s.id, server({ credentialId: c.id, headers: [{ name: 'Authorization', value: 'Bearer own' }, { name: 'X-Team', value: 'a' }] }))
  80. expect(app.mcpServers.rows([own.id])[0]!.headers).toEqual({ Authorization: 'Bearer own', 'X-Team': 'a' })
  81. app.mcpServers.update(s.id, server({ credentialId: c.id }))
  82. const logs = app.db.select().from(auditLogs).all().map(l => l.summary).join('\n')
  83. expect(logs).toContain('添加 MCP 凭据 业务后台(请求头 Authorization;适用地址')
  84. expect(logs).not.toContain('tok-1')
  85. })
  86. it('适用地址与类型:地址不匹配或类型不同时不能选择;改小适用地址、改类型、删除使用中的凭据会被拒绝', () => {
  87. const c = app.mcpCredentials.list().find(x => x.name === '业务后台')!
  88. expect(() => app.mcpServers.create(server({ name: 'other', url: 'http://127.0.0.1:1/mcp', credentialId: c.id }))).toThrow(/只能用于/)
  89. expect(() => app.mcpServers.create(server({ name: 'other', url: `${origin}/mcpx`, credentialId: c.id }))).toThrow(/只能用于/)
  90. const env = app.mcpCredentials.create(cred({ name: '本地密钥', kind: 'stdio', values: [{ name: 'API_TOKEN', value: 'e1' }], urlPrefixes: ['https://ignored.com'] }))
  91. expect(env.urlPrefixes).toEqual([])
  92. expect(() => app.mcpServers.create(server({ name: 'other', credentialId: env.id }))).toThrow(/不能用于这个服务/)
  93. expect(() => app.mcpServers.create(server({ name: 'other', credentialId: 'mcc_nope' }))).toThrow(/不存在/)
  94. expect(() => app.mcpCredentials.update(c.id, cred({ values: [{ name: 'Authorization', value: null }], urlPrefixes: [`${origin}/other`] }))).toThrow(/不在新的适用地址内/)
  95. expect(() => app.mcpCredentials.update(c.id, cred({ kind: 'stdio', values: [{ name: 'TOKEN', value: 'x' }] }))).toThrow(/不能改变类型/)
  96. expect(() => app.mcpCredentials.delete(c.id)).toThrow(/正在被使用/)
  97. expect(() => app.mcpCredentials.create(cred())).toThrow(/已被使用/)
  98. expect(() => app.mcpCredentials.create(cred({ name: '空值', values: [{ name: 'Authorization', value: null }] }))).toThrow(/请填写/)
  99. expect(() => app.mcpCredentials.create(cred({ name: '坏名称', values: [{ name: 'Bad Header', value: 'x' }] }))).toThrow(/名称不正确/)
  100. // 不填适用地址:任意地址可用
  101. const any = app.mcpCredentials.create(cred({ name: '通用' }))
  102. expect(app.mcpServers.create(server({ name: 'anywhere', url: 'http://127.0.0.1:1/mcp', credentialId: any.id })).credentialId).toBe(any.id)
  103. app.mcpServers.update(app.mcpServers.list().find(x => x.name === 'anywhere')!.id, server({ name: 'anywhere', url: 'http://127.0.0.1:1/mcp', credentialId: null }))
  104. app.mcpCredentials.delete(any.id)
  105. })
  106. it('更换令牌:修改凭据后,使用它的服务按新值重新连接', async () => {
  107. const c = app.mcpCredentials.list().find(x => x.name === '业务后台')!
  108. const s = app.mcpServers.list().find(x => x.name === 'orders')!
  109. accepted = 'Bearer tok-2'
  110. expect((await app.mcpServers.refresh(s.id)).status).toBe('error')
  111. app.mcpCredentials.update(c.id, cred({ values: [{ name: 'Authorization', value: 'Bearer tok-2' }], urlPrefixes: [`${origin}/mcp`] }))
  112. expect(app.mcpServers.rows([s.id])[0]!.headers).toEqual({ Authorization: 'Bearer tok-2' })
  113. expect((await app.mcpServers.refresh(s.id)).status).toBe('connected')
  114. // 只改适用地址、值留空:沿用原值
  115. app.mcpCredentials.update(c.id, cred({ values: [{ name: 'Authorization', value: null }], urlPrefixes: [origin] }))
  116. expect(app.mcpServers.rows([s.id])[0]!.headers).toEqual({ Authorization: 'Bearer tok-2' })
  117. })
  118. it('保存为凭据:把服务自己的请求头转成凭据,服务改为引用它', async () => {
  119. const s = app.mcpServers.create(server({ name: 'devices', url: `${origin}/mcp/devices`, headers: [{ name: 'Authorization', value: 'Bearer tok-2' }] }))
  120. expect(() => app.mcpCredentials.extract(s.id, { name: '设备', urlPrefixes: ['https://elsewhere.com'] })).toThrow(/不在适用地址内/)
  121. const c = app.mcpCredentials.extract(s.id, { name: '设备', urlPrefixes: [`${origin}/mcp/`] })
  122. const after = app.mcpServers.get(s.id)
  123. expect(after).toMatchObject({ credentialId: c.id, headers: [] })
  124. expect(app.mcpServers.rows([s.id])[0]!.headers).toEqual({ Authorization: 'Bearer tok-2' })
  125. expect((await app.mcpServers.refresh(s.id)).status).toBe('connected')
  126. expect(() => app.mcpCredentials.extract(s.id, { name: '再来', urlPrefixes: [] })).toThrow(/已经在使用凭据/)
  127. })
  128. it('本地进程:凭据补充环境变量', () => {
  129. const env = app.mcpCredentials.list().find(x => x.name === '本地密钥')!
  130. const applied = app.mcpCredentials.apply({
  131. id: 'x', name: 'x', title: 'x', description: '', transport: 'stdio', enabled: true, url: '', headers: {}, command: 'node', args: [], env: { OWN: '1', API_TOKEN: 'mine' },
  132. allowNetwork: false, readPaths: [], writePaths: [], credentialId: env.id, toolPolicies: {}, toolHints: {}, backgroundTools: [], oauth: null, features: {}, toolsCache: [], toolsUpdatedAt: null, createdAt: '', updatedAt: '',
  133. })
  134. expect(applied.env).toEqual({ API_TOKEN: 'mine', OWN: '1' })
  135. const noOwn = app.mcpCredentials.apply({ ...applied, env: {} })
  136. expect(noOwn.env).toEqual({ API_TOKEN: 'e1' })
  137. })
  138. })