gateway.test.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. import fs from 'node:fs'
  2. import http from 'node:http'
  3. import path from 'node:path'
  4. import type { AddressInfo } from 'node:net'
  5. import { Client } from '@modelcontextprotocol/sdk/client/index.js'
  6. import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
  7. import pino from 'pino'
  8. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  9. import { createApp, type App } from '../src/app'
  10. import { buildServer } from '../src/http/server'
  11. import type { GatewayRunContext } from '../src/mcp-gateway/tokens'
  12. import { resolveAccess } from '../src/services/access-policy'
  13. import { config } from '../src/config'
  14. let app: App
  15. let server: Awaited<ReturnType<typeof buildServer>>
  16. let base: string
  17. let site: http.Server
  18. let siteUrl: string
  19. const audits: string[] = []
  20. function runCtx(over: Partial<GatewayRunContext> = {}): GatewayRunContext {
  21. const ws = path.join(config.dataDir, 'workspaces', `ses_gw_${Math.random().toString(36).slice(2)}`)
  22. fs.mkdirSync(path.join(ws, 'outputs'), { recursive: true })
  23. const settings = app.settings.get()
  24. return {
  25. sessionId: 's', runId: 'r', provider: 'codex', workspaceDir: ws, permissionMode: 'ask', settings,
  26. access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
  27. knowledgeRoots: [{ id: 'kb', name: '账单', path: '/data/bills', description: '家庭账单', access: 'read', enabled: true, check: { exists: true, readable: true, isDirectory: true, denied: null, underHome: false, message: null } }],
  28. mcpServerIds: [],
  29. audit: (kind, summary) => audits.push(`${kind}:${summary}`),
  30. interact: async () => ({ resolution: 'allowed', scope: 'once' }),
  31. ...over,
  32. }
  33. }
  34. async function connect(token: string) {
  35. const client = new Client({ name: 'test', version: '0' })
  36. await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${token}` } } }))
  37. return client
  38. }
  39. const text = (r: Awaited<ReturnType<Client['callTool']>>) => (r.content as { text: string }[]).map(c => c.text).join('\n')
  40. beforeAll(async () => {
  41. const log = pino({ level: 'silent' })
  42. app = createApp(log, { providers: {} })
  43. server = await buildServer(app, { logger: log })
  44. await server.listen({ host: '127.0.0.1', port: 0 })
  45. base = `http://127.0.0.1:${(server.server.address() as AddressInfo).port}`
  46. site = http.createServer((_req, res) => {
  47. res.setHeader('content-type', 'text/html; charset=utf-8')
  48. res.end('<html><head><script>evil()</script></head><body><h1>标题</h1><p>正文 <b>加粗</b></p></body></html>')
  49. })
  50. await new Promise<void>(r => site.listen(0, '127.0.0.1', r))
  51. siteUrl = `http://localhost:${(site.address() as AddressInfo).port}/page`
  52. })
  53. afterAll(async () => {
  54. await server.close()
  55. site.close()
  56. })
  57. describe('MCP Gateway', () => {
  58. it('无效令牌被拒绝', async () => {
  59. const res = await fetch(`${base}/mcp`, { method: 'POST', headers: { authorization: 'Bearer nope', 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, body: '{}' })
  60. expect(res.status).toBe(401)
  61. await expect(connect('nope')).rejects.toThrow()
  62. })
  63. it('列出工具;资料库列表;文档转换写入工作区', async () => {
  64. const ctx = runCtx()
  65. const client = await connect(app.gatewayTokens.issue(ctx))
  66. const tools = (await client.listTools()).tools.map(t => t.name).sort()
  67. expect(tools).toEqual(['convert_document', 'kb_list_roots', 'recalc_workbook', 'web_fetch'])
  68. expect(text(await client.callTool({ name: 'kb_list_roots', arguments: {} }))).toContain('账单:/data/bills(只读)')
  69. fs.writeFileSync(path.join(ctx.workspaceDir, 'a.txt'), '你好')
  70. const r = await client.callTool({ name: 'convert_document', arguments: { input: 'a.txt', format: 'pdf' } })
  71. expect(text(r)).toBe('已生成:outputs/a.pdf')
  72. expect(fs.existsSync(path.join(ctx.workspaceDir, 'outputs/a.pdf'))).toBe(true)
  73. expect(audits.some(a => a === 'gateway_call:convert_document')).toBe(true)
  74. await client.close()
  75. }, 60_000)
  76. it('只读模式不能生成文件;工作区外的输入被拒绝', async () => {
  77. const ctx = runCtx({ permissionMode: 'readonly' })
  78. const client = await connect(app.gatewayTokens.issue(ctx))
  79. const r = await client.callTool({ name: 'convert_document', arguments: { input: 'a.txt', format: 'pdf' } })
  80. expect(r.isError).toBe(true)
  81. expect(text(r)).toContain('只读模式')
  82. const ctx2 = runCtx()
  83. const c2 = await connect(app.gatewayTokens.issue(ctx2))
  84. const r2 = await c2.callTool({ name: 'convert_document', arguments: { input: '/etc/hostname', format: 'pdf' } })
  85. expect(text(r2)).toContain('工作区内')
  86. await client.close()
  87. await c2.close()
  88. })
  89. it('web_fetch 拦截内网地址(包括解析到内网的域名),放开后能转成 Markdown', async () => {
  90. const ctx = runCtx()
  91. const client = await connect(app.gatewayTokens.issue(ctx))
  92. const blocked = await client.callTool({ name: 'web_fetch', arguments: { url: siteUrl } })
  93. expect(blocked.isError).toBe(true)
  94. expect(text(blocked)).toMatch(/不允许访问内网地址:localhost/)
  95. expect(audits.some(a => a.startsWith('network_denied:'))).toBe(true)
  96. await client.close()
  97. const open = runCtx({ settings: { ...app.settings.get(), network: { ...app.settings.get().network, blockPrivateFetch: false } } })
  98. const c2 = await connect(app.gatewayTokens.issue(open))
  99. const r = text(await c2.callTool({ name: 'web_fetch', arguments: { url: siteUrl } }))
  100. expect(r).toContain('# 标题')
  101. expect(r).toContain('正文 **加粗**')
  102. expect(r).not.toContain('evil')
  103. await c2.close()
  104. })
  105. it('令牌作废后不能再调用', async () => {
  106. const token = app.gatewayTokens.issue(runCtx())
  107. app.gatewayTokens.revoke(token)
  108. await expect(connect(token)).rejects.toThrow()
  109. })
  110. it('Codex 会话使用固定令牌:每次 Run 重新绑定,Run 之间失效', () => {
  111. const reg = app.gatewayTokens
  112. const t1 = reg.issue(runCtx({ sessionId: 'ses_fixed', runId: 'r1' }), { stablePerSession: true })
  113. reg.revoke(t1)
  114. expect(reg.get(t1)).toBeNull()
  115. const t2 = reg.issue(runCtx({ sessionId: 'ses_fixed', runId: 'r2' }), { stablePerSession: true })
  116. expect(t2).toBe(t1)
  117. expect(reg.get(t2)?.runId).toBe('r2')
  118. // 其他会话、非固定令牌各不相同
  119. expect(reg.issue(runCtx({ sessionId: 'ses_other' }), { stablePerSession: true })).not.toBe(t1)
  120. expect(reg.issue(runCtx({ sessionId: 'ses_fixed' }))).not.toBe(t1)
  121. reg.forgetSession('ses_fixed')
  122. expect(reg.get(t1)).toBeNull()
  123. expect(reg.issue(runCtx({ sessionId: 'ses_fixed' }), { stablePerSession: true })).not.toBe(t1)
  124. })
  125. })