mcp-tasks.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. // MCP 支持完善 · 阶段 B:长任务(MCP Tasks)与后台调用合并(docs/design/16-mcp-support.md §3)
  2. import http from 'node:http'
  3. import type { AddressInfo } from 'node:net'
  4. import pino from 'pino'
  5. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  6. import { z } from 'zod'
  7. import { Client } from '@modelcontextprotocol/sdk/client/index.js'
  8. import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
  9. import { InMemoryTaskMessageQueue, InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js'
  10. import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
  11. import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
  12. import type { BackgroundCallDto, ProviderStatusDto } from '@agent-server/shared'
  13. import { createApp, type App } from '../src/app'
  14. import { buildServer } from '../src/http/server'
  15. import { taskTiming } from '../src/mcp-gateway/tasks'
  16. import type { AgentProvider, RunContext } from '../src/providers/types'
  17. type ToolCall = (tool: string, args?: Record<string, unknown>) => Promise<string>
  18. class ScriptedProvider implements AgentProvider {
  19. readonly id = 'claude' as const
  20. script: (input: string, tool: ToolCall) => Promise<string> = async () => 'ok'
  21. inputs: { text: string; sessionId: string }[] = []
  22. async run(ctx: RunContext) {
  23. ctx.setProviderSessionId(`fake-${ctx.sessionId}`)
  24. this.inputs.push({ text: ctx.input.text, sessionId: ctx.sessionId })
  25. const url = new URL(ctx.gateway.url)
  26. const client = new Client({ name: 'fake', version: '1' })
  27. await client.connect(new StreamableHTTPClientTransport(new URL(`${url.pathname}${url.search}`, base), { requestInit: { headers: { Authorization: `Bearer ${ctx.gateway.token}` } } }))
  28. const tool: ToolCall = async (name, args = {}) => {
  29. const r = await client.callTool({ name: `jobs__${name}`, arguments: args }, undefined, { timeout: 60_000 })
  30. return (r.content as { text: string }[]).map(c => c.text).join('\n')
  31. }
  32. try {
  33. ctx.emit('message.completed', { messageId: `m${this.inputs.length}`, text: await this.script(ctx.input.text, tool) })
  34. } finally {
  35. await client.close()
  36. }
  37. return { usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0.001 } }
  38. }
  39. async status(): Promise<ProviderStatusDto> {
  40. return { id: 'claude', enabled: true, available: true, loggedIn: true, account: 'test', cliPath: '', version: 'x', models: ['m'], defaultModel: 'm', message: null }
  41. }
  42. }
  43. let app: App
  44. let server: Awaited<ReturnType<typeof buildServer>>
  45. let base: string
  46. let upstream: http.Server
  47. let apiKey: string
  48. const fake = new ScriptedProvider()
  49. const auth = () => ({ authorization: `Bearer ${apiKey}` })
  50. async function waitFor<T>(fn: () => T | undefined | false | null, ms = 8000): Promise<T> {
  51. const end = Date.now() + ms
  52. while (Date.now() < end) {
  53. const v = fn()
  54. if (v) return v
  55. await new Promise(r => setTimeout(r, 20))
  56. }
  57. throw new Error('timeout')
  58. }
  59. /** 外部服务:任务不按 MCP 会话归属(无状态服务),重新连接后也能查询;任务由测试放行后完成 */
  60. const taskStore = new InMemoryTaskStore()
  61. const queue = new InMemoryTaskMessageQueue()
  62. const gates = new Map<string, (r: { text: string; isError?: boolean }) => void>()
  63. const created: string[] = []
  64. function startUpstream(): Promise<string> {
  65. upstream = http.createServer(async (req, res) => {
  66. const mcp = new McpServer({ name: 'jobs', version: '1.0.0' }, { capabilities: { tasks: { requests: { tools: { call: {} } }, cancel: {} } }, taskStore, taskMessageQueue: queue })
  67. mcp.experimental.tasks.registerToolTask('run_job', {
  68. description: '执行作业(可能很久)', inputSchema: { key: z.string() }, execution: { taskSupport: 'required' },
  69. }, {
  70. async createTask({ key }, { taskStore: store, taskRequestedTtl }) {
  71. const task = await store.createTask({ ttl: taskRequestedTtl, pollInterval: 100 })
  72. created.push(task.taskId)
  73. const finish = (r: { text: string; isError?: boolean }) => void taskStore.storeTaskResult(task.taskId, r.isError ? 'failed' : 'completed', { content: [{ type: 'text', text: r.text }], ...(r.isError ? { isError: true } : {}) })
  74. if (key.startsWith('quick')) setTimeout(() => finish({ text: `作业完成:${key}` }), 200)
  75. else gates.set(key, finish)
  76. return { task, _meta: { 'io.modelcontextprotocol/model-immediate-response': `作业 ${key} 已排队,预计 1 小时` } }
  77. },
  78. getTask: async (_args, { taskId, taskStore: store }) => (await store.getTask(taskId))!,
  79. getTaskResult: async (_args, { taskId, taskStore: store }) => await store.getTaskResult(taskId) as never,
  80. })
  81. const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
  82. res.on('close', () => { void t.close(); void mcp.close() })
  83. await mcp.connect(t)
  84. let body = ''
  85. req.on('data', c => (body += c))
  86. req.on('end', () => void t.handleRequest(req, res, body ? JSON.parse(body) : undefined))
  87. })
  88. return new Promise(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}/mcp`)))
  89. }
  90. const finish = async (key: string, r: { text: string; isError?: boolean }) => (await waitFor(() => gates.get(key)))(r)
  91. Object.assign(taskTiming, { minPollMs: 50, defaultPollMs: 100, foregroundPollMs: 50, tickMs: 50 })
  92. beforeAll(async () => {
  93. const log = pino({ level: 'silent' })
  94. app = createApp(log, { providers: { claude: fake } })
  95. server = await buildServer(app, { logger: log })
  96. base = await server.listen({ port: 0, host: '127.0.0.1' })
  97. const url = await startUpstream()
  98. const serverId = app.mcpServers.create({
  99. name: 'jobs', title: '作业服务', description: '', transport: 'http', enabled: true, url, headers: [], command: '', args: [], env: [],
  100. allowNetwork: false, readPaths: [], writePaths: [],
  101. }).id
  102. await app.mcpServers.refresh(serverId)
  103. app.mcpServers.setToolPolicy(serverId, 'run_job', 'auto')
  104. const { id: _id, version: _v, updatedAt: _u, builtin: _b, builtinKey: _k, ...input } = app.profiles.getBuiltin()
  105. const profile = app.profiles.create({ ...input, name: '带作业服务', isDefault: false, mcpServerIds: [serverId] })
  106. apiKey = app.apiKeys.create('平台T', { allowedProfileIds: [profile.id], defaultProfileId: profile.id }).key
  107. app.settings.update({ backgroundCalls: { autoAfterSec: 1 } })
  108. })
  109. afterAll(async () => {
  110. app.mcpTasks.stop()
  111. app.backgroundCalls.stop()
  112. await app.mcpUpstream.closeAll()
  113. await server.close()
  114. upstream.closeAllConnections()
  115. upstream.close()
  116. })
  117. const newSession = async () => (await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })).json().id as string
  118. const calls = async (sid: string) => (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}/background-calls`, headers: auth() })).json() as BackgroundCallDto[]
  119. const send = (sid: string, text: string) => server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: auth(), payload: { text } })
  120. const idle = (sid: string) => !app.runs.activeForSession(sid)
  121. describe('长任务', () => {
  122. it('工具列表标出长任务;很快完成的任务在前台直接返回结果', async () => {
  123. expect(app.mcpServers.list().find(s => s.name === 'jobs')!.tools[0]).toMatchObject({ name: 'run_job', taskSupport: 'required' })
  124. const sid = await newSession()
  125. let reply = ''
  126. fake.script = async (input, tool) => (reply = await tool('run_job', { key: 'quick1' }))
  127. await send(sid, '跑一下')
  128. await waitFor(() => idle(sid) && reply)
  129. expect(reply).toBe('作业完成:quick1')
  130. expect(await calls(sid)).toHaveLength(0)
  131. })
  132. it('超过时限的任务转为后台(带上服务的说明),按任务 id 查询,完成后把结果发到会话', async () => {
  133. const sid = await newSession()
  134. let reply = ''
  135. fake.script = async (input, tool) => (input.startsWith('【后台调用') ? '收到' : (reply = await tool('run_job', { key: 'slow1' })))
  136. await send(sid, '跑个大的')
  137. await waitFor(() => idle(sid) && reply, 10_000)
  138. expect(reply).toMatch(/已转为后台执行(后台调用 bg_.+)/)
  139. expect(reply).toContain('服务的说明:作业 slow1 已排队,预计 1 小时')
  140. const [running] = await calls(sid)
  141. expect(running).toMatchObject({ status: 'running', mode: 'task', tool: 'run_job' })
  142. const before = fake.inputs.length
  143. await finish('slow1', { text: '报表已生成:1024 行' })
  144. const delivered = await waitFor(() => fake.inputs.slice(before).find(i => i.sessionId === sid))
  145. expect(delivered.text).toMatch(/^【后台调用完成】作业服务 · run_job/)
  146. expect(delivered.text).toContain('报表已生成:1024 行')
  147. await waitFor(() => idle(sid))
  148. expect((await calls(sid))[0]).toMatchObject({ status: 'completed', delivered: true })
  149. }, 20_000)
  150. it('任务失败按失败通知;平台取消时通知服务取消任务', async () => {
  151. const sid = await newSession()
  152. fake.script = async (input, tool) => (input.startsWith('【后台调用') ? '收到' : tool('run_job', { key: input }))
  153. await send(sid, 'fail1')
  154. await waitFor(() => idle(sid) && app.backgroundCalls.list({ sessionId: sid })[0], 10_000)
  155. await finish('fail1', { text: '数据源不可用', isError: true })
  156. await waitFor(() => app.backgroundCalls.list({ sessionId: sid })[0]?.status === 'failed')
  157. expect(app.backgroundCalls.list({ sessionId: sid })[0]!.error).toContain('数据源不可用')
  158. await waitFor(() => idle(sid))
  159. await send(sid, 'cancel1')
  160. const row = await waitFor(() => idle(sid) && app.backgroundCalls.list({ sessionId: sid }).find(c => c.status === 'running'), 10_000)
  161. const res = await server.inject({ method: 'POST', url: `/v1/background-calls/${row.id}/cancel`, headers: auth() })
  162. expect(res.statusCode).toBe(200)
  163. const taskId = created.at(-1)!
  164. await expect.poll(async () => (await taskStore.getTask(taskId))?.status).toBe('cancelled')
  165. }, 25_000)
  166. it('服务重启后按任务 id 继续查询', async () => {
  167. const sid = await newSession()
  168. fake.script = async (input, tool) => (input.startsWith('【后台调用') ? '收到' : tool('run_job', { key: 'restart1' }))
  169. await send(sid, '跑')
  170. await waitFor(() => idle(sid) && app.backgroundCalls.list({ sessionId: sid })[0], 10_000)
  171. // 模拟重启:旧实例停止查询、断开连接,新实例接手同一个数据库
  172. app.mcpTasks.stop()
  173. app.backgroundCalls.stop()
  174. await app.mcpUpstream.closeAll()
  175. const app2 = createApp(pino({ level: 'silent' }), { db: app.db, providers: { claude: fake } })
  176. try {
  177. expect(app2.backgroundCalls.list({ sessionId: sid })[0]).toMatchObject({ status: 'running', mode: 'task' })
  178. const before = fake.inputs.length
  179. await finish('restart1', { text: '重启后也拿到了' })
  180. const delivered = await waitFor(() => fake.inputs.slice(before).find(i => i.sessionId === sid), 10_000)
  181. expect(delivered.text).toContain('重启后也拿到了')
  182. } finally {
  183. app2.mcpTasks.stop()
  184. app2.backgroundCalls.stop()
  185. await app2.mcpUpstream.closeAll()
  186. }
  187. }, 25_000)
  188. })