// 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 => ({ 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 => ({ 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' }) }) })