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