upstream.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. // 第三方 MCP 服务的连接管理(ADR-0021):MCP Gateway 作为客户端连接它们,把工具转接给两个引擎。
  2. // - http:Streamable HTTP(失败时退回 SSE),请求头可携带令牌
  3. // - stdio:本地进程,在 bubblewrap 沙箱中运行:系统目录只读、独立的可写 home、按配置开放网络与额外路径;
  4. // 宿主 Unix socket(docker.sock 等)不可达;密钥只通过环境变量传给该进程
  5. // 连接按需建立、多个对话共用,空闲 10 分钟后关闭;配置变化时重新连接
  6. import fs from 'node:fs'
  7. import path from 'node:path'
  8. import type { Readable } from 'node:stream'
  9. import { Client } from '@modelcontextprotocol/sdk/client/index.js'
  10. import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
  11. import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
  12. import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
  13. import { APP_VERSION, config } from '../config'
  14. import type { McpCachedTool, mcpServers } from '../db/schema'
  15. import { hostSockets } from '../services/office'
  16. export type McpServerRow = typeof mcpServers.$inferSelect
  17. const CONNECT_TIMEOUT_MS = 120_000
  18. const IDLE_MS = 10 * 60_000
  19. const STDERR_LINES = 40
  20. export const BWRAP = ['/usr/bin/bwrap', '/bin/bwrap'].find(p => fs.existsSync(p)) ?? null
  21. interface Conn {
  22. key: string
  23. client: Client
  24. stderr: string[]
  25. lastUsed: number
  26. /** 正在执行的调用数:有调用时不因空闲关闭(后台调用可能执行数小时) */
  27. inFlight: number
  28. }
  29. /** 影响连接的配置;变化时需要重新连接 */
  30. const connKey = (s: McpServerRow) => JSON.stringify([s.transport, s.url, s.headers, s.command, s.args, s.env, s.allowNetwork, s.readPaths, s.writePaths])
  31. /** 本地进程的独立 home(npx / uvx 缓存等),在数据目录下 */
  32. export const mcpHomeDir = (id: string) => path.join(config.dataDir, 'data', 'mcp', id)
  33. export function stdioSandboxArgs(s: Pick<McpServerRow, 'allowNetwork' | 'readPaths' | 'writePaths'>, home: string): string[] {
  34. return [
  35. '--ro-bind', '/usr', '/usr',
  36. '--ro-bind', '/etc', '/etc',
  37. '--symlink', 'usr/lib', '/lib',
  38. '--symlink', 'usr/lib64', '/lib64',
  39. '--symlink', 'usr/bin', '/bin',
  40. '--symlink', 'usr/sbin', '/sbin',
  41. ...hostSockets().flatMap(p => ['--ro-bind', '/dev/null', p]),
  42. '--dev', '/dev',
  43. '--proc', '/proc',
  44. '--tmpfs', '/tmp',
  45. '--bind', home, home,
  46. ...s.readPaths.flatMap(p => ['--ro-bind', p, p]),
  47. ...s.writePaths.flatMap(p => ['--bind', p, p]),
  48. '--unshare-all',
  49. ...(s.allowNetwork ? ['--share-net'] : []),
  50. '--die-with-parent',
  51. '--new-session',
  52. '--chdir', home,
  53. '--setenv', 'HOME', home,
  54. '--setenv', 'TMPDIR', '/tmp',
  55. '--setenv', 'LANG', 'C.UTF-8',
  56. '--setenv', 'PATH', '/usr/local/bin:/usr/bin:/bin',
  57. ]
  58. }
  59. /** 网络层的偶发错误(连接被重置、超时等),可以安全地重新连接 */
  60. export const isTransient = (err: unknown) => /fetch failed|ECONNRESET|ETIMEDOUT|ECONNREFUSED|EAI_AGAIN|socket hang up|UND_ERR|Connection closed/i.test(String((err as Error)?.message ?? err) + String((err as { cause?: { code?: string } })?.cause?.code ?? ''))
  61. function withTimeout<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
  62. let timer: NodeJS.Timeout
  63. return Promise.race([p, new Promise<T>((_, reject) => { timer = setTimeout(() => reject(new Error(`${what}超时(${Math.round(ms / 1000)} 秒)`)), ms) })])
  64. .finally(() => clearTimeout(timer))
  65. }
  66. export class McpUpstream {
  67. private conns = new Map<string, Conn>()
  68. private pending = new Map<string, Promise<Conn>>()
  69. private errors = new Map<string, { error: string; stderr: string | null }>()
  70. private sweeper: NodeJS.Timeout
  71. constructor() {
  72. this.sweeper = setInterval(() => this.closeIdle(), 60_000)
  73. this.sweeper.unref()
  74. }
  75. status(id: string): { status: 'connected' | 'idle' | 'error'; lastError: string | null; stderrTail: string | null } {
  76. const c = this.conns.get(id)
  77. const e = this.errors.get(id)
  78. if (c) return { status: 'connected', lastError: null, stderrTail: c.stderr.length ? c.stderr.join('\n') : null }
  79. return { status: e ? 'error' : 'idle', lastError: e?.error ?? null, stderrTail: e?.stderr ?? null }
  80. }
  81. /** 列出工具(包括分页) */
  82. async listTools(s: McpServerRow): Promise<McpCachedTool[]> {
  83. const c = await this.conn(s)
  84. return listAll(c.client)
  85. }
  86. async callTool(s: McpServerRow, name: string, args: Record<string, unknown>, signal: AbortSignal, timeoutMs: number) {
  87. const c = await this.conn(s)
  88. c.lastUsed = Date.now()
  89. c.inFlight++
  90. try {
  91. return await c.client.callTool({ name, arguments: args }, undefined, { signal, timeout: timeoutMs, resetTimeoutOnProgress: true, maxTotalTimeout: timeoutMs * 2 })
  92. } finally {
  93. c.inFlight--
  94. c.lastUsed = Date.now()
  95. }
  96. }
  97. /** 用一份配置临时连接并列出工具(后台"测试连接"),不影响正在使用的连接 */
  98. async test(s: McpServerRow): Promise<{ tools: McpCachedTool[]; stderr: string[] }> {
  99. const stderr: string[] = []
  100. const client = await this.connect(s, stderr)
  101. try {
  102. return { tools: await listAll(client), stderr }
  103. } finally {
  104. await client.close().catch(() => {})
  105. }
  106. }
  107. async close(id: string) {
  108. const c = this.conns.get(id)
  109. this.conns.delete(id)
  110. this.errors.delete(id)
  111. await c?.client.close().catch(() => {})
  112. }
  113. async closeAll() {
  114. clearInterval(this.sweeper)
  115. await Promise.all([...this.conns.keys()].map(id => this.close(id)))
  116. }
  117. private closeIdle() {
  118. for (const [id, c] of this.conns) if (c.inFlight === 0 && Date.now() - c.lastUsed > IDLE_MS) void this.close(id)
  119. }
  120. private async conn(s: McpServerRow): Promise<Conn> {
  121. const key = connKey(s)
  122. const existing = this.conns.get(s.id)
  123. if (existing && existing.key === key) {
  124. existing.lastUsed = Date.now()
  125. return existing
  126. }
  127. if (existing) await this.close(s.id)
  128. const inflight = this.pending.get(s.id)
  129. if (inflight) return inflight
  130. const stderr: string[] = []
  131. // 远程服务偶发的网络错误重试一次
  132. const connectOnce = () => this.connect(s, stderr)
  133. const p = connectOnce().catch(err => (s.transport === 'http' && isTransient(err) ? connectOnce() : Promise.reject(err))).then(client => {
  134. const c: Conn = { key, client, stderr, lastUsed: Date.now(), inFlight: 0 }
  135. this.conns.set(s.id, c)
  136. this.errors.delete(s.id)
  137. // 连接断开(进程退出、服务端关闭)后下次调用重新连接
  138. client.onclose = () => {
  139. if (this.conns.get(s.id) === c) this.conns.delete(s.id)
  140. }
  141. return c
  142. }, (err: Error) => {
  143. this.errors.set(s.id, { error: err.message, stderr: stderr.length ? stderr.join('\n') : null })
  144. throw err
  145. }).finally(() => this.pending.delete(s.id))
  146. this.pending.set(s.id, p)
  147. return p
  148. }
  149. private async connect(s: McpServerRow, stderr: string[]): Promise<Client> {
  150. const client = new Client({ name: 'agent-server-gateway', version: APP_VERSION })
  151. if (s.transport === 'http') {
  152. const url = new URL(s.url)
  153. const requestInit: RequestInit = { headers: s.headers }
  154. try {
  155. await withTimeout(client.connect(new StreamableHTTPClientTransport(url, { requestInit })), CONNECT_TIMEOUT_MS, '连接')
  156. } catch (err) {
  157. // 旧版服务只支持 SSE
  158. const fallback = new Client({ name: 'agent-server-gateway', version: APP_VERSION })
  159. try {
  160. await withTimeout(fallback.connect(new SSEClientTransport(url, { requestInit, eventSourceInit: { fetch: (u, init) => fetch(u, { ...init, headers: { ...(init?.headers as Record<string, string>), ...s.headers } }) } })), CONNECT_TIMEOUT_MS, '连接')
  161. return fallback
  162. } catch {
  163. throw err
  164. }
  165. }
  166. return client
  167. }
  168. if (!BWRAP) throw new Error('服务器上没有 bubblewrap(bwrap),无法在沙箱中运行本地 MCP 服务')
  169. const home = mcpHomeDir(s.id)
  170. fs.mkdirSync(home, { recursive: true, mode: 0o700 })
  171. const transport = new StdioClientTransport({
  172. command: BWRAP,
  173. args: [...stdioSandboxArgs(s, home), '--', s.command, ...s.args],
  174. // 密钥只放在环境变量里(不出现在命令行参数中)
  175. env: { ...s.env, HOME: home },
  176. stderr: 'pipe',
  177. cwd: home,
  178. })
  179. ;(transport.stderr as Readable | null)?.on('data', (d: Buffer) => {
  180. for (const line of d.toString('utf8').split('\n')) if (line.trim()) stderr.push(line.slice(0, 500))
  181. if (stderr.length > STDERR_LINES) stderr.splice(0, stderr.length - STDERR_LINES)
  182. })
  183. try {
  184. await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, '启动')
  185. } catch (err) {
  186. await transport.close().catch(() => {})
  187. const tail = stderr.slice(-5).join('\n')
  188. throw new Error(`${(err as Error).message}${tail ? `\n${tail}` : ''}`)
  189. }
  190. return client
  191. }
  192. }
  193. async function listAll(client: Client): Promise<McpCachedTool[]> {
  194. const tools: McpCachedTool[] = []
  195. let cursor: string | undefined
  196. for (let page = 0; page < 20; page++) {
  197. const r = await withTimeout(client.listTools(cursor ? { cursor } : undefined), 60_000, '列出工具')
  198. for (const t of r.tools) {
  199. tools.push({ name: t.name, description: t.description ?? '', inputSchema: t.inputSchema as Record<string, unknown>, readOnly: t.annotations?.readOnlyHint === true })
  200. }
  201. cursor = r.nextCursor
  202. if (!cursor) break
  203. }
  204. return tools
  205. }