mcp-resources.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. // MCP 支持完善 · 阶段 E:服务端的资源与提示模板(docs/design/16-mcp-support.md §6)
  2. import fs from 'node:fs'
  3. import http from 'node:http'
  4. import type { AddressInfo } from 'node:net'
  5. import path from 'node:path'
  6. import pino from 'pino'
  7. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  8. import { z } from 'zod'
  9. import { Client } from '@modelcontextprotocol/sdk/client/index.js'
  10. import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
  11. import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
  12. import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
  13. import type { FileDto, McpPromptListDto, McpPromptResultDto, McpResourceListDto, SessionDetailDto } from '@agent-server/shared'
  14. import { createApp, type App } from '../src/app'
  15. import { config } from '../src/config'
  16. import { buildServer } from '../src/http/server'
  17. import type { GatewayRunContext } from '../src/mcp-gateway/tokens'
  18. import { resolveAccess } from '../src/services/access-policy'
  19. let app: App
  20. let server: Awaited<ReturnType<typeof buildServer>>
  21. let base: string
  22. let upstream: http.Server
  23. let serverId: string
  24. let apiKey: string
  25. const auth = () => ({ authorization: `Bearer ${apiKey}` })
  26. function startUpstream(): Promise<string> {
  27. upstream = http.createServer(async (req, res) => {
  28. const mcp = new McpServer({ name: 'docs', version: '1.0.0' })
  29. mcp.registerResource('guide', 'docs://manual/guide.md', { title: '使用手册', description: '产品使用手册', mimeType: 'text/markdown' }, async uri => ({
  30. contents: [{ uri: uri.href, mimeType: 'text/markdown', text: '# 手册\n第一章' }],
  31. }))
  32. mcp.registerResource('big', 'docs://manual/big.txt', { title: '大文件', mimeType: 'text/plain' }, async uri => ({ contents: [{ uri: uri.href, text: '行'.repeat(30_000) }] }))
  33. mcp.registerResource('logo', 'docs://assets/logo.png', { title: '标志', mimeType: 'image/png' }, async uri => ({
  34. contents: [{ uri: uri.href, mimeType: 'image/png', blob: Buffer.from([137, 80, 78, 71]).toString('base64') }],
  35. }))
  36. mcp.registerResource('topic', new ResourceTemplate('docs://topics/{product}/{topic}', { list: undefined }), { title: '主题文档', description: '按产品和主题读取' }, async (uri, v) => ({
  37. contents: [{ uri: uri.href, text: `${v.product} 的 ${v.topic}` }],
  38. }))
  39. mcp.registerPrompt('weekly', { title: '周报', description: '生成周报', argsSchema: { week: z.string().describe('第几周'), team: z.string().optional() } }, async ({ week, team }) => ({
  40. messages: [
  41. { role: 'user', content: { type: 'text', text: `请为${team ?? '团队'}写第 ${week} 周的周报` } },
  42. { role: 'user', content: { type: 'resource', resource: { uri: 'docs://templates/weekly.md', mimeType: 'text/markdown', text: '## 本周进展' } } },
  43. ],
  44. }))
  45. const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
  46. res.on('close', () => { void t.close(); void mcp.close() })
  47. await mcp.connect(t)
  48. let body = ''
  49. req.on('data', c => (body += c))
  50. req.on('end', () => void t.handleRequest(req, res, body ? JSON.parse(body) : undefined))
  51. })
  52. return new Promise(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}/mcp`)))
  53. }
  54. function runCtx(ids: string[]): GatewayRunContext {
  55. const ws = path.join(config.dataDir, 'workspaces', `ses_res_${Math.random().toString(36).slice(2)}`)
  56. fs.mkdirSync(ws, { recursive: true })
  57. const settings = app.settings.get()
  58. return {
  59. sessionId: `ses_${Math.random().toString(36).slice(2)}`, runId: 'r', provider: 'claude', workspaceDir: ws, permissionMode: 'readonly', settings,
  60. access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
  61. knowledgeRoots: [], mcpServerIds: ids, audit: () => {}, interact: async () => ({ resolution: 'allowed', scope: 'once' }),
  62. }
  63. }
  64. async function gateway(ctx: GatewayRunContext) {
  65. const client = new Client({ name: 'test', version: '1' })
  66. await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${app.gatewayTokens.issue(ctx)}` } } }))
  67. return client
  68. }
  69. const text = (r: unknown) => (r as { content: { text: string }[] }).content.map(c => c.text).join('\n')
  70. beforeAll(async () => {
  71. const log = pino({ level: 'silent' })
  72. app = createApp(log, { providers: {} })
  73. server = await buildServer(app, { logger: log })
  74. base = await server.listen({ port: 0, host: '127.0.0.1' })
  75. const url = await startUpstream()
  76. serverId = app.mcpServers.create({ name: 'docs', title: '文档服务', description: '', transport: 'http', enabled: true, url, headers: [], command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [] }).id
  77. const { id: _id, version: _v, updatedAt: _u, builtin: _b, builtinKey: _k, ...input } = app.profiles.getBuiltin()
  78. const profile = app.profiles.create({ ...input, name: '带文档服务', isDefault: false, mcpServerIds: [serverId] })
  79. apiKey = app.apiKeys.create('平台R', { allowedProfileIds: [profile.id], defaultProfileId: profile.id }).key
  80. })
  81. afterAll(async () => {
  82. await app.mcpUpstream.closeAll()
  83. await server.close()
  84. upstream.close()
  85. })
  86. describe('资源(模型按需读取)', () => {
  87. it('刷新时记录服务的能力;勾选了提供资源的服务时网关提供两个只读工具;读取的内容按大小内联或存为文件', async () => {
  88. const before = await gateway(runCtx([serverId]))
  89. expect((await before.listTools()).tools.map(t => t.name)).not.toContain('mcp_resources') // 还没连接过,不知道服务提供资源
  90. await before.close()
  91. expect((await app.mcpServers.refresh(serverId)).features).toEqual({ resources: true, prompts: true })
  92. const ctx = runCtx([serverId])
  93. const c = await gateway(ctx)
  94. const names = (await c.listTools()).tools.map(t => t.name)
  95. expect(names).toEqual(expect.arrayContaining(['mcp_resources', 'mcp_resource_read'])) // 只读模式下也提供
  96. const list = text(await c.callTool({ name: 'mcp_resources', arguments: {} }))
  97. expect(list).toContain('[docs] 使用手册 docs://manual/guide.md (text/markdown)')
  98. expect(list).toContain('模板 主题文档 docs://topics/{product}/{topic}')
  99. expect(list).not.toContain('第一章')
  100. expect(text(await c.callTool({ name: 'mcp_resource_read', arguments: { server: 'docs', uri: 'docs://manual/guide.md' } }))).toBe('docs://manual/guide.md(text/markdown)\n# 手册\n第一章')
  101. expect(text(await c.callTool({ name: 'mcp_resource_read', arguments: { server: 'docs', uri: 'docs://topics/ess/warranty' } }))).toContain('ess 的 warranty')
  102. const big = text(await c.callTool({ name: 'mcp_resource_read', arguments: { server: 'docs', uri: 'docs://manual/big.txt' } }))
  103. const saved = /保存到 (\S+))/.exec(big)![1]!
  104. expect(saved).toBe(path.join('.agent', 'mcp', 'resources', 'big.txt'))
  105. expect(fs.readFileSync(path.join(ctx.workspaceDir, saved), 'utf8')).toHaveLength(30_000)
  106. const logo = text(await c.callTool({ name: 'mcp_resource_read', arguments: { server: 'docs', uri: 'docs://assets/logo.png' } }))
  107. expect(logo).toMatch(/二进制内容(4 B)已保存到 \.agent\/mcp\/resources\/logo\.png/)
  108. await c.close()
  109. const none = await gateway(runCtx([]))
  110. expect((await none.listTools()).tools.map(t => t.name)).not.toContain('mcp_resources')
  111. await none.close()
  112. })
  113. })
  114. describe('资料与快捷指令(会话 API)', () => {
  115. it('会话详情给出能力;列出资源、附加为会话文件;列出提示模板、展开后返回文本和附件', async () => {
  116. const sid = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })).json().id as string
  117. expect((await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: auth() })).json<SessionDetailDto>().mcp).toEqual({ resources: true, prompts: true })
  118. const list = (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}/mcp/resources`, headers: auth() })).json<McpResourceListDto>()
  119. expect(list.resources.map(r => r.uri)).toEqual(['docs://manual/guide.md', 'docs://manual/big.txt', 'docs://assets/logo.png'])
  120. expect(list.resources[0]).toMatchObject({ serverId, serverName: 'docs', serverTitle: '文档服务', title: '使用手册', description: '产品使用手册' })
  121. expect(list.templates).toEqual([expect.objectContaining({ uriTemplate: 'docs://topics/{product}/{topic}', title: '主题文档' })])
  122. const attached = (await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/mcp/resources/attach`, headers: auth(), payload: { server: 'docs', uri: 'docs://manual/guide.md' } })).json<FileDto[]>()
  123. expect(attached).toEqual([expect.objectContaining({ path: 'uploads/guide.md', origin: 'upload', size: Buffer.byteLength('# 手册\n第一章') })])
  124. const run = await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: auth(), payload: { text: '看看这份手册', attachments: attached.map(f => f.id) } })
  125. expect([202, 409]).toContain(run.statusCode) // 没有引擎时也能校验附件属于这个会话
  126. const prompts = (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}/mcp/prompts`, headers: auth() })).json<McpPromptListDto>()
  127. expect(prompts.prompts).toEqual([expect.objectContaining({ name: 'weekly', title: '周报', arguments: [{ name: 'week', description: '第几周', required: true }, { name: 'team', description: null, required: false }] })])
  128. const got = (await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/mcp/prompts/get`, headers: auth(), payload: { server: serverId, name: 'weekly', arguments: { week: '44', team: '储能组' } } })).json<McpPromptResultDto>()
  129. expect(got.text).toBe('请为储能组写第 44 周的周报')
  130. expect(got.attachments).toEqual([expect.objectContaining({ path: 'uploads/weekly.md' })])
  131. const missing = await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/mcp/resources/attach`, headers: auth(), payload: { server: 'nope', uri: 'docs://x' } })
  132. expect(missing.statusCode).toBe(404)
  133. })
  134. })