server.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. // MCP Gateway:服务进程内的 Streamable HTTP MCP 服务(/mcp),两个引擎共用同一套工具(ADR-0006)。
  2. // 提供内置工具(builtin.ts),并转接 Profile 勾选的第三方 MCP 服务(ADR-0021):工具名为 <服务名>__<工具名>,
  3. // 调用前按工具策略与权限模式审批,结果过长时截断并把完整内容存入工作区
  4. import crypto from 'node:crypto'
  5. import fs from 'node:fs'
  6. import path from 'node:path'
  7. import type { FastifyInstance } from 'fastify'
  8. import { Server } from '@modelcontextprotocol/sdk/server/index.js'
  9. import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
  10. import { CallToolRequestSchema, ListToolsRequestSchema, type Tool } from '@modelcontextprotocol/sdk/types.js'
  11. import { z } from 'zod'
  12. import type { AppSettings } from '@agent-server/shared'
  13. import type { McpCachedTool } from '../db/schema'
  14. import { defaultPolicy, type McpServerService } from '../services/mcp-servers'
  15. import { BUILTIN_TOOLS, fail, type ToolContent, type ToolResult } from './builtin'
  16. import type { GatewayRunContext, RunTokenRegistry } from './tokens'
  17. import { isTransient, type McpServerRow, type McpUpstream } from './upstream'
  18. export const GATEWAY_SERVER = 'gateway'
  19. export const GATEWAY_TOOLS = BUILTIN_TOOLS.map(t => t.name)
  20. /** 第三方工具名中服务名与工具名的分隔符 */
  21. export const MCP_SEP = '__'
  22. const MAX_TEXT = 100_000
  23. const MAX_INLINE_BINARY = 5 * 1048576
  24. const CALL_TIMEOUT_MS = 10 * 60_000
  25. const LIST_TIMEOUT_MS = 20_000
  26. /** 引擎等待网关工具的时间(秒):交互超时 + 第三方工具最长执行时间 + 余量 */
  27. export const gatewayToolTimeoutSec = (s: AppSettings) => s.limits.interactionTimeoutSec + CALL_TIMEOUT_MS / 1000 + 60
  28. export interface GatewayDeps {
  29. mcp: McpServerService
  30. upstream: McpUpstream
  31. }
  32. /** 会话内"本会话允许"过的第三方工具 */
  33. const sessionAllowed = new Map<string, Set<string>>()
  34. function builtinTools(): Tool[] {
  35. return BUILTIN_TOOLS.map(t => ({
  36. name: t.name, description: t.description,
  37. inputSchema: z.toJSONSchema(t.schema) as Tool['inputSchema'],
  38. ...(t.readOnly ? { annotations: { readOnlyHint: true } } : {}),
  39. }))
  40. }
  41. /** 这次对话可用的第三方工具(只读模式下只提供只读工具) */
  42. async function externalTools(ctx: GatewayRunContext, deps: GatewayDeps): Promise<{ row: McpServerRow; tool: McpCachedTool; exposed: string }[]> {
  43. const out: { row: McpServerRow; tool: McpCachedTool; exposed: string }[] = []
  44. for (const row of deps.mcp.rows(ctx.mcpServerIds)) {
  45. let tools = row.toolsCache
  46. if (!tools.length) {
  47. tools = await Promise.race([
  48. deps.mcp.listToolsFor(row).catch(() => [] as McpCachedTool[]),
  49. new Promise<McpCachedTool[]>(r => setTimeout(() => r([]), LIST_TIMEOUT_MS)),
  50. ])
  51. }
  52. for (const tool of tools) {
  53. const policy = row.toolPolicies[tool.name] ?? defaultPolicy(tool)
  54. if (policy === 'off' || (ctx.permissionMode === 'readonly' && !tool.readOnly)) continue
  55. out.push({ row, tool, exposed: `${row.name}${MCP_SEP}${tool.name}`.slice(0, 64) })
  56. }
  57. }
  58. return out
  59. }
  60. function buildServer(ctx: GatewayRunContext, deps: GatewayDeps): Server {
  61. const server = new Server({ name: 'agent-server-gateway', version: '0.1.0' }, { capabilities: { tools: {} } })
  62. server.setRequestHandler(ListToolsRequestSchema, async () => ({
  63. tools: [
  64. ...builtinTools(),
  65. ...(await externalTools(ctx, deps)).map(({ row, tool, exposed }) => ({
  66. name: exposed,
  67. description: `[${row.title}] ${tool.description}`.slice(0, 2000),
  68. inputSchema: (tool.inputSchema?.type === 'object' ? tool.inputSchema : { type: 'object', properties: {} }) as Tool['inputSchema'],
  69. ...(tool.readOnly ? { annotations: { readOnlyHint: true } } : {}),
  70. })),
  71. ],
  72. }))
  73. server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
  74. const name = req.params.name
  75. const args = (req.params.arguments ?? {}) as Record<string, unknown>
  76. const started = Date.now()
  77. const audit = (summary: string, detail: Record<string, unknown> = {}) =>
  78. ctx.audit('gateway_call', summary, { args, ms: Date.now() - started, engine: ctx.provider, ...detail })
  79. const builtin = BUILTIN_TOOLS.find(t => t.name === name)
  80. if (builtin) {
  81. const parsed = builtin.schema.safeParse(args)
  82. if (!parsed.success) return fail(`参数不正确:${parsed.error.issues.map(i => `${i.path.join('.')} ${i.message}`).join(';')}`)
  83. try {
  84. const r = await builtin.handler(parsed.data as Record<string, unknown>, ctx)
  85. audit(`${name}${r.isError ? '(失败)' : ''}`)
  86. return r
  87. } catch (err) {
  88. audit(`${name}(异常)`, { error: (err as Error).message })
  89. return fail((err as Error).message)
  90. }
  91. }
  92. const found = (await externalTools(ctx, deps)).find(t => t.exposed === name)
  93. if (!found) return fail(`工具不存在或当前不可用:${name}`)
  94. const { row, tool } = found
  95. const policy = row.toolPolicies[tool.name] ?? defaultPolicy(tool)
  96. // 需要审批:跟随权限模式(需审批时询问;自动模式直接调用;只读模式已在列表中排除非只读工具)
  97. if (policy === 'ask' && ctx.permissionMode === 'ask' && !sessionAllowed.get(ctx.sessionId)?.has(name)) {
  98. const outcome = await ctx.interact({
  99. kind: 'approval', tool: `mcp__${GATEWAY_SERVER}__${name}`, title: `${row.title} · ${tool.name}`,
  100. input: args, detail: { reason: tool.description.slice(0, 300) || undefined }, options: ['allow_once', 'allow_session', 'deny'],
  101. })
  102. ctx.audit('approval', `MCP 工具 ${name}:${outcome.resolution}`, { args })
  103. if (outcome.resolution !== 'allowed') return fail(`用户拒绝了这次调用${outcome.resolution === 'denied' && outcome.reason ? `:${outcome.reason}` : ''}`)
  104. if (outcome.scope === 'session') sessionAllowed.set(ctx.sessionId, new Set([...(sessionAllowed.get(ctx.sessionId) ?? []), name]))
  105. }
  106. try {
  107. const call = () => deps.upstream.callTool(row, tool.name, args, extra.signal, CALL_TIMEOUT_MS) as Promise<{ content?: ToolContent[]; structuredContent?: unknown; isError?: boolean }>
  108. // 偶发的网络错误:只读工具重新连接后重试一次;其他工具不重试,避免重复执行
  109. const r = await call().catch(async err => {
  110. if (!tool.readOnly || !isTransient(err)) throw err
  111. await deps.upstream.close(row.id)
  112. return call()
  113. })
  114. const result = postProcess(r, ctx.workspaceDir, name)
  115. audit(`${name}${result.isError ? '(失败)' : ''}`, { server: row.name })
  116. return result
  117. } catch (err) {
  118. audit(`${name}(异常)`, { server: row.name, error: (err as Error).message })
  119. return fail(`调用 ${row.title} 失败:${(err as Error).message}`)
  120. }
  121. })
  122. return server
  123. }
  124. /** 资源文件名:取 URI 最后一段,只保留安全字符;取不到时用工具名 */
  125. function resourceFileName(uri: unknown, tool: string): string {
  126. let last = ''
  127. if (typeof uri === 'string') {
  128. const noQuery = uri.split(/[?#]/)[0]!
  129. last = noQuery.slice(noQuery.lastIndexOf('/') + 1)
  130. try {
  131. last = decodeURIComponent(last)
  132. } catch {
  133. // 保留原文
  134. }
  135. }
  136. const safe = last.replace(/[^\p{L}\p{N}._-]+/gu, '_').replace(/^[._]+/, '').slice(0, 120)
  137. return safe || `${tool.replace(/[^\w.-]/g, '_')}.bin`
  138. }
  139. const formatBytes = (n: number) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`)
  140. /**
  141. * 只保留引擎能处理的内容;过长的文本和过大的二进制存入工作区 .agent/mcp/ 并返回路径。
  142. * resource(嵌入资源,如导出的 CSV、文件)一律存入工作区,只返回路径,不放进上下文
  143. */
  144. function postProcess(r: { content?: ToolContent[]; structuredContent?: unknown; isError?: boolean }, workspaceDir: string, tool: string): ToolResult {
  145. const dir = path.join(workspaceDir, '.agent', 'mcp')
  146. const save = (ext: string, data: string | Buffer) => {
  147. fs.mkdirSync(dir, { recursive: true })
  148. const file = path.join(dir, `${tool.replace(/[^\w.-]/g, '_')}-${Date.now()}-${crypto.randomBytes(3).toString('hex')}.${ext}`)
  149. fs.writeFileSync(file, data)
  150. return path.relative(workspaceDir, file)
  151. }
  152. /** 按资源原名保存,重名时加序号 */
  153. const saveNamed = (name: string, data: string | Buffer) => {
  154. fs.mkdirSync(dir, { recursive: true })
  155. const ext = path.extname(name)
  156. const stem = name.slice(0, name.length - ext.length)
  157. let file = path.join(dir, name)
  158. for (let i = 2; fs.existsSync(file); i++) file = path.join(dir, `${stem}-${i}${ext}`)
  159. fs.writeFileSync(file, data)
  160. return path.relative(workspaceDir, file)
  161. }
  162. const content: ToolContent[] = []
  163. for (const c of r.content ?? []) {
  164. if (c.type === 'resource' && c.resource && typeof c.resource === 'object') {
  165. const res = c.resource as { uri?: unknown; mimeType?: unknown; text?: unknown; blob?: unknown }
  166. const data = typeof res.blob === 'string' ? Buffer.from(res.blob, 'base64') : typeof res.text === 'string' ? res.text : null
  167. if (data === null) {
  168. content.push({ type: 'text', text: '(资源没有内容)' })
  169. continue
  170. }
  171. const saved = saveNamed(resourceFileName(res.uri, tool), data)
  172. const size = typeof data === 'string' ? Buffer.byteLength(data) : data.length
  173. const mime = typeof res.mimeType === 'string' && res.mimeType ? res.mimeType : typeof data === 'string' ? 'text/plain' : 'application/octet-stream'
  174. content.push({ type: 'text', text: `已保存到 ${saved}(${formatBytes(size)},${mime})` })
  175. } else if (c.type === 'text' && typeof c.text === 'string' && c.text.length > MAX_TEXT) {
  176. const saved = save('txt', c.text)
  177. content.push({ type: 'text', text: `${c.text.slice(0, MAX_TEXT)}\n\n(内容过长,以上为前一部分;完整内容已保存到 ${saved})` })
  178. } else if ((c.type === 'image' || c.type === 'audio') && typeof c.data === 'string' && c.data.length > MAX_INLINE_BINARY) {
  179. const ext = String(c.mimeType ?? '').split('/')[1]?.replace(/[^\w]/g, '') || 'bin'
  180. content.push({ type: 'text', text: `(${c.type === 'image' ? '图片' : '音频'}过大,已保存到 ${save(ext, Buffer.from(c.data, 'base64'))})` })
  181. } else {
  182. content.push(c)
  183. }
  184. }
  185. if (!content.length && r.structuredContent !== undefined) content.push({ type: 'text', text: JSON.stringify(r.structuredContent, null, 2).slice(0, MAX_TEXT) })
  186. if (!content.length) content.push({ type: 'text', text: '(无返回内容)' })
  187. return { content, ...(r.isError ? { isError: true } : {}) }
  188. }
  189. export function registerGateway(f: FastifyInstance, tokens: RunTokenRegistry, deps: GatewayDeps) {
  190. f.post('/mcp', async (req, reply) => {
  191. const auth = req.headers.authorization
  192. const ctx = tokens.get(auth?.startsWith('Bearer ') ? auth.slice(7).trim() : undefined)
  193. if (!ctx) return reply.code(401).send({ jsonrpc: '2.0', error: { code: -32001, message: '无效或已过期的运行令牌' }, id: null })
  194. // 无状态模式:每个请求一个 server/transport 实例,绑定到该 Run 的上下文
  195. const server = buildServer(ctx, deps)
  196. const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
  197. reply.hijack()
  198. reply.raw.on('close', () => {
  199. void transport.close()
  200. void server.close()
  201. })
  202. await server.connect(transport)
  203. await transport.handleRequest(req.raw, reply.raw, req.body)
  204. })
  205. const notAllowed = { jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed' }, id: null }
  206. f.get('/mcp', async (_req, reply) => reply.code(405).send(notAllowed))
  207. f.delete('/mcp', async (_req, reply) => reply.code(405).send(notAllowed))
  208. }
  209. /** 会话删除时清除"本会话允许" */
  210. export function forgetGatewaySession(sessionId: string) {
  211. sessionAllowed.delete(sessionId)
  212. }