mcp-reference.test.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. // 参考 MCP 服务(examples/mcp-reference-server)经网关的端到端检查:示例代码与平台的支持保持一致
  2. import { spawn, type ChildProcess } from 'node:child_process'
  3. import fs from 'node:fs'
  4. import path from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import pino from 'pino'
  7. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  8. import { Client } from '@modelcontextprotocol/sdk/client/index.js'
  9. import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
  10. import { createApp, type App } from '../src/app'
  11. import { config } from '../src/config'
  12. import { buildServer } from '../src/http/server'
  13. import { taskTiming } from '../src/mcp-gateway/tasks'
  14. import type { GatewayRunContext } from '../src/mcp-gateway/tokens'
  15. import { resolveAccess } from '../src/services/access-policy'
  16. import type { NewForm } from '../src/services/interactions'
  17. const dir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../examples/mcp-reference-server')
  18. const PORT = 18_000 + Math.floor(Math.random() * 1000)
  19. let ref: ChildProcess
  20. let app: App
  21. let server: Awaited<ReturnType<typeof buildServer>>
  22. let base: string
  23. let serverId: string
  24. const forms: NewForm[] = []
  25. function runCtx(): GatewayRunContext {
  26. const ws = path.join(config.dataDir, 'workspaces', `ses_ref_${Math.random().toString(36).slice(2)}`)
  27. fs.mkdirSync(ws, { recursive: true })
  28. const settings = app.settings.get()
  29. return {
  30. sessionId: `ses_${Math.random().toString(36).slice(2)}`, runId: 'r', provider: 'claude', workspaceDir: ws, permissionMode: 'auto', settings,
  31. access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
  32. knowledgeRoots: [], mcpServerIds: [serverId], audit: () => {},
  33. interact: async req => {
  34. if (req.kind !== 'form') return { resolution: 'allowed', scope: 'once' }
  35. forms.push(req)
  36. return { resolution: 'submitted', content: { name: '王五', phone: '13800000000', address: '深圳', speed: 'express', insured: true } }
  37. },
  38. }
  39. }
  40. const text = (r: unknown) => (r as { content: { text: string }[] }).content.map(c => c.text).join('\n')
  41. Object.assign(taskTiming, { minPollMs: 50, foregroundPollMs: 50, tickMs: 50 })
  42. beforeAll(async () => {
  43. ref = spawn(process.execPath, ['server.mjs'], { cwd: dir, env: { ...process.env, PORT: String(PORT), REF_TOKEN: 'ref-test' }, stdio: ['ignore', 'pipe', 'inherit'] })
  44. await new Promise<void>((resolve, reject) => {
  45. ref.stdout!.on('data', () => resolve())
  46. ref.on('exit', code => reject(new Error(`参考服务退出:${code}`)))
  47. })
  48. const log = pino({ level: 'silent' })
  49. app = createApp(log, { providers: {} })
  50. server = await buildServer(app, { logger: log })
  51. base = await server.listen({ port: 0, host: '127.0.0.1' })
  52. serverId = app.mcpServers.create({
  53. name: 'orders', title: '订单系统', description: '', transport: 'http', enabled: true, url: `http://127.0.0.1:${PORT}/mcp`,
  54. headers: [{ name: 'Authorization', value: 'Bearer ref-test' }], command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [],
  55. }).id
  56. })
  57. afterAll(async () => {
  58. app.mcpTasks.stop()
  59. app.backgroundCalls.stop()
  60. await app.mcpUpstream.closeAll()
  61. await server.close()
  62. ref.kill()
  63. })
  64. describe('参考 MCP 服务', () => {
  65. it('工具提示、结构化输出、询问用户、长任务、资源与提示模板都按指南工作', async () => {
  66. const s = await app.mcpServers.refresh(serverId)
  67. expect(s.lastError).toBeNull()
  68. expect(s.features).toEqual({ resources: true, prompts: true })
  69. const tool = (n: string) => s.tools.find(t => t.name === n)!
  70. expect(tool('get_order')).toMatchObject({ readOnly: true, policy: 'auto', hasOutputSchema: true, openWorld: false })
  71. expect(tool('cancel_order')).toMatchObject({ destructive: true, idempotent: true, policy: 'confirm' })
  72. expect(tool('add_note')).toMatchObject({ destructive: false, idempotent: false, policy: 'ask' })
  73. expect(tool('export_report')).toMatchObject({ taskSupport: 'optional' })
  74. expect(tool('purge_cancelled')).toMatchObject({ taskSupport: 'required', destructive: true })
  75. const session = await app.sessions.create('admin', {})
  76. const ctx = { ...runCtx(), sessionId: session.id }
  77. const c = new Client({ name: 'test', version: '1' })
  78. await c.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${app.gatewayTokens.issue(ctx)}` } } }))
  79. expect(JSON.parse(text(await c.callTool({ name: 'orders__get_order', arguments: { orderId: 'A1001' } })))).toMatchObject({ customer: '张三', amount: 1299 })
  80. expect(text(await c.callTool({ name: 'orders__get_order', arguments: { orderId: 'A9999' } }))).toContain('可以先用 list_orders')
  81. expect(text(await c.callTool({ name: 'orders__ship_order', arguments: { orderId: 'A1001' } }))).toContain('"name":"王五"')
  82. expect(forms[0]!.fields.map(f => f.name)).toEqual(['name', 'phone', 'address', 'speed', 'insured'])
  83. // 很快完成的长任务在前台返回,导出的 CSV 存入工作区
  84. const exported = text(await c.callTool({ name: 'orders__export_report', arguments: { month: '2026-10', seconds: 1 } }, undefined, { timeout: 30_000 }))
  85. expect(exported).toContain('2026-10 销售报表已生成')
  86. expect(exported).toMatch(/已保存到 \.agent\/mcp\/sales-2026-10\.csv/)
  87. // 长任务中询问用户:卡片属于会话(不属于某一轮)
  88. await c.callTool({ name: 'orders__cancel_order', arguments: { orderId: 'A1002', reason: '测试' } })
  89. const purge = c.callTool({ name: 'orders__purge_cancelled', arguments: {} }, undefined, { timeout: 30_000 })
  90. await expect.poll(() => app.interactions.pendingFor(session.id).length, { timeout: 10_000 }).toBe(1)
  91. const card = app.interactions.pendingFor(session.id)[0]!
  92. expect(card.kind === 'form' && card.message).toContain('将永久删除 1 个已取消订单:A1002')
  93. app.interactions.respond(card.interactionId, { action: 'accept', content: { confirm: true } })
  94. expect(text(await purge)).toBe('已删除 1 个订单')
  95. const tools = (await c.listTools()).tools.map(t => t.name)
  96. expect(tools).toEqual(expect.arrayContaining(['mcp_resources', 'mcp_resource_read']))
  97. expect(text(await c.callTool({ name: 'mcp_resource_read', arguments: { server: 'orders', uri: 'ref://docs/order-api.md' } }))).toContain('退款规则')
  98. expect(text(await c.callTool({ name: 'mcp_resource_read', arguments: { server: 'orders', uri: 'ref://orders/A1001' } }))).toContain('"customer": "张三"')
  99. await c.close()
  100. const prompts = await app.mcpContent.listPrompts([serverId])
  101. expect(prompts.prompts.map(p => p.name)).toEqual(['order_review'])
  102. }, 60_000)
  103. })