mcp-hints.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. // MCP 支持完善 · 阶段 A:工具提示、结构化输出、工具列表变化(docs/design/16-mcp-support.md §2)
  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 { Client } from '@modelcontextprotocol/sdk/client/index.js'
  10. import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
  11. import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
  12. import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
  13. import { z } from 'zod'
  14. import type { McpServerInput } from '@agent-server/shared'
  15. import { createApp, type App } from '../src/app'
  16. import { config } from '../src/config'
  17. import { buildServer } from '../src/http/server'
  18. import type { GatewayRunContext } from '../src/mcp-gateway/tokens'
  19. import { resolveAccess } from '../src/services/access-policy'
  20. import type { InteractionOutcome, NewApproval } from '../src/services/interactions'
  21. let app: App
  22. let server: Awaited<ReturnType<typeof buildServer>>
  23. let base: string
  24. let upstream: http.Server
  25. let upstreamUrl: string
  26. let flakyCalls = 0
  27. const servers = new Set<McpServer>()
  28. /** 有状态的测试服务(会话 id),可以发出工具列表变化通知 */
  29. function startUpstream(): Promise<string> {
  30. const transports = new Map<string, StreamableHTTPServerTransport>()
  31. const build = () => {
  32. const mcp = new McpServer({ name: 'fixture-hints', version: '1.0.0' })
  33. mcp.registerTool('delete_all', { title: '删除全部', description: '删除全部数据', inputSchema: {}, annotations: { destructiveHint: true } }, async () => ({ content: [{ type: 'text', text: 'deleted' }] }))
  34. mcp.registerTool('set_value', { description: '设置值', inputSchema: { v: z.string() }, annotations: { idempotentHint: true, destructiveHint: false, openWorldHint: false } }, async ({ v }) => ({ content: [{ type: 'text', text: `set:${v}` }] }))
  35. mcp.registerTool('send_once', { description: '发送(不幂等)', inputSchema: {} }, async () => ({ content: [{ type: 'text', text: 'sent' }] }))
  36. mcp.registerTool('stats', {
  37. description: '统计', inputSchema: {}, annotations: { readOnlyHint: true },
  38. outputSchema: { total: z.number(), items: z.array(z.string()) },
  39. }, async () => {
  40. const structuredContent = { total: 2, items: ['a', 'b'] }
  41. return { content: [{ type: 'text', text: JSON.stringify(structuredContent) }], structuredContent }
  42. })
  43. mcp.registerTool('stats_summary', { description: '统计(文本与结构化不同)', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => ({
  44. content: [{ type: 'text', text: '共 2 条' }], structuredContent: { total: 2 },
  45. }))
  46. // 服务端自己的校验接受任何值,对外声明的 JSON Schema 却是 number:模拟返回结构与声明不符的服务
  47. mcp.registerTool('stats_bad', { description: '结构不符', inputSchema: {}, annotations: { readOnlyHint: true }, outputSchema: { total: z.any().meta({ type: 'number' }) } }, async () => ({
  48. content: [{ type: 'text', text: '坏结果' }], structuredContent: { total: 'two' },
  49. }))
  50. servers.add(mcp)
  51. return mcp
  52. }
  53. upstream = http.createServer(async (req, res) => {
  54. let body = ''
  55. req.on('data', c => (body += c))
  56. await new Promise(r => req.on('end', r))
  57. const parsed = body ? JSON.parse(body) : undefined
  58. // 偶发的网络错误:调用 set_value / send_once 的第一次直接断开连接
  59. if (parsed?.method === 'tools/call' && ['set_value', 'send_once'].includes(parsed.params.name) && flakyCalls++ % 2 === 0) return req.socket.destroy()
  60. const sid = req.headers['mcp-session-id'] as string | undefined
  61. let t = sid ? transports.get(sid) : undefined
  62. if (!t) {
  63. t = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: id => void transports.set(id, t!) })
  64. await build().connect(t)
  65. }
  66. await t.handleRequest(req, res, parsed)
  67. })
  68. return new Promise(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}/mcp`)))
  69. }
  70. const input = (): McpServerInput => ({
  71. name: 'hints', title: '提示服务', description: '', transport: 'http', enabled: true, url: upstreamUrl,
  72. headers: [], command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [],
  73. })
  74. const approvals: NewApproval[] = []
  75. const audits: { summary: string; detail: Record<string, unknown> }[] = []
  76. const answer: InteractionOutcome = { resolution: 'allowed', scope: 'once' }
  77. function runCtx(over: Partial<GatewayRunContext> = {}): GatewayRunContext {
  78. const ws = path.join(config.dataDir, 'workspaces', `ses_hints_${Math.random().toString(36).slice(2)}`)
  79. fs.mkdirSync(ws, { recursive: true })
  80. const settings = app.settings.get()
  81. return {
  82. sessionId: `ses_${Math.random().toString(36).slice(2)}`, runId: 'r', provider: 'claude', workspaceDir: ws, permissionMode: 'auto', settings,
  83. access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
  84. knowledgeRoots: [], mcpServerIds: [], audit: (_k, summary, detail) => void audits.push({ summary, detail: detail as Record<string, unknown> }),
  85. interact: async req => { approvals.push(req as NewApproval); return answer },
  86. ...over,
  87. }
  88. }
  89. async function gateway(ctx: GatewayRunContext) {
  90. const client = new Client({ name: 'test', version: '1' })
  91. await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${app.gatewayTokens.issue(ctx)}` } } }))
  92. return client
  93. }
  94. const texts = (r: unknown) => (r as { content: { text: string }[] }).content.map(c => c.text)
  95. beforeAll(async () => {
  96. const log = pino({ level: 'silent' })
  97. app = createApp(log, { providers: {} })
  98. server = await buildServer(app, { logger: log })
  99. base = await server.listen({ port: 0, host: '127.0.0.1' })
  100. upstreamUrl = await startUpstream()
  101. })
  102. afterAll(async () => {
  103. await app.mcpUpstream.closeAll()
  104. await server.close()
  105. upstream.closeAllConnections()
  106. upstream.close()
  107. })
  108. describe('工具提示', () => {
  109. it('明确声明破坏性的工具默认总是审批(自动模式也问、每次都问);管理员可以覆盖提示', async () => {
  110. const s = app.mcpServers.create(input())
  111. const tools = (await app.mcpServers.refresh(s.id)).tools
  112. const del = tools.find(t => t.name === 'delete_all')!
  113. expect(del).toMatchObject({ title: '删除全部', destructive: true, policy: 'confirm', declared: { destructive: true, readOnly: null } })
  114. // 没写提示的工具:规范默认值不用来强制审批
  115. expect(tools.find(t => t.name === 'send_once')).toMatchObject({ destructive: false, idempotent: false, openWorld: true, policy: 'ask' })
  116. expect(tools.find(t => t.name === 'set_value')).toMatchObject({ idempotent: true, openWorld: false, policy: 'ask' })
  117. expect(tools.find(t => t.name === 'stats')).toMatchObject({ readOnly: true, policy: 'auto', hasOutputSchema: true })
  118. const ctx = runCtx({ mcpServerIds: [s.id] })
  119. const c = await gateway(ctx)
  120. const listed = (await c.listTools()).tools.find(t => t.name === 'hints__delete_all')!
  121. expect(listed.annotations).toMatchObject({ destructiveHint: true, readOnlyHint: false })
  122. expect(listed.title).toBe('删除全部')
  123. expect(texts(await c.callTool({ name: 'hints__delete_all', arguments: {} }))).toEqual(['deleted'])
  124. expect(texts(await c.callTool({ name: 'hints__delete_all', arguments: {} }))).toEqual(['deleted'])
  125. expect(approvals).toHaveLength(2)
  126. expect(approvals[0]).toMatchObject({ options: ['allow_once', 'deny'], title: '提示服务 · 删除全部' })
  127. expect(approvals[0]!.detail?.reason).toMatch(/破坏性/)
  128. // 覆盖为非破坏性:回到跟随权限模式,自动模式下不再询问
  129. const after = app.mcpServers.setToolHints(s.id, 'delete_all', { destructive: false })
  130. expect(after.tools.find(t => t.name === 'delete_all')).toMatchObject({ destructive: false, policy: 'ask', overrides: { destructive: false } })
  131. await c.callTool({ name: 'hints__delete_all', arguments: {} })
  132. expect(approvals).toHaveLength(2)
  133. // 覆盖为只读:只读模式下也提供
  134. app.mcpServers.setToolHints(s.id, 'send_once', { readOnly: true })
  135. const ro = await gateway(runCtx({ mcpServerIds: [s.id], permissionMode: 'readonly' }))
  136. expect((await ro.listTools()).tools.map(t => t.name)).toEqual(expect.arrayContaining(['hints__send_once', 'hints__stats']))
  137. await ro.close()
  138. // 清除覆盖
  139. expect(app.mcpServers.setToolHints(s.id, 'send_once', {}).tools.find(t => t.name === 'send_once')!.overrides).toEqual({})
  140. await c.close()
  141. })
  142. it('网络错误:幂等工具重新连接后重试,不幂等的不重试', async () => {
  143. const s = app.mcpServers.list().find(x => x.name === 'hints')!
  144. const c = await gateway(runCtx({ mcpServerIds: [s.id] }))
  145. flakyCalls = 0
  146. expect(texts(await c.callTool({ name: 'hints__set_value', arguments: { v: '1' } }))).toEqual(['set:1'])
  147. flakyCalls = 0
  148. const r = await c.callTool({ name: 'hints__send_once', arguments: {} })
  149. expect(r.isError).toBe(true)
  150. expect(flakyCalls).toBe(1)
  151. await c.close()
  152. })
  153. })
  154. describe('结构化输出', () => {
  155. it('文本已含同样的 JSON 时不重复;不同时附在后面;不符合 outputSchema 时照常返回并记录', async () => {
  156. const s = app.mcpServers.list().find(x => x.name === 'hints')!
  157. const ctx = runCtx({ mcpServerIds: [s.id] })
  158. const c = await gateway(ctx)
  159. const listed = (await c.listTools()).tools.find(t => t.name === 'hints__stats')!
  160. expect(listed.description).toMatch(/返回结构(JSON Schema):.*"total"/)
  161. expect(listed.outputSchema).toBeUndefined()
  162. expect(texts(await c.callTool({ name: 'hints__stats', arguments: {} }))).toEqual(['{"total":2,"items":["a","b"]}'])
  163. expect(texts(await c.callTool({ name: 'hints__stats_summary', arguments: {} }))).toEqual(['共 2 条', '结构化结果:\n{\n "total": 2\n}'])
  164. audits.length = 0
  165. const bad = await c.callTool({ name: 'hints__stats_bad', arguments: {} })
  166. expect(bad.isError).toBeFalsy()
  167. expect(texts(bad)[0]).toBe('坏结果')
  168. expect(audits.at(-1)!.detail).toMatchObject({ structured: { total: 'two' }, structuredError: expect.stringMatching(/outputSchema/) })
  169. await c.close()
  170. })
  171. })
  172. describe('工具列表变化', () => {
  173. it('服务通知工具列表变化后自动刷新缓存', async () => {
  174. const s = app.mcpServers.list().find(x => x.name === 'hints')!
  175. const c = await gateway(runCtx({ mcpServerIds: [s.id] }))
  176. await c.callTool({ name: 'hints__stats', arguments: {} }) // 建立上游连接
  177. await new Promise(r => setTimeout(r, 2500))
  178. for (const mcp of servers) {
  179. if (mcp.isConnected()) mcp.registerTool('added_later', { description: '后来加的', inputSchema: {} }, async () => ({ content: [{ type: 'text', text: 'new' }] }))
  180. }
  181. await expect.poll(() => app.mcpServers.get(s.id).tools.map(t => t.name), { timeout: 8000 }).toContain('added_later')
  182. expect((await c.listTools()).tools.map(t => t.name)).toContain('hints__added_later')
  183. await c.close()
  184. }, 15_000)
  185. })