Ver Fonte

feat: MCP 工具返回的嵌入资源一律存入工作区

- 第三方 MCP 工具返回的 resource(导出的 CSV、文件等)存入 .agent/mcp/,结果中只返回路径、大小和 MIME,不放进上下文
- 文件名取 URI 最后一段(清理路径分隔符等不安全字符,不会越出 .agent/mcp),重名加序号;blob 解码为二进制
- 测试覆盖文本 / 二进制资源、重名与 ../ 路径;ADR-0021、08 文档补充说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang há 1 semana atrás
pai
commit
0af60895b2

+ 45 - 3
apps/server/src/mcp-gateway/server.ts

@@ -135,18 +135,60 @@ function buildServer(ctx: GatewayRunContext, deps: GatewayDeps): Server {
   return server
 }
 
-/** 只保留引擎能处理的内容;过长的文本和过大的二进制存入工作区 .agent/mcp/ 并返回路径 */
+/** 资源文件名:取 URI 最后一段,只保留安全字符;取不到时用工具名 */
+function resourceFileName(uri: unknown, tool: string): string {
+  let last = ''
+  if (typeof uri === 'string') {
+    const noQuery = uri.split(/[?#]/)[0]!
+    last = noQuery.slice(noQuery.lastIndexOf('/') + 1)
+    try {
+      last = decodeURIComponent(last)
+    } catch {
+      // 保留原文
+    }
+  }
+  const safe = last.replace(/[^\p{L}\p{N}._-]+/gu, '_').replace(/^[._]+/, '').slice(0, 120)
+  return safe || `${tool.replace(/[^\w.-]/g, '_')}.bin`
+}
+
+const formatBytes = (n: number) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`)
+
+/**
+ * 只保留引擎能处理的内容;过长的文本和过大的二进制存入工作区 .agent/mcp/ 并返回路径。
+ * resource(嵌入资源,如导出的 CSV、文件)一律存入工作区,只返回路径,不放进上下文
+ */
 function postProcess(r: { content?: ToolContent[]; structuredContent?: unknown; isError?: boolean }, workspaceDir: string, tool: string): ToolResult {
+  const dir = path.join(workspaceDir, '.agent', 'mcp')
   const save = (ext: string, data: string | Buffer) => {
-    const dir = path.join(workspaceDir, '.agent', 'mcp')
     fs.mkdirSync(dir, { recursive: true })
     const file = path.join(dir, `${tool.replace(/[^\w.-]/g, '_')}-${Date.now()}-${crypto.randomBytes(3).toString('hex')}.${ext}`)
     fs.writeFileSync(file, data)
     return path.relative(workspaceDir, file)
   }
+  /** 按资源原名保存,重名时加序号 */
+  const saveNamed = (name: string, data: string | Buffer) => {
+    fs.mkdirSync(dir, { recursive: true })
+    const ext = path.extname(name)
+    const stem = name.slice(0, name.length - ext.length)
+    let file = path.join(dir, name)
+    for (let i = 2; fs.existsSync(file); i++) file = path.join(dir, `${stem}-${i}${ext}`)
+    fs.writeFileSync(file, data)
+    return path.relative(workspaceDir, file)
+  }
   const content: ToolContent[] = []
   for (const c of r.content ?? []) {
-    if (c.type === 'text' && typeof c.text === 'string' && c.text.length > MAX_TEXT) {
+    if (c.type === 'resource' && c.resource && typeof c.resource === 'object') {
+      const res = c.resource as { uri?: unknown; mimeType?: unknown; text?: unknown; blob?: unknown }
+      const data = typeof res.blob === 'string' ? Buffer.from(res.blob, 'base64') : typeof res.text === 'string' ? res.text : null
+      if (data === null) {
+        content.push({ type: 'text', text: '(资源没有内容)' })
+        continue
+      }
+      const saved = saveNamed(resourceFileName(res.uri, tool), data)
+      const size = typeof data === 'string' ? Buffer.byteLength(data) : data.length
+      const mime = typeof res.mimeType === 'string' && res.mimeType ? res.mimeType : typeof data === 'string' ? 'text/plain' : 'application/octet-stream'
+      content.push({ type: 'text', text: `已保存到 ${saved}(${formatBytes(size)},${mime})` })
+    } else if (c.type === 'text' && typeof c.text === 'string' && c.text.length > MAX_TEXT) {
       const saved = save('txt', c.text)
       content.push({ type: 'text', text: `${c.text.slice(0, MAX_TEXT)}\n\n(内容过长,以上为前一部分;完整内容已保存到 ${saved})` })
     } else if ((c.type === 'image' || c.type === 'audio') && typeof c.data === 'string' && c.data.length > MAX_INLINE_BINARY) {

+ 22 - 2
apps/server/test/mcp.test.ts

@@ -37,6 +37,14 @@ function startUpstream(): Promise<string> {
     mcp.registerTool('echo', { description: '原样返回', inputSchema: { text: z.string() }, annotations: { readOnlyHint: true } }, async ({ text }) => ({ content: [{ type: 'text', text: `echo:${text}` }] }))
     mcp.registerTool('write_note', { description: '写一条笔记', inputSchema: { note: z.string() } }, async ({ note }) => ({ content: [{ type: 'text', text: `saved:${note}` }] }))
     mcp.registerTool('big', { description: '很长的结果', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => ({ content: [{ type: 'text', text: 'x'.repeat(150_000) }] }))
+    mcp.registerTool('export', { description: '导出文件', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => ({
+      content: [
+        { type: 'text', text: '共 3 行' },
+        { type: 'resource', resource: { uri: 'fwh://export/power flow/SN1_2026.csv', mimeType: 'text/csv', text: 'ts,pv\n1,2\n3,4\n' } },
+        { type: 'resource', resource: { uri: 'fwh://export/../../wave.bin?x=1', mimeType: 'application/octet-stream', blob: Buffer.from([0, 1, 2, 255]).toString('base64') } },
+        { type: 'resource', resource: { uri: 'fwh://export/power flow/SN1_2026.csv', mimeType: 'text/csv', text: 'again' } },
+      ],
+    }))
     const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
     res.on('close', () => { void t.close(); void mcp.close() })
     await mcp.connect(t)
@@ -90,7 +98,7 @@ describe('第三方 MCP(远程 HTTP)', () => {
   it('测试连接、登记;密钥不返回,更新时可沿用', async () => {
     const t = await app.mcpServers.test(httpInput(), null)
     expect(t).toMatchObject({ ok: true })
-    expect(t.tools.map(x => [x.name, x.readOnly, x.policy])).toEqual([['echo', true, 'auto'], ['write_note', false, 'ask'], ['big', true, 'auto']])
+    expect(t.tools.map(x => [x.name, x.readOnly, x.policy])).toEqual([['echo', true, 'auto'], ['write_note', false, 'ask'], ['big', true, 'auto'], ['export', true, 'auto']])
     const bad = await app.mcpServers.test(httpInput({ headers: [{ name: 'Authorization', value: 'Bearer wrong' }] }), null)
     expect(bad.ok).toBe(false)
 
@@ -99,7 +107,7 @@ describe('第三方 MCP(远程 HTTP)', () => {
     expect(JSON.stringify(s)).not.toContain('up-secret')
     const updated = app.mcpServers.update(s.id, { ...httpInput({ title: '演示服务2' }), headers: [{ name: 'Authorization', value: null }] })
     expect(updated.title).toBe('演示服务2')
-    expect((await app.mcpServers.refresh(s.id)).tools).toHaveLength(3)
+    expect((await app.mcpServers.refresh(s.id)).tools).toHaveLength(4)
     expect(() => app.mcpServers.create(httpInput())).toThrow(/已被使用/)
     expect(() => app.mcpServers.create(httpInput({ name: 'Bad Name' }))).toThrow(/名称/)
     expect(() => app.mcpServers.create(httpInput({ name: 'x2', headers: [{ name: 'Authorization', value: null }] }))).toThrow(/请填写/)
@@ -133,6 +141,18 @@ describe('第三方 MCP(远程 HTTP)', () => {
     expect(big.length).toBeLessThan(101_000)
     const saved = /保存到 (\S+))/.exec(big)![1]!
     expect(fs.readFileSync(path.join(ctx.workspaceDir, saved), 'utf8')).toHaveLength(150_000)
+
+    // 嵌入资源一律存入工作区,只返回路径;文件名取 URI 最后一段,重名加序号,不会越出 .agent/mcp
+    const exported = (await c.callTool({ name: 'demo__export', arguments: {} })).content as { type: string; text: string }[]
+    expect(exported.map(x => x.type)).toEqual(['text', 'text', 'text', 'text'])
+    expect(exported[0]!.text).toBe('共 3 行')
+    const paths = exported.slice(1).map(x => /^已保存到 (\S+?)(/.exec(x.text)![1]!)
+    expect(paths).toEqual([path.join('.agent', 'mcp', 'SN1_2026.csv'), path.join('.agent', 'mcp', 'wave.bin'), path.join('.agent', 'mcp', 'SN1_2026-2.csv')])
+    expect(exported[1]!.text).toContain('text/csv')
+    expect(exported.map(x => x.text).join('')).not.toContain('ts,pv')
+    expect(fs.readFileSync(path.join(ctx.workspaceDir, paths[0]!), 'utf8')).toBe('ts,pv\n1,2\n3,4\n')
+    expect([...fs.readFileSync(path.join(ctx.workspaceDir, paths[1]!))]).toEqual([0, 1, 2, 255])
+    expect(fs.readFileSync(path.join(ctx.workspaceDir, paths[2]!), 'utf8')).toBe('again')
     await c.close()
 
     const ro = await gateway(runCtx({ mcpServerIds: [id], permissionMode: 'readonly' }))

+ 1 - 1
docs/adr/0021-third-party-mcp-via-gateway.md

@@ -26,7 +26,7 @@
 - **远程服务**:Streamable HTTP,失败时退回旧版 SSE。可配置请求头(如 Bearer 令牌)。需要 OAuth 登录的服务暂不支持。
 - **连接**:按需建立,多个对话共用,空闲 10 分钟关闭;配置变化时重新连接。工具列表缓存在数据库中,引擎列工具时不必每次启动服务。
 - **偶发网络错误**:连接重试一次;只读工具调用重新连接后重试一次;写工具不自动重试,避免重复执行。
-- **结果处理**:超过 10 万字的文本、超过 5 MB 的图片 / 音频存入工作区 `.agent/mcp/`,返回路径。
+- **结果处理**:超过 10 万字的文本、超过 5 MB 的图片 / 音频存入工作区 `.agent/mcp/`,返回路径。嵌入资源(`resource`,如导出的 CSV、文件)**一律**存入 `.agent/mcp/`(文件名取 URI 最后一段,重名加序号;`blob` 解码为二进制),结果中只返回路径、大小和 MIME,不放进上下文(2026-10-24 补充)。
 - **超时**:
   - 第三方工具调用最长 10 分钟(有进度时顺延)
   - 引擎等待网关工具的时间 = 交互超时 + 10 分钟 + 余量,Claude 用 `MCP_TOOL_TIMEOUT`,Codex 用 `tool_timeout_sec`

+ 1 - 1
docs/design/08-platform-connectors.md

@@ -1,6 +1,6 @@
 # 08 内部平台数据接入:MCP Gateway
 
-> **状态:MCP Gateway 已上线(2026-10-23,M2 ②)**,承载内置工具;**第三方 MCP 服务(远程 HTTP / 本地进程)可在后台"MCP 服务"页面添加,经网关转接(2026-10-24,ADR-0021)**。已有 MCP 的平台优先用这种方式接入;需要定制逻辑时再写连接器(§3,仍暂缓)。
+> **状态:MCP Gateway 已上线(2026-10-23,M2 ②)**,承载内置工具;**第三方 MCP 服务(远程 HTTP / 本地进程)可在后台"MCP 服务"页面添加,经网关转接(2026-10-24,ADR-0021)**。工具返回的嵌入资源(导出文件等)一律存入工作区、只返回路径。已有 MCP 的平台优先用这种方式接入;需要定制逻辑时再写连接器(§3,仍暂缓)。
 >
 > 已实现:
 > - `POST /mcp`:无状态 Streamable HTTP(`@modelcontextprotocol/sdk` 1.32.1),每个请求按令牌绑定到对应 Run 的上下文