// 参考 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> 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((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) })