mcp-elicitation.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. // MCP 支持完善 · 阶段 C:外部服务向用户询问信息(Elicitation,docs/design/16-mcp-support.md §4)
  2. import crypto from 'node:crypto'
  3. import fs from 'node:fs'
  4. import http from 'node:http'
  5. import type { AddressInfo } from 'node:net'
  6. import path from 'node:path'
  7. import pino from 'pino'
  8. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  9. import { z } from 'zod'
  10. import { Client } from '@modelcontextprotocol/sdk/client/index.js'
  11. import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
  12. import { InMemoryTaskMessageQueue, InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js'
  13. import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
  14. import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
  15. import type { ElicitResult } from '@modelcontextprotocol/sdk/types.js'
  16. import type { InteractionFormRequest } from '@agent-server/shared'
  17. import { createApp, type App } from '../src/app'
  18. import { config } from '../src/config'
  19. import { schemaToFields } from '../src/mcp-gateway/elicitation'
  20. import { buildServer } from '../src/http/server'
  21. import { taskTiming } from '../src/mcp-gateway/tasks'
  22. import type { GatewayRunContext } from '../src/mcp-gateway/tokens'
  23. import { resolveAccess } from '../src/services/access-policy'
  24. import { validateFormContent, type InteractionOutcome, type NewForm } from '../src/services/interactions'
  25. let app: App
  26. let server: Awaited<ReturnType<typeof buildServer>>
  27. let base: string
  28. let upstream: http.Server
  29. let serverId: string
  30. const SCHEMA = {
  31. type: 'object' as const,
  32. properties: {
  33. name: { type: 'string' as const, title: '收件人', minLength: 1 },
  34. email: { type: 'string' as const, title: '邮箱', format: 'email' as const },
  35. count: { type: 'integer' as const, title: '数量', minimum: 1, maximum: 10, default: 1 },
  36. urgent: { type: 'boolean' as const, title: '加急' },
  37. color: { type: 'string' as const, title: '颜色', oneOf: [{ const: 'red', title: '红' }, { const: 'blue', title: '蓝' }] },
  38. tags: { type: 'array' as const, title: '标签', items: { type: 'string' as const, enum: ['a', 'b', 'c'] }, maxItems: 2 },
  39. },
  40. required: ['name', 'color'],
  41. }
  42. /** 有状态的测试服务:调用过程中询问用户;长任务中询问用户(任务按 MCP 会话归属) */
  43. const taskStore = new InMemoryTaskStore()
  44. function startUpstream(): Promise<string> {
  45. const transports = new Map<string, StreamableHTTPServerTransport>()
  46. const build = () => {
  47. const mcp = new McpServer({ name: 'ask', version: '1.0.0' }, { capabilities: { tasks: { requests: { tools: { call: {} } } } }, taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() })
  48. mcp.registerTool('order', { description: '下单(会询问收件信息)', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => {
  49. const r = await mcp.server.elicitInput({ mode: 'form', message: '请填写收件信息', requestedSchema: SCHEMA })
  50. return { content: [{ type: 'text', text: `${r.action}:${JSON.stringify(r.content ?? {})}` }] }
  51. })
  52. mcp.registerTool('login', { description: '需要授权', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => {
  53. const r = await mcp.server.elicitInput({ mode: 'url', message: '请先授权访问日历', url: 'https://auth.example.com/authorize?x=1', elicitationId: 'el1' })
  54. return { content: [{ type: 'text', text: r.action }] }
  55. })
  56. mcp.experimental.tasks.registerToolTask('approve_job', { description: '长任务中途需要确认', inputSchema: { key: z.string() }, execution: { taskSupport: 'required' } }, {
  57. async createTask({ key }, { taskStore: store, taskRequestedTtl }) {
  58. const task = await store.createTask({ ttl: taskRequestedTtl, pollInterval: 50 })
  59. void (async () => {
  60. await store.updateTaskStatus(task.taskId, 'input_required')
  61. let result: ElicitResult | undefined
  62. for await (const m of mcp.server.experimental.tasks.elicitInputStream({ mode: 'form', message: `继续执行 ${key} 吗?`, requestedSchema: { type: 'object', properties: { ok: { type: 'boolean', title: '继续' } }, required: ['ok'] } }, { relatedTask: { taskId: task.taskId } })) {
  63. if (m.type === 'result') result = m.result as ElicitResult
  64. }
  65. await store.storeTaskResult(task.taskId, 'completed', { content: [{ type: 'text', text: `任务结束:${result?.action}:${JSON.stringify(result?.content ?? {})}` }] })
  66. })()
  67. return { task }
  68. },
  69. getTask: async (_a, { taskId, taskStore: store }) => (await store.getTask(taskId))!,
  70. getTaskResult: async (_a, { taskId, taskStore: store }) => await store.getTaskResult(taskId) as never,
  71. })
  72. return mcp
  73. }
  74. upstream = http.createServer(async (req, res) => {
  75. let body = ''
  76. req.on('data', c => (body += c))
  77. await new Promise(r => req.on('end', r))
  78. const sid = req.headers['mcp-session-id'] as string | undefined
  79. let t = sid ? transports.get(sid) : undefined
  80. if (!t) {
  81. t = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: id => void transports.set(id, t!) })
  82. await build().connect(t)
  83. }
  84. await t.handleRequest(req, res, body ? JSON.parse(body) : undefined)
  85. })
  86. return new Promise(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}/mcp`)))
  87. }
  88. const forms: NewForm[] = []
  89. let reply: (f: NewForm) => InteractionOutcome = () => ({ resolution: 'cancelled' })
  90. function runCtx(): GatewayRunContext {
  91. const ws = path.join(config.dataDir, 'workspaces', `ses_ask_${Math.random().toString(36).slice(2)}`)
  92. fs.mkdirSync(ws, { recursive: true })
  93. const settings = app.settings.get()
  94. return {
  95. sessionId: `ses_${Math.random().toString(36).slice(2)}`, runId: 'r', provider: 'claude', workspaceDir: ws, permissionMode: 'auto', settings,
  96. access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
  97. knowledgeRoots: [], mcpServerIds: [serverId], audit: () => {},
  98. interact: async req => {
  99. if (req.kind !== 'form') return { resolution: 'allowed', scope: 'once' }
  100. forms.push(req)
  101. return reply(req)
  102. },
  103. }
  104. }
  105. async function gateway(ctx: GatewayRunContext) {
  106. const client = new Client({ name: 'test', version: '1' })
  107. await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${app.gatewayTokens.issue(ctx)}` } } }))
  108. return client
  109. }
  110. const text = (r: unknown) => (r as { content: { text: string }[] }).content[0]!.text
  111. Object.assign(taskTiming, { minPollMs: 50, defaultPollMs: 100, foregroundPollMs: 50, tickMs: 50 })
  112. beforeAll(async () => {
  113. const log = pino({ level: 'silent' })
  114. app = createApp(log, { providers: {} })
  115. server = await buildServer(app, { logger: log })
  116. base = await server.listen({ port: 0, host: '127.0.0.1' })
  117. const url = await startUpstream()
  118. serverId = app.mcpServers.create({ name: 'ask', title: '下单服务', description: '', transport: 'http', enabled: true, url, headers: [], command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [] }).id
  119. await app.mcpServers.refresh(serverId)
  120. app.mcpServers.setToolPolicy(serverId, 'approve_job', 'auto')
  121. })
  122. afterAll(async () => {
  123. app.mcpTasks.stop()
  124. app.backgroundCalls.stop()
  125. await app.mcpUpstream.closeAll()
  126. await server.close()
  127. upstream.closeAllConnections()
  128. upstream.close()
  129. })
  130. describe('表单字段', () => {
  131. it('requestedSchema 转换为表单字段;提交时按字段校验', () => {
  132. const fields = schemaToFields(SCHEMA)
  133. expect(fields).toEqual([
  134. { name: 'name', title: '收件人', required: true, type: 'string', minLength: 1 },
  135. { name: 'email', title: '邮箱', required: false, type: 'string', format: 'email' },
  136. { name: 'count', title: '数量', required: false, type: 'integer', minimum: 1, maximum: 10, default: 1 },
  137. { name: 'urgent', title: '加急', required: false, type: 'boolean' },
  138. { name: 'color', title: '颜色', required: true, type: 'choice', options: [{ value: 'red', label: '红' }, { value: 'blue', label: '蓝' }], multiple: false },
  139. { name: 'tags', title: '标签', required: false, type: 'choice', options: ['a', 'b', 'c'].map(v => ({ value: v, label: v })), multiple: true, maxItems: 2 },
  140. ])
  141. expect(schemaToFields({ type: 'object', properties: { x: { type: 'object' } } })).toMatch(/不支持/)
  142. if (typeof fields === 'string') throw new Error(fields)
  143. expect(validateFormContent(fields, { name: '张三', color: 'red', count: '3', extra: 'x' })).toEqual({ name: '张三', color: 'red', count: 3 })
  144. expect(() => validateFormContent(fields, { color: 'red' })).toThrow(/请填写:收件人/)
  145. expect(() => validateFormContent(fields, { name: 'a', color: 'green' })).toThrow(/颜色 的选项不正确/)
  146. expect(() => validateFormContent(fields, { name: 'a', color: 'red', count: 11 })).toThrow(/不能大于 10/)
  147. expect(() => validateFormContent(fields, { name: 'a', color: 'red', email: 'nope' })).toThrow(/邮箱地址/)
  148. expect(() => validateFormContent(fields, { name: 'a', color: 'red', tags: ['a', 'b', 'c'] })).toThrow(/最多选 2 项/)
  149. })
  150. })
  151. describe('调用过程中询问', () => {
  152. it('表单询问显示在这一轮,提交的内容回给服务;拒绝、关闭原样回给服务', async () => {
  153. const c = await gateway(runCtx())
  154. reply = () => ({ resolution: 'submitted', content: { name: '张三', color: 'blue' } })
  155. expect(text(await c.callTool({ name: 'ask__order', arguments: {} }))).toBe('accept:{"name":"张三","color":"blue"}')
  156. expect(forms[0]).toMatchObject({ kind: 'form', mode: 'form', source: '下单服务', message: '请填写收件信息' })
  157. expect(forms[0]!.fields).toHaveLength(6)
  158. reply = () => ({ resolution: 'denied' })
  159. expect(text(await c.callTool({ name: 'ask__order', arguments: {} }))).toBe('decline:{}')
  160. reply = () => ({ resolution: 'timeout' })
  161. expect(text(await c.callTool({ name: 'ask__order', arguments: {} }))).toBe('cancel:{}')
  162. await c.close()
  163. })
  164. it('网址询问:显示网址,用户完成后回复 accept', async () => {
  165. const c = await gateway(runCtx())
  166. reply = () => ({ resolution: 'submitted', content: {} })
  167. expect(text(await c.callTool({ name: 'ask__login', arguments: {} }))).toBe('accept')
  168. expect(forms.at(-1)).toMatchObject({ mode: 'url', url: 'https://auth.example.com/authorize?x=1', message: '请先授权访问日历' })
  169. await c.close()
  170. })
  171. it('无法确定属于哪个会话的询问一律拒绝', async () => {
  172. const row = app.mcpServers.rows([serverId])[0]!
  173. const r = await app.elicitation.handle({ row, params: { mode: 'form', message: 'x', requestedSchema: { type: 'object', properties: {} } }, taskId: null, scope: null, owners: [{ sessionId: 'a' }, { sessionId: 'b' }] })
  174. expect(r).toEqual({ action: 'decline' })
  175. })
  176. })
  177. describe('长任务中询问', () => {
  178. it('任务进入 input_required 时发出询问卡片(不属于某一轮),回答后任务继续并完成', async () => {
  179. const session = await app.sessions.create('admin', {})
  180. const ctx = { ...runCtx(), sessionId: session.id }
  181. const c = await gateway(ctx)
  182. const call = c.callTool({ name: 'ask__approve_job', arguments: { key: 'J1' } }, undefined, { timeout: 30_000 })
  183. const pending = await expect.poll(() => app.interactions.pendingFor(ctx.sessionId)[0], { timeout: 8000 }).toBeTruthy().then(() => app.interactions.pendingFor(ctx.sessionId)[0] as InteractionFormRequest)
  184. expect(pending).toMatchObject({ kind: 'form', message: '继续执行 J1 吗?', fields: [{ name: 'ok', type: 'boolean', required: true }] })
  185. expect(() => app.interactions.respond(pending.interactionId, { action: 'accept', content: {} })).toThrow(/请填写/)
  186. app.interactions.respond(pending.interactionId, { action: 'accept', content: { ok: true } })
  187. expect(text(await call)).toBe('任务结束:accept:{"ok":true}')
  188. await c.close()
  189. }, 20_000)
  190. })