mcp-servers.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. // 第三方 MCP 服务的登记与管理(ADR-0021)。连接由 mcp-gateway/upstream.ts 管理,运行时经 MCP Gateway 转接
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. import { eq } from 'drizzle-orm'
  5. import { z } from 'zod'
  6. import type { AppSettings, McpSecretEntry, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolDto, McpToolHints, McpToolPolicy } from '@agent-server/shared'
  7. import { config } from '../config'
  8. import type { DB } from '../db'
  9. import { mcpServers, profiles, type McpCachedTool, type McpFeatures, type McpOAuthState } from '../db/schema'
  10. import { badRequest, notFound } from '../lib/errors'
  11. import { newId, nowIso } from '../lib/ids'
  12. import { isWithin, realpathLoose } from '../lib/paths'
  13. import { BUILTIN_TOOL_INFO } from '../mcp-gateway/builtin'
  14. import { BWRAP, mcpHomeDir, McpUpstream, type McpServerRow } from '../mcp-gateway/upstream'
  15. import { globalDenyList } from './access-policy'
  16. const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/
  17. const ENV_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
  18. const HEADER_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/
  19. const secretSchema = z.object({ name: z.string().min(1).max(200), value: z.string().max(20_000).nullable() })
  20. const inputSchema = z.object({
  21. name: z.string().regex(NAME_RE, '名称只能用小写字母、数字、下划线、连字符(1-32 位,以字母或数字开头)'),
  22. title: z.string().trim().min(1).max(60),
  23. description: z.string().max(1000).default(''),
  24. transport: z.enum(['http', 'stdio']),
  25. enabled: z.boolean().default(true),
  26. url: z.string().max(2000).default(''),
  27. headers: z.array(secretSchema).max(50).default([]),
  28. oauth: z.object({
  29. enabled: z.boolean(), clientId: z.string().trim().max(500).default(''), clientSecret: z.string().max(2000).nullable().default(null), scope: z.string().trim().max(1000).default(''),
  30. }).optional(),
  31. command: z.string().max(500).default(''),
  32. args: z.array(z.string().max(2000)).max(100).default([]),
  33. env: z.array(secretSchema).max(100).default([]),
  34. allowNetwork: z.boolean().default(false),
  35. readPaths: z.array(z.string()).max(50).default([]),
  36. writePaths: z.array(z.string()).max(50).default([]),
  37. })
  38. export interface EffectiveHints {
  39. readOnly: boolean
  40. /** 只认明确声明(或管理员设置)的 true:规范的默认值 true 不能用来强制审批 */
  41. destructive: boolean
  42. idempotent: boolean
  43. openWorld: boolean
  44. }
  45. /** 生效的工具提示:管理员的覆盖优先,其次是服务的声明(docs/design/16-mcp-support.md §2.1) */
  46. export function effectiveHints(t: McpCachedTool, o: McpToolHints = {}): EffectiveHints {
  47. const readOnly = o.readOnly ?? t.readOnly
  48. return {
  49. readOnly,
  50. destructive: !readOnly && (o.destructive ?? t.annotations?.destructiveHint === true),
  51. idempotent: readOnly || (o.idempotent ?? t.annotations?.idempotentHint === true),
  52. openWorld: t.annotations?.openWorldHint !== false,
  53. }
  54. }
  55. /** 没有单独设置时:只读工具直接调用;明确声明破坏性的总是审批;其余跟随权限模式 */
  56. export const defaultPolicy = (h: Pick<EffectiveHints, 'readOnly' | 'destructive'>): McpToolPolicy => (h.readOnly ? 'auto' : h.destructive ? 'confirm' : 'ask')
  57. /** 工具的生效提示与策略 */
  58. export function toolState(r: Pick<McpServerRow, 'toolHints' | 'toolPolicies'>, t: McpCachedTool): { hints: EffectiveHints; policy: McpToolPolicy } {
  59. const hints = effectiveHints(t, r.toolHints[t.name])
  60. return { hints, policy: r.toolPolicies[t.name] ?? defaultPolicy(hints) }
  61. }
  62. export class McpServerService {
  63. private refreshing = new Map<string, NodeJS.Timeout>()
  64. constructor(private db: DB, private upstream: McpUpstream, private settings: () => AppSettings, private audit: (summary: string) => void) {
  65. // 服务通知工具列表变化,或新建了连接:重新列出工具(合并 2 秒内的多次通知,docs/design/16-mcp-support.md §2.3)
  66. upstream.onToolsChanged = id => {
  67. clearTimeout(this.refreshing.get(id))
  68. this.refreshing.set(id, setTimeout(() => {
  69. this.refreshing.delete(id)
  70. const r = this.db.select().from(mcpServers).where(eq(mcpServers.id, id)).get()
  71. if (r?.enabled) void this.listToolsFor(r).catch(() => {})
  72. }, 2000))
  73. }
  74. }
  75. state(): McpStateDto {
  76. return { builtin: BUILTIN_TOOL_INFO, servers: this.list(), sandboxAvailable: !!BWRAP }
  77. }
  78. list(): McpServerDto[] {
  79. const usedBy = this.usage()
  80. return this.db.select().from(mcpServers).orderBy(mcpServers.name).all().map(r => this.toDto(r, usedBy.get(r.id) ?? []))
  81. }
  82. get(id: string): McpServerDto {
  83. return this.toDto(this.row(id), this.usage().get(id) ?? [])
  84. }
  85. /** 运行时:取 Profile 勾选且已启用的服务 */
  86. rows(ids: string[]): McpServerRow[] {
  87. if (!ids.length) return []
  88. return this.db.select().from(mcpServers).all().filter(r => r.enabled && ids.includes(r.id))
  89. }
  90. create(input: McpServerInput): McpServerDto {
  91. const data = this.validate(input, null)
  92. if (this.db.select().from(mcpServers).where(eq(mcpServers.name, data.name)).get()) throw badRequest(`名称 ${data.name} 已被使用`)
  93. const id = newId('mcp')
  94. this.db.insert(mcpServers).values({ id, ...data }).run()
  95. this.audit(`添加 MCP 服务 ${data.name}(${data.transport === 'http' ? data.url : data.command})`)
  96. return this.get(id)
  97. }
  98. update(id: string, input: McpServerInput): McpServerDto {
  99. const prev = this.row(id)
  100. const data = this.validate(input, prev)
  101. const dup = this.db.select().from(mcpServers).where(eq(mcpServers.name, data.name)).get()
  102. if (dup && dup.id !== id) throw badRequest(`名称 ${data.name} 已被使用`)
  103. this.db.update(mcpServers).set({ ...data, updatedAt: nowIso() }).where(eq(mcpServers.id, id)).run()
  104. this.audit(`修改 MCP 服务 ${data.name}`)
  105. return this.get(id)
  106. }
  107. async delete(id: string) {
  108. const r = this.row(id)
  109. // 从引用它的 Profile 中移除
  110. for (const p of this.db.select().from(profiles).all()) {
  111. if (p.mcpServerIds.includes(id)) this.db.update(profiles).set({ mcpServerIds: p.mcpServerIds.filter(x => x !== id) }).where(eq(profiles.id, p.id)).run()
  112. }
  113. this.db.delete(mcpServers).where(eq(mcpServers.id, id)).run()
  114. await this.upstream.close(id)
  115. fs.rmSync(mcpHomeDir(id), { recursive: true, force: true })
  116. this.audit(`删除 MCP 服务 ${r.name}`)
  117. }
  118. /** 重新连接并更新工具列表 */
  119. async refresh(id: string): Promise<McpServerDto> {
  120. const r = this.row(id)
  121. await this.upstream.close(id)
  122. try {
  123. await this.listToolsFor(r)
  124. } catch {
  125. // 错误记录在连接状态中,随 DTO 返回
  126. }
  127. return this.get(id)
  128. }
  129. async listToolsFor(r: McpServerRow): Promise<McpCachedTool[]> {
  130. const tools = await this.upstream.listTools(r)
  131. const features = await this.upstream.features(r).catch(() => r.features)
  132. this.updateTools(r, tools, features)
  133. return tools
  134. }
  135. /** 总是在后台执行(docs/design/15-background-calls.md §3) */
  136. setToolBackground(id: string, tool: string, background: boolean): McpServerDto {
  137. const r = this.row(id)
  138. const list = r.backgroundTools.filter(t => t !== tool)
  139. this.db.update(mcpServers).set({ backgroundTools: background ? [...list, tool] : list, updatedAt: nowIso() }).where(eq(mcpServers.id, id)).run()
  140. this.audit(`MCP 工具 ${r.name}__${tool} ${background ? '设为总是后台执行' : '取消总是后台执行'}`)
  141. return this.get(id)
  142. }
  143. setToolPolicy(id: string, tool: string, policy: McpToolPolicy): McpServerDto {
  144. const r = this.row(id)
  145. this.db.update(mcpServers).set({ toolPolicies: { ...r.toolPolicies, [tool]: policy }, updatedAt: nowIso() }).where(eq(mcpServers.id, id)).run()
  146. this.audit(`MCP 工具 ${r.name}__${tool} 设为 ${({ auto: '直接调用', ask: '跟随权限模式', confirm: '总是审批', off: '关闭' } as const)[policy]}`)
  147. return this.get(id)
  148. }
  149. /** 覆盖工具提示;某一项为 undefined 表示沿用服务的声明 */
  150. setToolHints(id: string, tool: string, hints: McpToolHints): McpServerDto {
  151. const r = this.row(id)
  152. const clean: McpToolHints = {}
  153. for (const k of ['readOnly', 'destructive', 'idempotent'] as const) if (typeof hints[k] === 'boolean') clean[k] = hints[k]
  154. const next = { ...r.toolHints }
  155. if (Object.keys(clean).length) next[tool] = clean
  156. else delete next[tool]
  157. this.db.update(mcpServers).set({ toolHints: next, updatedAt: nowIso() }).where(eq(mcpServers.id, id)).run()
  158. const label = { readOnly: '只读', destructive: '破坏性', idempotent: '幂等' } as const
  159. const desc = Object.entries(clean).map(([k, v]) => `${label[k as keyof McpToolHints]}=${v ? '是' : '否'}`).join('、') || '沿用服务的声明'
  160. this.audit(`MCP 工具 ${r.name}__${tool} 的提示:${desc}`)
  161. return this.get(id)
  162. }
  163. /** 测试一份配置(新建前或修改前);已登记的服务可以只传新值,未修改的密钥沿用原值 */
  164. async test(input: McpServerInput, id: string | null): Promise<McpTestResultDto> {
  165. const prev = id ? this.row(id) : null
  166. const data = this.validate(input, prev, true)
  167. const t0 = Date.now()
  168. const tempId = id ?? `test_${Date.now()}`
  169. try {
  170. const { tools, stderr } = await this.upstream.test({ ...(prev ?? emptyRow()), ...data, id: tempId })
  171. return { ok: true, tools: tools.map(t => toToolDto(t, prev ?? { toolPolicies: {}, toolHints: {}, backgroundTools: [] })), error: null, stderrTail: stderr.length ? stderr.join('\n') : null, ms: Date.now() - t0 }
  172. } catch (err) {
  173. return { ok: false, tools: [], error: (err as Error).message, stderrTail: null, ms: Date.now() - t0 }
  174. } finally {
  175. // 未登记的服务测试时使用的临时数据目录
  176. if (!id) fs.rmSync(mcpHomeDir(tempId), { recursive: true, force: true })
  177. }
  178. }
  179. /**
  180. * 工具列表或能力有变化时才更新(toolsUpdatedAt 是 Codex 会话的工具版本之一,变化会触发分叉,ADR-0020);
  181. * 第一次列出时也记录时间
  182. */
  183. private updateTools(r: McpServerRow, tools: McpCachedTool[], features: McpFeatures) {
  184. const cur = this.db.select({ tools: mcpServers.toolsCache, features: mcpServers.features, at: mcpServers.toolsUpdatedAt }).from(mcpServers).where(eq(mcpServers.id, r.id)).get()
  185. if (cur?.at && JSON.stringify(cur.tools) === JSON.stringify(tools) && JSON.stringify(cur.features) === JSON.stringify(features)) return
  186. this.db.update(mcpServers).set({ toolsCache: tools, features, toolsUpdatedAt: nowIso() }).where(eq(mcpServers.id, r.id)).run()
  187. }
  188. private row(id: string): McpServerRow {
  189. const r = this.db.select().from(mcpServers).where(eq(mcpServers.id, id)).get()
  190. if (!r) throw notFound('MCP 服务')
  191. return r
  192. }
  193. private usage(): Map<string, string[]> {
  194. const out = new Map<string, string[]>()
  195. for (const p of this.db.select({ name: profiles.name, ids: profiles.mcpServerIds }).from(profiles).all()) {
  196. for (const id of p.ids) out.set(id, [...(out.get(id) ?? []), p.name])
  197. }
  198. return out
  199. }
  200. private toDto(r: McpServerRow, profileNames: string[]): McpServerDto {
  201. const masked = (rec: Record<string, string>): McpSecretEntry[] => Object.keys(rec).map(name => ({ name, value: null, isSet: rec[name] !== '' }))
  202. return {
  203. id: r.id, name: r.name, title: r.title, description: r.description, transport: r.transport, enabled: r.enabled,
  204. url: r.url, headers: masked(r.headers),
  205. oauth: r.oauth ? { enabled: r.oauth.enabled, clientId: r.oauth.clientId, clientSecret: null, scope: r.oauth.scope } : { enabled: false, clientId: '', clientSecret: null, scope: '' },
  206. oauthStatus: r.oauth?.enabled ? {
  207. authorized: !!r.oauth.tokens, authorizedAt: r.oauth.authorizedAt ?? null, clientSecretSet: !!r.oauth.clientSecret,
  208. registered: !!(r.oauth.clientId || r.oauth.clientInfo), lastError: r.oauth.lastError ?? null,
  209. } : null,
  210. command: r.command, args: r.args, env: masked(r.env),
  211. allowNetwork: r.allowNetwork, readPaths: r.readPaths, writePaths: r.writePaths,
  212. tools: r.toolsCache.map(t => toToolDto(t, r)), toolsUpdatedAt: r.toolsUpdatedAt, features: { resources: !!r.features.resources, prompts: !!r.features.prompts },
  213. ...this.upstream.status(r.id), profileNames, createdAt: r.createdAt, updatedAt: r.updatedAt,
  214. }
  215. }
  216. private validate(input: McpServerInput, prev: McpServerRow | null, forTest = false) {
  217. const r = inputSchema.safeParse(input)
  218. if (!r.success) {
  219. const i = r.error.issues[0]!
  220. throw badRequest(`${i.path.length ? `${i.path.join('.')}:` : ''}${i.message}`)
  221. }
  222. const d = r.data
  223. if (d.transport === 'http') {
  224. let u: URL
  225. try {
  226. u = new URL(d.url)
  227. } catch {
  228. throw badRequest('服务地址不是有效的 URL')
  229. }
  230. if (!['http:', 'https:'].includes(u.protocol)) throw badRequest('服务地址只支持 http / https')
  231. } else {
  232. if (!d.command.trim()) throw badRequest('请填写启动命令')
  233. if (!BWRAP && !forTest) throw badRequest('服务器上没有 bubblewrap(bwrap),无法运行本地 MCP 服务')
  234. }
  235. for (const h of d.headers) if (!HEADER_RE.test(h.name)) throw badRequest(`请求头名称不正确:${h.name}`)
  236. for (const e of d.env) if (!ENV_RE.test(e.name)) throw badRequest(`环境变量名称不正确:${e.name}`)
  237. return {
  238. name: d.name, title: d.title, description: d.description, transport: d.transport, enabled: d.enabled,
  239. url: d.transport === 'http' ? d.url.trim() : '',
  240. headers: d.transport === 'http' ? mergeSecrets(d.headers, prev?.headers ?? {}) : {},
  241. oauth: d.transport === 'http' ? mergeOAuth(d.oauth, prev?.oauth ?? null) : null,
  242. command: d.transport === 'stdio' ? d.command.trim() : '',
  243. args: d.transport === 'stdio' ? d.args : [],
  244. env: d.transport === 'stdio' ? mergeSecrets(d.env, prev?.env ?? {}) : {},
  245. allowNetwork: d.transport === 'stdio' && d.allowNetwork,
  246. readPaths: d.transport === 'stdio' ? this.checkPaths(d.readPaths, 'read') : [],
  247. writePaths: d.transport === 'stdio' ? this.checkPaths(d.writePaths, 'write') : [],
  248. }
  249. }
  250. /** 沙箱中额外开放的路径:必须是存在的绝对路径;不能是全局禁止项、服务数据目录或会话工作区 */
  251. private checkPaths(paths: string[], kind: 'read' | 'write'): string[] {
  252. const s = this.settings()
  253. const deny = globalDenyList(s)
  254. return paths.map(p => p.trim()).filter(Boolean).map(p => {
  255. if (!path.isAbsolute(p)) throw badRequest(`路径必须是绝对路径:${p}`)
  256. const real = realpathLoose(p)
  257. if (!fs.existsSync(real)) throw badRequest(`路径不存在:${p}`)
  258. if (real === '/') throw badRequest('不能开放根目录')
  259. for (const protectedDir of [config.dataDir, s.paths.workspaceRoot]) {
  260. if (isWithin(real, realpathLoose(protectedDir)) || isWithin(realpathLoose(protectedDir), real)) throw badRequest(`不能开放服务数据目录或会话工作区:${p}`)
  261. }
  262. const hit = deny.find(d => !d.includes('*') && (isWithin(real, d) || isWithin(d, real)))
  263. if (hit) throw badRequest(`${kind === 'write' ? '可写' : '只读'}路径与全局禁止项冲突:${hit}`)
  264. return real
  265. })
  266. }
  267. }
  268. /** OAuth 配置:密钥为 null 时沿用原值;Client ID 变化时清除注册信息与令牌 */
  269. function mergeOAuth(input: { enabled: boolean; clientId: string; clientSecret: string | null; scope: string } | undefined, prev: McpOAuthState | null): McpOAuthState | null {
  270. if (!input) return prev
  271. const clientSecret = input.clientSecret ?? (prev && prev.clientId === input.clientId ? prev.clientSecret : '')
  272. const same = prev && prev.clientId === input.clientId && prev.scope === input.scope
  273. return { ...(same ? prev : {}), enabled: input.enabled, clientId: input.clientId, clientSecret, scope: input.scope }
  274. }
  275. /** 密钥:value 为 null 表示保留原值 */
  276. function mergeSecrets(entries: { name: string; value: string | null }[], prev: Record<string, string>): Record<string, string> {
  277. const out: Record<string, string> = {}
  278. for (const e of entries) {
  279. const v = e.value ?? prev[e.name]
  280. if (v === undefined) throw badRequest(`请填写 ${e.name} 的值`)
  281. out[e.name] = v
  282. }
  283. return out
  284. }
  285. function toToolDto(t: McpCachedTool, r: Pick<McpServerRow, 'toolPolicies' | 'toolHints' | 'backgroundTools'>): McpToolDto {
  286. const { hints, policy } = toolState(r, t)
  287. const a = t.annotations ?? {}
  288. return {
  289. name: t.name, title: t.title ?? null, description: t.description, ...hints,
  290. declared: { readOnly: a.readOnlyHint ?? (t.readOnly || null), destructive: a.destructiveHint ?? null, idempotent: a.idempotentHint ?? null, openWorld: a.openWorldHint ?? null },
  291. overrides: r.toolHints[t.name] ?? {}, policy, background: r.backgroundTools.includes(t.name),
  292. hasOutputSchema: !!t.outputSchema, taskSupport: t.taskSupport ?? null,
  293. }
  294. }
  295. function emptyRow(): McpServerRow {
  296. const now = nowIso()
  297. return {
  298. id: '', name: '', title: '', description: '', transport: 'http', enabled: true, url: '', headers: {}, command: '', args: [], env: {},
  299. allowNetwork: false, readPaths: [], writePaths: [], toolPolicies: {}, toolHints: {}, backgroundTools: [], oauth: null, features: {}, toolsCache: [], toolsUpdatedAt: null, createdAt: now, updatedAt: now,
  300. }
  301. }