mcp.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import fs from 'node:fs'
  2. import http from 'node:http'
  3. import type { AddressInfo } from 'node:net'
  4. import os from 'node:os'
  5. import path from 'node:path'
  6. import { fileURLToPath } from 'node:url'
  7. import pino from 'pino'
  8. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  9. import { Client } from '@modelcontextprotocol/sdk/client/index.js'
  10. import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
  11. import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
  12. import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
  13. import { z } from 'zod'
  14. import type { McpServerInput } from '@agent-server/shared'
  15. import { createApp, type App } from '../src/app'
  16. import { config } from '../src/config'
  17. import { buildServer } from '../src/http/server'
  18. import type { GatewayRunContext } from '../src/mcp-gateway/tokens'
  19. import { BWRAP } from '../src/mcp-gateway/upstream'
  20. import { resolveAccess } from '../src/services/access-policy'
  21. import type { InteractionOutcome, NewApproval } from '../src/services/interactions'
  22. const here = path.dirname(fileURLToPath(import.meta.url))
  23. let app: App
  24. let server: Awaited<ReturnType<typeof buildServer>>
  25. let base: string
  26. let upstream: http.Server
  27. let upstreamUrl: string
  28. const seenAuth: (string | undefined)[] = []
  29. /** 测试用的远程 MCP 服务(Streamable HTTP,无状态) */
  30. function startUpstream(): Promise<string> {
  31. upstream = http.createServer(async (req, res) => {
  32. seenAuth.push(req.headers.authorization)
  33. if (req.headers.authorization !== 'Bearer up-secret') return res.writeHead(401).end()
  34. const mcp = new McpServer({ name: 'fixture-http', version: '1.0.0' })
  35. mcp.registerTool('echo', { description: '原样返回', inputSchema: { text: z.string() }, annotations: { readOnlyHint: true } }, async ({ text }) => ({ content: [{ type: 'text', text: `echo:${text}` }] }))
  36. mcp.registerTool('write_note', { description: '写一条笔记', inputSchema: { note: z.string() } }, async ({ note }) => ({ content: [{ type: 'text', text: `saved:${note}` }] }))
  37. mcp.registerTool('big', { description: '很长的结果', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => ({ content: [{ type: 'text', text: 'x'.repeat(150_000) }] }))
  38. const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
  39. res.on('close', () => { void t.close(); void mcp.close() })
  40. await mcp.connect(t)
  41. let body = ''
  42. req.on('data', c => (body += c))
  43. req.on('end', () => void t.handleRequest(req, res, body ? JSON.parse(body) : undefined))
  44. })
  45. return new Promise(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}/mcp`)))
  46. }
  47. const httpInput = (over: Partial<McpServerInput> = {}): McpServerInput => ({
  48. name: 'demo', title: '演示服务', description: '', transport: 'http', enabled: true, url: upstreamUrl,
  49. headers: [{ name: 'Authorization', value: 'Bearer up-secret' }], command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [], ...over,
  50. })
  51. const approvals: NewApproval[] = []
  52. let answer: InteractionOutcome = { resolution: 'allowed', scope: 'once' }
  53. function runCtx(over: Partial<GatewayRunContext> = {}): GatewayRunContext {
  54. const ws = path.join(config.dataDir, 'workspaces', `ses_mcp_${Math.random().toString(36).slice(2)}`)
  55. fs.mkdirSync(ws, { recursive: true })
  56. const settings = app.settings.get()
  57. return {
  58. sessionId: `ses_${Math.random().toString(36).slice(2)}`, runId: 'r', provider: 'claude', workspaceDir: ws, permissionMode: 'ask', settings,
  59. access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
  60. knowledgeRoots: [], mcpServerIds: [], audit: () => {},
  61. interact: async req => { approvals.push(req); return answer },
  62. ...over,
  63. }
  64. }
  65. async function gateway(ctx: GatewayRunContext) {
  66. const client = new Client({ name: 'test', version: '1' })
  67. await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${app.gatewayTokens.issue(ctx)}` } } }))
  68. return client
  69. }
  70. const text = (r: unknown) => ((r as { content: { text: string }[] }).content[0]!.text)
  71. beforeAll(async () => {
  72. const log = pino({ level: 'silent' })
  73. app = createApp(log, { providers: {} })
  74. server = await buildServer(app, { logger: log })
  75. base = await server.listen({ port: 0, host: '127.0.0.1' })
  76. upstreamUrl = await startUpstream()
  77. })
  78. afterAll(async () => {
  79. await app.mcpUpstream.closeAll()
  80. await server.close()
  81. upstream.close()
  82. })
  83. describe('第三方 MCP(远程 HTTP)', () => {
  84. it('测试连接、登记;密钥不返回,更新时可沿用', async () => {
  85. const t = await app.mcpServers.test(httpInput(), null)
  86. expect(t).toMatchObject({ ok: true })
  87. expect(t.tools.map(x => [x.name, x.readOnly, x.policy])).toEqual([['echo', true, 'auto'], ['write_note', false, 'ask'], ['big', true, 'auto']])
  88. const bad = await app.mcpServers.test(httpInput({ headers: [{ name: 'Authorization', value: 'Bearer wrong' }] }), null)
  89. expect(bad.ok).toBe(false)
  90. const s = app.mcpServers.create(httpInput())
  91. expect(s.headers).toEqual([{ name: 'Authorization', value: null, isSet: true }])
  92. expect(JSON.stringify(s)).not.toContain('up-secret')
  93. const updated = app.mcpServers.update(s.id, { ...httpInput({ title: '演示服务2' }), headers: [{ name: 'Authorization', value: null }] })
  94. expect(updated.title).toBe('演示服务2')
  95. expect((await app.mcpServers.refresh(s.id)).tools).toHaveLength(3)
  96. expect(() => app.mcpServers.create(httpInput())).toThrow(/已被使用/)
  97. expect(() => app.mcpServers.create(httpInput({ name: 'Bad Name' }))).toThrow(/名称/)
  98. expect(() => app.mcpServers.create(httpInput({ name: 'x2', headers: [{ name: 'Authorization', value: null }] }))).toThrow(/请填写/)
  99. })
  100. it('经网关转接:只提供 Profile 勾选的服务;只读工具直接调用,其余审批;只读模式只列只读工具', async () => {
  101. const id = app.mcpServers.list().find(s => s.name === 'demo')!.id
  102. const none = await gateway(runCtx())
  103. expect((await none.listTools()).tools.map(t => t.name)).not.toContain('demo__echo')
  104. await none.close()
  105. const ctx = runCtx({ mcpServerIds: [id] })
  106. const c = await gateway(ctx)
  107. const names = (await c.listTools()).tools.map(t => t.name)
  108. expect(names).toEqual(expect.arrayContaining(['convert_document', 'web_fetch', 'demo__echo', 'demo__write_note', 'demo__big']))
  109. expect(text(await c.callTool({ name: 'demo__echo', arguments: { text: '你好' } }))).toBe('echo:你好')
  110. expect(approvals).toHaveLength(0)
  111. answer = { resolution: 'denied', reason: '不需要' }
  112. const denied = await c.callTool({ name: 'demo__write_note', arguments: { note: 'a' } })
  113. expect(denied.isError).toBe(true)
  114. expect(text(denied)).toMatch(/拒绝.*不需要/)
  115. expect(approvals[0]).toMatchObject({ kind: 'approval', tool: 'mcp__gateway__demo__write_note', title: '演示服务2 · write_note', input: { note: 'a' } })
  116. answer = { resolution: 'allowed', scope: 'session' }
  117. expect(text(await c.callTool({ name: 'demo__write_note', arguments: { note: 'b' } }))).toBe('saved:b')
  118. expect(text(await c.callTool({ name: 'demo__write_note', arguments: { note: 'c' } }))).toBe('saved:c')
  119. expect(approvals).toHaveLength(2) // 本会话允许后不再询问
  120. const big = text(await c.callTool({ name: 'demo__big', arguments: {} }))
  121. expect(big.length).toBeLessThan(101_000)
  122. const saved = /保存到 (\S+))/.exec(big)![1]!
  123. expect(fs.readFileSync(path.join(ctx.workspaceDir, saved), 'utf8')).toHaveLength(150_000)
  124. await c.close()
  125. const ro = await gateway(runCtx({ mcpServerIds: [id], permissionMode: 'readonly' }))
  126. const roNames = (await ro.listTools()).tools.map(t => t.name)
  127. expect(roNames).toContain('demo__echo')
  128. expect(roNames).not.toContain('demo__write_note')
  129. expect((await ro.callTool({ name: 'demo__write_note', arguments: { note: 'x' } })).isError).toBe(true)
  130. await ro.close()
  131. // 自动模式不询问;关闭的工具不提供
  132. const before = approvals.length
  133. const auto = await gateway(runCtx({ mcpServerIds: [id], permissionMode: 'auto' }))
  134. expect(text(await auto.callTool({ name: 'demo__write_note', arguments: { note: 'd' } }))).toBe('saved:d')
  135. expect(approvals).toHaveLength(before)
  136. app.mcpServers.setToolPolicy(id, 'echo', 'off')
  137. expect((await auto.listTools()).tools.map(t => t.name)).not.toContain('demo__echo')
  138. await auto.close()
  139. expect(seenAuth.every(a => a === 'Bearer up-secret' || a === 'Bearer wrong')).toBe(true)
  140. })
  141. it('Profile 引用 MCP 服务;删除服务时从 Profile 中移除', async () => {
  142. const id = app.mcpServers.list().find(s => s.name === 'demo')!.id
  143. const { id: _i, version: _v, updatedAt: _u, builtin: _b, ...input } = app.profiles.getBuiltin()
  144. const p = app.profiles.create({ ...input, name: 'MCP 测试', isDefault: false, mcpServerIds: [id] })
  145. expect(app.mcpServers.get(id).profileNames).toEqual(['MCP 测试'])
  146. expect(() => app.profiles.create({ ...input, name: 'x', isDefault: false, mcpServerIds: ['mcp_none'] })).toThrow(/不存在/)
  147. await app.mcpServers.delete(id)
  148. expect(app.profiles.get(p.id).mcpServerIds).toEqual([])
  149. })
  150. })
  151. describe.skipIf(!BWRAP)('第三方 MCP(本地进程,沙箱)', () => {
  152. it('在沙箱中运行:密钥经环境变量传入、独立 home、只能读开放的路径', async () => {
  153. const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-outside-'))
  154. fs.writeFileSync(path.join(outside, 'secret.txt'), 'TOP-SECRET')
  155. const input: McpServerInput = {
  156. name: 'local', title: '本地服务', description: '', transport: 'stdio', enabled: true, url: '', headers: [],
  157. command: process.execPath, args: [path.join(here, 'fixtures-mcp-stdio.mjs')],
  158. env: [{ name: 'FIXTURE_SECRET', value: 's3cr3t' }], allowNetwork: false,
  159. // 测试脚本与依赖所在目录(只读)
  160. readPaths: [here, path.resolve(here, '..', 'node_modules'), path.resolve(here, '../../../node_modules'), path.dirname(process.execPath)], writePaths: [],
  161. }
  162. const s = app.mcpServers.create(input)
  163. expect(s.env).toEqual([{ name: 'FIXTURE_SECRET', value: null, isSet: true }])
  164. const refreshed = await app.mcpServers.refresh(s.id)
  165. expect(refreshed.lastError).toBeNull()
  166. expect(refreshed.tools.map(t => t.name)).toEqual(['whoami', 'read_file'])
  167. const c = await gateway(runCtx({ mcpServerIds: [s.id] }))
  168. const who = JSON.parse(text(await c.callTool({ name: 'local__whoami', arguments: {} })))
  169. expect(who.secret).toBe('s3cr3t')
  170. expect(who.home).toMatch(/data\/mcp\/mcp_/)
  171. const denied = await c.callTool({ name: 'local__read_file', arguments: { path: path.join(outside, 'secret.txt') } })
  172. expect(text(denied)).toMatch(/ERR ENOENT/)
  173. expect(text(await c.callTool({ name: 'local__read_file', arguments: { path: path.join(here, 'setup.ts') } }))).toContain('AGENT_SERVER_DATA_DIR')
  174. await c.close()
  175. expect(() => app.mcpServers.create({ ...input, name: 'bad1', readPaths: [config.dataDir] })).toThrow(/服务数据目录/)
  176. expect(() => app.mcpServers.create({ ...input, name: 'bad2', readPaths: ['relative/path'] })).toThrow(/绝对路径/)
  177. }, 60_000)
  178. })