| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154 |
- // MCP 凭据(docs/design/20-mcp-credentials.md):服务引用凭据、适用地址的前缀匹配、修改后按新值连接、保存为凭据
- import http from 'node:http'
- import type { AddressInfo } from 'node:net'
- import pino from 'pino'
- import { afterAll, beforeAll, describe, expect, it } from 'vitest'
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
- import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
- import { z } from 'zod'
- import type { McpCredentialInput, McpServerInput } from '@agent-server/shared'
- import { createApp, type App } from '../src/app'
- import { auditLogs } from '../src/db/schema'
- import { matchesPrefix, normalizePrefix } from '../src/services/mcp-credentials'
- let app: App
- let upstream: http.Server
- let origin: string
- /** 上游接受的令牌(模拟更换令牌) */
- let accepted = 'Bearer tok-1'
- beforeAll(async () => {
- app = createApp(pino({ level: 'silent' }), { providers: {} })
- upstream = http.createServer(async (req, res) => {
- if (req.headers.authorization !== accepted) return res.writeHead(401).end()
- const mcp = new McpServer({ name: 'fixture', version: '1.0.0' })
- mcp.registerTool('echo', { description: '原样返回', inputSchema: { text: z.string() }, annotations: { readOnlyHint: true } }, async ({ text }) => ({ content: [{ type: 'text', text }] }))
- const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
- res.on('close', () => { void t.close(); void mcp.close() })
- await mcp.connect(t)
- let body = ''
- req.on('data', c => (body += c))
- req.on('end', () => void t.handleRequest(req, res, body ? JSON.parse(body) : undefined))
- })
- origin = await new Promise(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}`)))
- })
- afterAll(async () => {
- await app.mcpUpstream.closeAll()
- upstream.close()
- })
- const server = (over: Partial<McpServerInput> = {}): McpServerInput => ({
- name: 'orders', title: '订单', description: '', transport: 'http', enabled: true, url: `${origin}/mcp/orders`,
- headers: [], command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [], credentialId: null, ...over,
- })
- const cred = (over: Partial<McpCredentialInput> = {}): McpCredentialInput => ({
- name: '业务后台', description: '', kind: 'http', values: [{ name: 'Authorization', value: 'Bearer tok-1' }], urlPrefixes: [], ...over,
- })
- describe('适用地址:前缀匹配', () => {
- it('协议、主机、端口完全相同,路径按段匹配', () => {
- expect(matchesPrefix('https://a.com/mcp/x', 'https://a.com')).toBe(true)
- expect(matchesPrefix('https://a.com/mcp', 'https://a.com/mcp')).toBe(true)
- expect(matchesPrefix('https://a.com/mcp/orders', 'https://a.com/mcp')).toBe(true)
- expect(matchesPrefix('https://a.com/mcp/orders', 'https://a.com/mcp/')).toBe(true)
- expect(matchesPrefix('https://a.com/mcp2', 'https://a.com/mcp')).toBe(false)
- expect(matchesPrefix('https://a.com.evil.com/mcp', 'https://a.com')).toBe(false)
- expect(matchesPrefix('http://a.com/mcp', 'https://a.com')).toBe(false)
- expect(matchesPrefix('https://a.com:8443/mcp', 'https://a.com')).toBe(false)
- expect(matchesPrefix('https://A.COM/mcp', 'https://a.com')).toBe(true)
- })
- it('规范化:协议与主机小写;不接受查询参数、非 http 地址', () => {
- expect(normalizePrefix(' HTTPS://API.Example.com ')).toBe('https://api.example.com')
- expect(normalizePrefix('https://api.example.com/mcp/')).toBe('https://api.example.com/mcp/')
- expect(() => normalizePrefix('https://a.com/mcp?x=1')).toThrow(/查询参数/)
- expect(() => normalizePrefix('ftp://a.com')).toThrow(/http/)
- expect(() => normalizePrefix('a.com')).toThrow(/URL/)
- })
- })
- describe('MCP 凭据', () => {
- it('服务引用凭据:连接时补充请求头,凭据显示被哪些服务使用;值不返回、不进入审计', async () => {
- const c = app.mcpCredentials.create(cred({ urlPrefixes: [`${origin}/mcp`] }))
- expect(c.values).toEqual([{ name: 'Authorization', value: null, isSet: true }])
- expect(c.urlPrefixes).toEqual([`${origin}/mcp`])
- // 没有凭据时连接被拒绝;选择凭据后可以连接
- expect((await app.mcpServers.test(server(), null)).ok).toBe(false)
- expect(await app.mcpServers.test(server({ credentialId: c.id }), null)).toMatchObject({ ok: true })
- const s = app.mcpServers.create(server({ credentialId: c.id }))
- expect(s.credentialId).toBe(c.id)
- expect(s.headers).toEqual([])
- expect(app.mcpServers.rows([s.id])[0]!.headers).toEqual({ Authorization: 'Bearer tok-1' })
- expect((await app.mcpServers.refresh(s.id)).status).toBe('connected')
- expect(app.mcpCredentials.get(c.id).usedBy).toEqual([{ id: s.id, name: 'orders', title: '订单' }])
- // 服务自己的同名请求头优先
- const own = app.mcpServers.update(s.id, server({ credentialId: c.id, headers: [{ name: 'Authorization', value: 'Bearer own' }, { name: 'X-Team', value: 'a' }] }))
- expect(app.mcpServers.rows([own.id])[0]!.headers).toEqual({ Authorization: 'Bearer own', 'X-Team': 'a' })
- app.mcpServers.update(s.id, server({ credentialId: c.id }))
- const logs = app.db.select().from(auditLogs).all().map(l => l.summary).join('\n')
- expect(logs).toContain('添加 MCP 凭据 业务后台(请求头 Authorization;适用地址')
- expect(logs).not.toContain('tok-1')
- })
- it('适用地址与类型:地址不匹配或类型不同时不能选择;改小适用地址、改类型、删除使用中的凭据会被拒绝', () => {
- const c = app.mcpCredentials.list().find(x => x.name === '业务后台')!
- expect(() => app.mcpServers.create(server({ name: 'other', url: 'http://127.0.0.1:1/mcp', credentialId: c.id }))).toThrow(/只能用于/)
- expect(() => app.mcpServers.create(server({ name: 'other', url: `${origin}/mcpx`, credentialId: c.id }))).toThrow(/只能用于/)
- const env = app.mcpCredentials.create(cred({ name: '本地密钥', kind: 'stdio', values: [{ name: 'API_TOKEN', value: 'e1' }], urlPrefixes: ['https://ignored.com'] }))
- expect(env.urlPrefixes).toEqual([])
- expect(() => app.mcpServers.create(server({ name: 'other', credentialId: env.id }))).toThrow(/不能用于这个服务/)
- expect(() => app.mcpServers.create(server({ name: 'other', credentialId: 'mcc_nope' }))).toThrow(/不存在/)
- expect(() => app.mcpCredentials.update(c.id, cred({ values: [{ name: 'Authorization', value: null }], urlPrefixes: [`${origin}/other`] }))).toThrow(/不在新的适用地址内/)
- expect(() => app.mcpCredentials.update(c.id, cred({ kind: 'stdio', values: [{ name: 'TOKEN', value: 'x' }] }))).toThrow(/不能改变类型/)
- expect(() => app.mcpCredentials.delete(c.id)).toThrow(/正在被使用/)
- expect(() => app.mcpCredentials.create(cred())).toThrow(/已被使用/)
- expect(() => app.mcpCredentials.create(cred({ name: '空值', values: [{ name: 'Authorization', value: null }] }))).toThrow(/请填写/)
- expect(() => app.mcpCredentials.create(cred({ name: '坏名称', values: [{ name: 'Bad Header', value: 'x' }] }))).toThrow(/名称不正确/)
- // 不填适用地址:任意地址可用
- const any = app.mcpCredentials.create(cred({ name: '通用' }))
- expect(app.mcpServers.create(server({ name: 'anywhere', url: 'http://127.0.0.1:1/mcp', credentialId: any.id })).credentialId).toBe(any.id)
- 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 }))
- app.mcpCredentials.delete(any.id)
- })
- it('更换令牌:修改凭据后,使用它的服务按新值重新连接', async () => {
- const c = app.mcpCredentials.list().find(x => x.name === '业务后台')!
- const s = app.mcpServers.list().find(x => x.name === 'orders')!
- accepted = 'Bearer tok-2'
- expect((await app.mcpServers.refresh(s.id)).status).toBe('error')
- app.mcpCredentials.update(c.id, cred({ values: [{ name: 'Authorization', value: 'Bearer tok-2' }], urlPrefixes: [`${origin}/mcp`] }))
- expect(app.mcpServers.rows([s.id])[0]!.headers).toEqual({ Authorization: 'Bearer tok-2' })
- expect((await app.mcpServers.refresh(s.id)).status).toBe('connected')
- // 只改适用地址、值留空:沿用原值
- app.mcpCredentials.update(c.id, cred({ values: [{ name: 'Authorization', value: null }], urlPrefixes: [origin] }))
- expect(app.mcpServers.rows([s.id])[0]!.headers).toEqual({ Authorization: 'Bearer tok-2' })
- })
- it('保存为凭据:把服务自己的请求头转成凭据,服务改为引用它', async () => {
- const s = app.mcpServers.create(server({ name: 'devices', url: `${origin}/mcp/devices`, headers: [{ name: 'Authorization', value: 'Bearer tok-2' }] }))
- expect(() => app.mcpCredentials.extract(s.id, { name: '设备', urlPrefixes: ['https://elsewhere.com'] })).toThrow(/不在适用地址内/)
- const c = app.mcpCredentials.extract(s.id, { name: '设备', urlPrefixes: [`${origin}/mcp/`] })
- const after = app.mcpServers.get(s.id)
- expect(after).toMatchObject({ credentialId: c.id, headers: [] })
- expect(app.mcpServers.rows([s.id])[0]!.headers).toEqual({ Authorization: 'Bearer tok-2' })
- expect((await app.mcpServers.refresh(s.id)).status).toBe('connected')
- expect(() => app.mcpCredentials.extract(s.id, { name: '再来', urlPrefixes: [] })).toThrow(/已经在使用凭据/)
- })
- it('本地进程:凭据补充环境变量', () => {
- const env = app.mcpCredentials.list().find(x => x.name === '本地密钥')!
- const applied = app.mcpCredentials.apply({
- id: 'x', name: 'x', title: 'x', description: '', transport: 'stdio', enabled: true, url: '', headers: {}, command: 'node', args: [], env: { OWN: '1', API_TOKEN: 'mine' },
- allowNetwork: false, readPaths: [], writePaths: [], credentialId: env.id, toolPolicies: {}, toolHints: {}, backgroundTools: [], oauth: null, features: {}, toolsCache: [], toolsUpdatedAt: null, createdAt: '', updatedAt: '',
- })
- expect(applied.env).toEqual({ API_TOKEN: 'mine', OWN: '1' })
- const noOwn = app.mcpCredentials.apply({ ...applied, env: {} })
- expect(noOwn.env).toEqual({ API_TOKEN: 'e1' })
- })
- })
|