Forráskód Böngészése

feat: 第三方 MCP 服务(远程 HTTP / 本地进程),经 MCP Gateway 转接

- 后台新增"MCP 服务"页面:内置工具只读展示;第三方服务的添加、测试连接、刷新工具、逐个工具策略
  (直接调用 / 跟随权限模式 / 关闭)、启停、删除;密钥(请求头、环境变量)只写不读
- 网关作为 MCP 客户端转接第三方服务,工具名 <服务名>__<工具名>,两个引擎共用;按 Profile 勾选(mcpServerIds)开放
- 审批在网关内完成:只读工具默认直接调用,其余跟随权限模式(需审批时弹出卡片,可本会话允许);只读模式只列只读工具
- 本地进程在 bubblewrap 沙箱中运行:系统目录只读、独立 home、默认断网、只开放指定路径,密钥只经环境变量传入
- 远程服务支持 Streamable HTTP(退回 SSE)与自定义请求头;偶发网络错误:连接重试,只读工具调用重试一次
- 过长结果存入工作区;引擎等待网关工具的超时随交互超时调整;Codex 网关地址带工具版本,勾选变化时分叉刷新工具
- 网关改用低层 Server 同时承载内置工具(zod → JSON Schema)与转接工具;审批函数由引擎与网关共用
- 实测:npx 启动的 server-everything 与远程 DeepWiki,Claude 对话中均可调用,非只读工具触发审批
- ADR-0021;08 / 09 文档更新

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 hete
szülő
commit
969a99373b

+ 1 - 0
apps/admin/src/components/Workbench.vue

@@ -16,6 +16,7 @@ const nav = [
   { to: '/profiles', icon: 'codicon-hubot', label: 'Profiles' },
   { to: '/knowledge', icon: 'codicon-library', label: '资料库' },
   { to: '/plugins', icon: 'codicon-extensions', label: '技能与插件' },
+  { to: '/mcp', icon: 'codicon-server-process', label: 'MCP 服务' },
   { to: '/api-keys', icon: 'codicon-key', label: 'API Keys' },
   { to: '/api-docs', icon: 'codicon-book', label: 'API 协议' },
   { to: '/audit', icon: 'codicon-checklist', label: '审计日志' },

+ 10 - 1
apps/admin/src/lib/api.ts

@@ -1,7 +1,7 @@
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 import { AgentClient } from '@agent-server/client'
 import type {
-  ApiKeyDto, ApiKeyProfiles, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, KnowledgeRootDto, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
+  ApiKeyDto, ApiKeyProfiles, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, KnowledgeRootDto, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
   ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto,
 } from '@agent-server/shared'
 
@@ -54,6 +54,15 @@ export const admin = {
     install: (name: string) => req<CodexPluginInstallResultDto>('POST', `/codex-plugins/${encodeURIComponent(name)}/install`),
     uninstall: (name: string) => req<{ ok: true }>('POST', `/codex-plugins/${encodeURIComponent(name)}/uninstall`),
   },
+  mcp: {
+    state: () => req<McpStateDto>('GET', '/mcp'),
+    create: (input: McpServerInput) => req<McpServerDto>('POST', '/mcp/servers', input),
+    update: (id: string, input: McpServerInput) => req<McpServerDto>('PUT', `/mcp/servers/${id}`, input),
+    remove: (id: string) => req<void>('DELETE', `/mcp/servers/${id}`),
+    refresh: (id: string) => req<McpServerDto>('POST', `/mcp/servers/${id}/refresh`),
+    setToolPolicy: (id: string, tool: string, policy: McpToolPolicy) => req<McpServerDto>('PUT', `/mcp/servers/${id}/tools/${encodeURIComponent(tool)}`, { policy }),
+    test: (input: McpServerInput, id: string | null) => req<McpTestResultDto>('POST', '/mcp/test', { input, id }),
+  },
   communitySkills: {
     list: () => req<CommunitySkillDto[]>('GET', '/community-skills'),
     search: (q: string) => req<CommunitySearchResultDto[]>('GET', `/community-skills/search?q=${encodeURIComponent(q)}`),

+ 1 - 0
apps/admin/src/router.ts

@@ -12,6 +12,7 @@ export const router = createRouter({
     { path: '/profiles/:id?', name: 'profiles', component: () => import('./views/ProfilesView.vue') },
     { path: '/knowledge', component: () => import('./views/KnowledgeView.vue') },
     { path: '/plugins', component: () => import('./views/PluginsView.vue') },
+    { path: '/mcp', component: () => import('./views/McpView.vue') },
     { path: '/api-keys', component: () => import('./views/ApiKeysView.vue') },
     { path: '/api-docs', component: () => import('./views/ApiDocsView.vue') },
     { path: '/audit', component: () => import('./views/AuditView.vue') },

+ 311 - 0
apps/admin/src/views/McpView.vue

@@ -0,0 +1,311 @@
+<script setup lang="ts">
+// MCP 服务:内置工具(MCP Gateway 自带)与第三方服务(远程 HTTP / 本地进程,经网关转接,ADR-0021)
+import { onMounted, reactive, ref } from 'vue'
+import type { McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolPolicy } from '@agent-server/shared'
+import PageHeader from '../components/PageHeader.vue'
+import { admin, errorMessage } from '../lib/api'
+import { formatTime, toast } from '../lib/ui'
+
+const state = ref<McpStateDto | null>(null)
+const busy = ref<string | null>(null)
+const expanded = reactive<Record<string, boolean>>({})
+
+const POLICY_LABELS: Record<McpToolPolicy, string> = { auto: '直接调用', ask: '跟随权限模式', off: '关闭' }
+
+async function load() {
+  try {
+    state.value = await admin.mcp.state()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+function replace(s: McpServerDto) {
+  if (!state.value) return
+  const i = state.value.servers.findIndex(x => x.id === s.id)
+  if (i >= 0) state.value.servers[i] = s
+  else state.value.servers.push(s)
+}
+
+async function act(key: string, fn: () => Promise<McpServerDto | void>, done?: string) {
+  busy.value = key
+  try {
+    const r = await fn()
+    if (r) replace(r)
+    if (done) toast(done, 'success')
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    busy.value = null
+  }
+}
+
+const refresh = (s: McpServerDto) => act(`refresh:${s.id}`, async () => {
+  const r = await admin.mcp.refresh(s.id)
+  if (r.lastError) toast(`连接失败:${r.lastError.split('\n')[0]}`, 'error')
+  else toast(`已更新,共 ${r.tools.length} 个工具`, 'success')
+  return r
+})
+const setPolicy = (s: McpServerDto, tool: string, policy: McpToolPolicy) => act(`tool:${s.id}:${tool}`, () => admin.mcp.setToolPolicy(s.id, tool, policy))
+const toggle = (s: McpServerDto) => act(`toggle:${s.id}`, () => admin.mcp.update(s.id, { ...toInput(s), enabled: !s.enabled }), s.enabled ? '已停用' : '已启用')
+async function remove(s: McpServerDto) {
+  if (!window.confirm(`删除 MCP 服务「${s.title}」?使用它的 Profile 将不再提供这些工具${s.transport === 'stdio' ? ',它的本地数据目录也会删除' : ''}。`)) return
+  await act(`del:${s.id}`, async () => {
+    await admin.mcp.remove(s.id)
+    if (state.value) state.value.servers = state.value.servers.filter(x => x.id !== s.id)
+  }, '已删除')
+}
+
+// ---- 编辑 ----
+interface Draft {
+  id: string | null
+  input: McpServerInput
+  argsText: string
+  readText: string
+  writeText: string
+}
+const draft = ref<Draft | null>(null)
+const testing = ref(false)
+const testResult = ref<McpTestResultDto | null>(null)
+const saving = ref(false)
+
+const blank = (): McpServerInput => ({
+  name: '', title: '', description: '', transport: 'http', enabled: true, url: '', headers: [], command: '', args: [], env: [],
+  allowNetwork: false, readPaths: [], writePaths: [],
+})
+function toInput(s: McpServerDto): McpServerInput {
+  return {
+    name: s.name, title: s.title, description: s.description, transport: s.transport, enabled: s.enabled, url: s.url,
+    headers: s.headers.map(h => ({ name: h.name, value: null, isSet: h.isSet })), command: s.command, args: [...s.args],
+    env: s.env.map(e => ({ name: e.name, value: null, isSet: e.isSet })), allowNetwork: s.allowNetwork, readPaths: [...s.readPaths], writePaths: [...s.writePaths],
+  }
+}
+function openEditor(s?: McpServerDto) {
+  const input = s ? toInput(s) : blank()
+  draft.value = { id: s?.id ?? null, input, argsText: input.args.join('\n'), readText: input.readPaths.join('\n'), writeText: input.writePaths.join('\n') }
+  testResult.value = null
+}
+const lines = (t: string) => t.split('\n').map(x => x.trim()).filter(Boolean)
+/** 参数每行一个(保留行内空格,只去掉空行) */
+const argLines = (t: string) => t.split('\n').map(x => x.replace(/\r$/, '')).filter(x => x.trim() !== '')
+function collect(): McpServerInput {
+  const d = draft.value!
+  return {
+    ...d.input,
+    headers: d.input.headers.filter(h => h.name.trim()).map(h => ({ name: h.name.trim(), value: h.value === '' ? null : h.value })),
+    env: d.input.env.filter(e => e.name.trim()).map(e => ({ name: e.name.trim(), value: e.value === '' ? null : e.value })),
+    args: argLines(d.argsText), readPaths: lines(d.readText), writePaths: lines(d.writeText),
+  }
+}
+async function test() {
+  testing.value = true
+  testResult.value = null
+  try {
+    testResult.value = await admin.mcp.test(collect(), draft.value!.id)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    testing.value = false
+  }
+}
+async function save() {
+  saving.value = true
+  try {
+    const d = draft.value!
+    const r = d.id ? await admin.mcp.update(d.id, collect()) : await admin.mcp.create(collect())
+    replace(r)
+    toast(d.id ? '已保存' : '已添加。到 Profile 中勾选后,对话中才能使用这些工具', 'success')
+    draft.value = null
+    if (!d.id) void refresh(r)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    saving.value = false
+  }
+}
+
+onMounted(load)
+</script>
+
+<template>
+  <div class="h-full flex flex-col">
+    <PageHeader title="MCP 服务" subtitle="两个引擎通过 AgentServer 的 MCP Gateway 使用这些工具">
+      <button type="button" class="btn-secondary" @click="load"><span class="codicon codicon-refresh" />刷新</button>
+      <button type="button" class="btn-primary" @click="openEditor()"><span class="codicon codicon-add" />添加服务</button>
+    </PageHeader>
+    <div v-if="state" class="flex-1 overflow-y-auto">
+      <div class="max-w-240 p-6 flex flex-col gap-6">
+        <section class="card p-4 flex gap-3">
+          <span class="codicon codicon-info text-link text-[18px] mt-0.5" />
+          <div class="flex flex-col gap-1.5 text-[12.5px]">
+            <div>所有工具都经由 AgentServer 的 MCP Gateway 提供给 Claude 和 Codex:配置一次,两个引擎共用;密钥只保存在服务端。</div>
+            <div>第三方服务需要<b>在 Profile 中勾选</b>才会出现在对话中;工具名为 <code class="font-mono">服务名__工具名</code>。每个工具可以设为:直接调用、跟随权限模式("需审批"时弹出审批卡片,"自动"时直接调用,"只读"时只提供只读工具)、关闭。</div>
+            <div class="hint">本地进程类服务在独立的沙箱中运行:系统目录只读、独立的数据目录、默认不能联网,只能访问你开放的路径。</div>
+          </div>
+        </section>
+
+        <!-- 内置工具 -->
+        <section class="flex flex-col gap-2">
+          <h2 class="section-title m-0">内置工具(所有 Profile 可用)</h2>
+          <div class="card divide-y divide-x-0 divide-solid divide-border">
+            <div v-for="t in state.builtin" :key="t.name" class="px-4 py-2.5 text-[12.5px]">
+              <div class="flex items-center gap-2">
+                <span class="font-mono font-500">{{ t.name }}</span>
+                <span class="badge bg-code" :class="t.readOnly ? 'text-success' : 'text-warning'">{{ t.readOnly ? '只读' : '会生成或修改文件' }}</span>
+              </div>
+              <div class="hint line-clamp-2">{{ t.description }}</div>
+            </div>
+          </div>
+        </section>
+
+        <!-- 第三方服务 -->
+        <section class="flex flex-col gap-3">
+          <h2 class="section-title m-0">第三方服务</h2>
+          <div v-if="!state.servers.length" class="card p-4 hint">还没有第三方 MCP 服务。点击右上角"添加服务",可以接入远程 MCP 地址,或在本机以沙箱方式运行 MCP 程序(如 npx / uvx 启动的服务)。</div>
+          <div v-for="s in state.servers" :key="s.id" class="card p-4 flex flex-col gap-2" :class="{ 'opacity-70': !s.enabled }">
+            <div class="flex items-center gap-2 flex-wrap">
+              <span class="w-2 h-2 rounded-full flex-none" :class="s.status === 'connected' ? 'bg-success' : s.status === 'error' ? 'bg-danger' : 'bg-[var(--app-input-border)]'" :title="{ connected: '已连接', idle: '未连接(使用时自动连接)', error: '连接失败' }[s.status]" />
+              <span class="font-600">{{ s.title }}</span>
+              <code class="font-mono text-[12px] text-fg-muted">{{ s.name }}__*</code>
+              <span class="badge bg-code">{{ s.transport === 'http' ? '远程 HTTP' : '本地进程' }}</span>
+              <span v-if="!s.enabled" class="badge bg-code text-fg-muted">已停用</span>
+              <span class="flex-1" />
+              <button type="button" class="btn-ghost" :disabled="!!busy" @click="refresh(s)">
+                <span class="codicon" :class="busy === `refresh:${s.id}` ? 'codicon-loading codicon-modifier-spin' : 'codicon-sync'" />刷新工具
+              </button>
+              <button type="button" class="btn-ghost" :disabled="!!busy" @click="toggle(s)">{{ s.enabled ? '停用' : '启用' }}</button>
+              <button type="button" class="btn-ghost" @click="openEditor(s)">编辑</button>
+              <button type="button" class="icon-btn" title="删除" :disabled="!!busy" @click="remove(s)"><span class="codicon codicon-trash" /></button>
+            </div>
+            <div v-if="s.description" class="hint">{{ s.description }}</div>
+            <div class="text-[12px] font-mono text-fg-muted break-all">
+              <template v-if="s.transport === 'http'">{{ s.url }}<template v-if="s.headers.length"> · 请求头 {{ s.headers.map(h => h.name).join('、') }}</template></template>
+              <template v-else>{{ [s.command, ...s.args].join(' ') }}<template v-if="s.env.length"> · 环境变量 {{ s.env.map(e => e.name).join('、') }}</template> · {{ s.allowNetwork ? '可联网' : '不能联网' }}</template>
+            </div>
+            <div class="hint">
+              {{ s.profileNames.length ? `使用它的 Profile:${s.profileNames.join('、')}` : '还没有 Profile 勾选这个服务' }}
+              <template v-if="s.toolsUpdatedAt"> · 工具列表更新于 {{ formatTime(s.toolsUpdatedAt) }}</template>
+            </div>
+            <div v-if="s.lastError" class="text-error text-[12px] whitespace-pre-wrap break-all">连接失败:{{ s.lastError }}</div>
+            <pre v-if="s.stderrTail && s.status === 'error'" class="m-0 p-2 rounded-md bg-code font-mono text-[11.5px] max-h-40 overflow-auto whitespace-pre-wrap">{{ s.stderrTail }}</pre>
+
+            <button type="button" class="self-start border-none bg-transparent p-0 cursor-pointer text-link text-[12px]" @click="expanded[s.id] = !expanded[s.id]">
+              {{ expanded[s.id] ? '收起工具' : `${s.tools.length} 个工具` }}
+            </button>
+            <div v-if="expanded[s.id]" class="flex flex-col divide-y divide-x-0 divide-solid divide-border border border-solid border-border rounded-md">
+              <div v-for="t in s.tools" :key="t.name" class="flex items-start gap-3 px-3 py-2 text-[12.5px]">
+                <div class="flex-1 min-w-0">
+                  <div class="flex items-center gap-2">
+                    <span class="font-mono">{{ t.name }}</span>
+                    <span v-if="t.readOnly" class="badge bg-code text-success">只读</span>
+                  </div>
+                  <div class="hint line-clamp-2">{{ t.description }}</div>
+                </div>
+                <select class="field w-36 flex-none" :value="t.policy" :disabled="!!busy" :aria-label="`${t.name} 的调用策略`" @change="setPolicy(s, t.name, ($event.target as HTMLSelectElement).value as McpToolPolicy)">
+                  <option v-for="(label, k) in POLICY_LABELS" :key="k" :value="k">{{ label }}</option>
+                </select>
+              </div>
+              <div v-if="!s.tools.length" class="px-3 py-2 hint">还没有工具列表,点击"刷新工具"连接服务</div>
+            </div>
+          </div>
+        </section>
+      </div>
+    </div>
+
+    <!-- 添加 / 编辑 -->
+    <div v-if="draft" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="draft = null">
+      <form class="w-[min(720px,94vw)] max-h-[90vh] overflow-y-auto card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]" @submit.prevent="save">
+        <div class="flex items-center gap-2">
+          <span class="font-600 text-[14px] flex-1">{{ draft.id ? `编辑「${draft.input.title}」` : '添加 MCP 服务' }}</span>
+          <button type="button" class="icon-btn" title="关闭" @click="draft = null"><span class="codicon codicon-close" /></button>
+        </div>
+        <div class="flex gap-4">
+          <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="draft.input.transport" type="radio" value="http">远程 HTTP</label>
+          <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="draft.input.transport" type="radio" value="stdio">本地进程(沙箱)</label>
+        </div>
+        <div class="grid grid-cols-2 gap-3">
+          <div>
+            <label class="label" for="m-title">显示名称</label>
+            <input id="m-title" v-model="draft.input.title" class="field" placeholder="例如:家庭记账数据库" required>
+          </div>
+          <div>
+            <label class="label" for="m-name">服务名(工具名前缀)</label>
+            <input id="m-name" v-model="draft.input.name" class="field font-mono" placeholder="例如 ledger" pattern="[a-z0-9][a-z0-9_\-]{0,31}" required>
+          </div>
+        </div>
+        <div>
+          <label class="label" for="m-desc">说明(可选)</label>
+          <input id="m-desc" v-model="draft.input.description" class="field">
+        </div>
+
+        <template v-if="draft.input.transport === 'http'">
+          <div>
+            <label class="label" for="m-url">服务地址</label>
+            <input id="m-url" v-model="draft.input.url" class="field font-mono" placeholder="https://example.com/mcp" required>
+            <div class="hint mt-1">支持 Streamable HTTP,旧版 SSE 服务自动兼容。需要 OAuth 登录的服务暂不支持,可在请求头中填写访问令牌。</div>
+          </div>
+          <div>
+            <div class="label">请求头(如 Authorization: Bearer …)</div>
+            <div v-for="(h, i) in draft.input.headers" :key="i" class="flex gap-2 mb-1.5">
+              <input v-model="h.name" class="field w-48 font-mono" placeholder="名称" :aria-label="`请求头 ${i + 1} 名称`">
+              <input v-model="h.value" type="password" autocomplete="new-password" class="field font-mono" :placeholder="h.isSet ? '已设置,留空保持不变' : '值'" :aria-label="`请求头 ${i + 1} 值`">
+              <button type="button" class="icon-btn" title="移除" @click="draft.input.headers.splice(i, 1)"><span class="codicon codicon-close" /></button>
+            </div>
+            <button type="button" class="btn-ghost" @click="draft.input.headers.push({ name: '', value: '' })"><span class="codicon codicon-add" />添加请求头</button>
+          </div>
+        </template>
+
+        <template v-else>
+          <div v-if="!state?.sandboxAvailable" class="text-error">服务器上没有 bubblewrap(bwrap),无法运行本地 MCP 服务。</div>
+          <div>
+            <label class="label" for="m-cmd">启动命令</label>
+            <input id="m-cmd" v-model="draft.input.command" class="field font-mono" placeholder="例如 npx、uvx、/usr/local/bin/node" required>
+          </div>
+          <div>
+            <label class="label" for="m-args">参数(每行一个)</label>
+            <textarea id="m-args" v-model="draft.argsText" class="field-area h-20" placeholder="-y&#10;@modelcontextprotocol/server-memory@2026.1.0" />
+            <div class="hint mt-1">建议写明版本号。npx / uvx 首次启动需要下载,需勾选"允许联网"。</div>
+          </div>
+          <div>
+            <div class="label">环境变量(密钥只传给这个进程)</div>
+            <div v-for="(e, i) in draft.input.env" :key="i" class="flex gap-2 mb-1.5">
+              <input v-model="e.name" class="field w-48 font-mono" placeholder="名称" :aria-label="`环境变量 ${i + 1} 名称`">
+              <input v-model="e.value" type="password" autocomplete="new-password" class="field font-mono" :placeholder="e.isSet ? '已设置,留空保持不变' : '值'" :aria-label="`环境变量 ${i + 1} 值`">
+              <button type="button" class="icon-btn" title="移除" @click="draft.input.env.splice(i, 1)"><span class="codicon codicon-close" /></button>
+            </div>
+            <button type="button" class="btn-ghost" @click="draft.input.env.push({ name: '', value: '' })"><span class="codicon codicon-add" />添加环境变量</button>
+          </div>
+          <label class="flex items-center gap-2 cursor-pointer"><input v-model="draft.input.allowNetwork" type="checkbox">允许联网(下载依赖、调用外部 API 时需要)</label>
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <label class="label" for="m-read">额外只读路径(每行一个)</label>
+              <textarea id="m-read" v-model="draft.readText" class="field-area h-16" placeholder="/data/ledger" />
+            </div>
+            <div>
+              <label class="label" for="m-write">额外可写路径(每行一个)</label>
+              <textarea id="m-write" v-model="draft.writeText" class="field-area h-16" placeholder="/data/ledger/ledger.db 所在目录" />
+            </div>
+          </div>
+          <div class="hint">系统目录(/usr、/etc)只读可见;不能开放服务数据目录、会话工作区和全局禁止项。</div>
+        </template>
+
+        <div v-if="testResult" class="rounded-md p-2.5 flex flex-col gap-1" :class="testResult.ok ? 'bg-[color-mix(in_srgb,var(--app-success)_12%,transparent)]' : 'bg-[color-mix(in_srgb,var(--app-danger)_12%,transparent)]'">
+          <div class="font-600">{{ testResult.ok ? `连接成功(${testResult.ms} ms),共 ${testResult.tools.length} 个工具` : '连接失败' }}</div>
+          <div v-if="testResult.error" class="whitespace-pre-wrap break-all text-error">{{ testResult.error }}</div>
+          <div v-if="testResult.tools.length" class="flex flex-wrap gap-1.5">
+            <span v-for="t in testResult.tools" :key="t.name" class="badge bg-code font-mono">{{ t.name }}<span v-if="t.readOnly" class="text-success ml-1">只读</span></span>
+          </div>
+          <pre v-if="testResult.stderrTail" class="m-0 p-2 rounded-md bg-code font-mono text-[11px] max-h-32 overflow-auto whitespace-pre-wrap">{{ testResult.stderrTail }}</pre>
+        </div>
+
+        <div class="flex justify-end gap-2">
+          <button type="button" class="btn-secondary" :disabled="testing" @click="test">
+            <span class="codicon" :class="testing ? 'codicon-loading codicon-modifier-spin' : 'codicon-debug-start'" />{{ testing ? '连接中…' : '测试连接' }}
+          </button>
+          <button type="button" class="btn-secondary" @click="draft = null">取消</button>
+          <button type="submit" class="btn-primary" :disabled="saving">{{ saving ? '保存中…' : '保存' }}</button>
+        </div>
+      </form>
+    </div>
+  </div>
+</template>

+ 27 - 4
apps/admin/src/views/ProfilesView.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import { computed, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
-import type { KnowledgeRootDto, PathRule, ProfileDto, ProfileInput, ProfileVersionDto, ProviderStatusDto, ResolvedAccessDto, SkillInfo } from '@agent-server/shared'
+import type { KnowledgeRootDto, McpServerDto, PathRule, ProfileDto, ProfileInput, ProfileVersionDto, ProviderStatusDto, ResolvedAccessDto, SkillInfo } from '@agent-server/shared'
 import PageHeader from '../components/PageHeader.vue'
 import SidePane from '../components/SidePane.vue'
 import { admin, client, errorMessage } from '../lib/api'
@@ -12,6 +12,13 @@ const router = useRouter()
 const profiles = ref<ProfileDto[]>([])
 const knowledge = ref<KnowledgeRootDto[]>([])
 const skills = ref<SkillInfo[]>([])
+const mcpServers = ref<McpServerDto[]>([])
+function toggleMcp(id: string) {
+  const d = draft.value
+  if (!d) return
+  const cur = d.mcpServerIds ?? []
+  d.mcpServerIds = cur.includes(id) ? cur.filter(x => x !== id) : [...cur, id]
+}
 const providers = ref<ProviderStatusDto[]>([])
 const draft = ref<ProfileInput | null>(null)
 const preview = ref<ResolvedAccessDto | null>(null)
@@ -26,7 +33,7 @@ const modelLabels = computed(() => providers.value.find(p => p.id === draft.valu
 const blank = (): ProfileInput => ({
   name: '', description: '', provider: 'claude', model: providers.value.find(p => p.id === 'claude')?.defaultModel ?? 'sonnet',
   systemPrompt: '', permissionMode: 'ask', allowedTools: [], disallowedTools: [], skills: 'all',
-  fileAccess: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRootIds: [], isDefault: false,
+  fileAccess: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRootIds: [], mcpServerIds: [], isDefault: false,
 })
 
 function toInput(p: ProfileDto): ProfileInput {
@@ -35,8 +42,8 @@ function toInput(p: ProfileDto): ProfileInput {
 }
 
 async function load() {
-  ;[profiles.value, knowledge.value, skills.value, providers.value] = await Promise.all([
-    admin.profiles(), admin.knowledge(), admin.skills(), client.providers(),
+  ;[profiles.value, knowledge.value, skills.value, providers.value, mcpServers.value] = await Promise.all([
+    admin.profiles(), admin.knowledge(), admin.skills(), client.providers(), admin.mcp.state().then(s => s.servers).catch(() => []),
   ])
 }
 
@@ -226,6 +233,22 @@ onMounted(() => load().catch(err => toast(errorMessage(err), 'error')))
             <div v-if="!skills.length" class="hint">没有可用技能。可以在"技能与插件"页面安装插件,或在本地技能目录中添加技能。</div>
           </section>
 
+          <section class="flex flex-col gap-2">
+            <div class="label m-0">第三方 MCP 服务</div>
+            <div class="hint">内置工具(文档转换、网页抓取等)始终可用;第三方服务需要在这里勾选。服务在"MCP 服务"页面添加。</div>
+            <label v-for="m in mcpServers" :key="m.id" class="flex items-start gap-2 cursor-pointer" :class="{ 'opacity-60': !m.enabled }">
+              <input type="checkbox" class="mt-1" :checked="(draft.mcpServerIds ?? []).includes(m.id)" @change="toggleMcp(m.id)">
+              <span class="min-w-0">
+                <span>{{ m.title }}</span>
+                <code class="font-mono text-[12px] text-fg-muted ml-1">{{ m.name }}__*</code>
+                <span class="badge bg-code ml-1">{{ m.transport === 'http' ? '远程' : '本地进程' }}</span>
+                <span v-if="!m.enabled" class="badge bg-code text-fg-muted ml-1">已停用</span>
+                <span class="block hint truncate">{{ m.tools.length }} 个工具{{ m.description ? ` · ${m.description}` : '' }}</span>
+              </span>
+            </label>
+            <div v-if="!mcpServers.length" class="hint">还没有第三方 MCP 服务</div>
+          </section>
+
           <section class="grid grid-cols-2 gap-4">
             <div>
               <label class="label" for="p-allow">仅允许这些工具(留空不限制)</label>

+ 24 - 0
apps/server/drizzle/0004_mcp_servers.sql

@@ -0,0 +1,24 @@
+CREATE TABLE `mcp_servers` (
+	`id` text PRIMARY KEY NOT NULL,
+	`name` text NOT NULL,
+	`title` text NOT NULL,
+	`description` text DEFAULT '' NOT NULL,
+	`transport` text NOT NULL,
+	`enabled` integer DEFAULT true NOT NULL,
+	`url` text DEFAULT '' NOT NULL,
+	`headers` text DEFAULT '{}' NOT NULL,
+	`command` text DEFAULT '' NOT NULL,
+	`args` text DEFAULT '[]' NOT NULL,
+	`env` text DEFAULT '{}' NOT NULL,
+	`allow_network` integer DEFAULT false NOT NULL,
+	`read_paths` text DEFAULT '[]' NOT NULL,
+	`write_paths` text DEFAULT '[]' NOT NULL,
+	`tool_policies` text DEFAULT '{}' NOT NULL,
+	`tools_cache` text DEFAULT '[]' NOT NULL,
+	`tools_updated_at` text,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `mcp_servers_name_unique` ON `mcp_servers` (`name`);--> statement-breakpoint
+ALTER TABLE `profiles` ADD `mcp_server_ids` text DEFAULT '[]' NOT NULL;

+ 1246 - 0
apps/server/drizzle/meta/0004_snapshot.json

@@ -0,0 +1,1246 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "200efeae-003d-487f-852a-e9c4403fd3c4",
+  "prevId": "23a4fdb9-89c3-4826-a763-de1fc9ab358a",
+  "tables": {
+    "admin_sessions": {
+      "name": "admin_sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "api_keys": {
+      "name": "api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_profile_ids": {
+          "name": "allowed_profile_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "default_profile_id": {
+          "name": "default_profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "api_keys_key_hash_unique": {
+          "name": "api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "audit_logs": {
+      "name": "audit_logs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "actor": {
+          "name": "actor",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "detail": {
+          "name": "detail",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "audit_ts_idx": {
+          "name": "audit_ts_idx",
+          "columns": [
+            "ts"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "events": {
+      "name": "events",
+      "columns": {
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "events_session_id_seq_pk": {
+          "columns": [
+            "session_id",
+            "seq"
+          ],
+          "name": "events_session_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "files": {
+      "name": "files",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "rel_path": {
+          "name": "rel_path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mime": {
+          "name": "mime",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "size": {
+          "name": "size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mtime_ms": {
+          "name": "mtime_ms",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "files_session_path_idx": {
+          "name": "files_session_path_idx",
+          "columns": [
+            "session_id",
+            "rel_path"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "interactions": {
+      "name": "interactions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "request": {
+          "name": "request",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "response": {
+          "name": "response",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "resolved_at": {
+          "name": "resolved_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "interactions_session_idx": {
+          "name": "interactions_session_idx",
+          "columns": [
+            "session_id",
+            "status"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "knowledge_roots": {
+      "name": "knowledge_roots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "access": {
+          "name": "access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'read'"
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "mcp_servers": {
+      "name": "mcp_servers",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "transport": {
+          "name": "transport",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "url": {
+          "name": "url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "headers": {
+          "name": "headers",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "command": {
+          "name": "command",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "args": {
+          "name": "args",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "env": {
+          "name": "env",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "allow_network": {
+          "name": "allow_network",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "read_paths": {
+          "name": "read_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "write_paths": {
+          "name": "write_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tool_policies": {
+          "name": "tool_policies",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "tools_cache": {
+          "name": "tools_cache",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tools_updated_at": {
+          "name": "tools_updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "mcp_servers_name_unique": {
+          "name": "mcp_servers_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profile_versions": {
+      "name": "profile_versions",
+      "columns": {
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "snapshot": {
+          "name": "snapshot",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "profile_versions_profile_id_version_pk": {
+          "columns": [
+            "profile_id",
+            "version"
+          ],
+          "name": "profile_versions_profile_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profiles": {
+      "name": "profiles",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "system_prompt": {
+          "name": "system_prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'ask'"
+        },
+        "allowed_tools": {
+          "name": "allowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "disallowed_tools": {
+          "name": "disallowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "skills": {
+          "name": "skills",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'\"all\"'"
+        },
+        "file_access": {
+          "name": "file_access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "knowledge_root_ids": {
+          "name": "knowledge_root_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "mcp_server_ids": {
+          "name": "mcp_server_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin": {
+          "name": "builtin",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 1
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "runs": {
+      "name": "runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "runs_session_idx": {
+          "name": "runs_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "sessions": {
+      "name": "sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "system_prompt_append": {
+          "name": "system_prompt_append",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider_session_id": {
+          "name": "provider_session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "archived": {
+          "name": "archived",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "sessions_source_idx": {
+          "name": "sessions_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "settings": {
+      "name": "settings",
+      "columns": {
+        "key": {
+          "name": "key",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "value": {
+          "name": "value",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "usage_daily": {
+      "name": "usage_daily",
+      "columns": {
+        "date": {
+          "name": "date",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "runs": {
+          "name": "runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "failed": {
+          "name": "failed",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "interrupted": {
+          "name": "interrupted",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "usage_daily_date_source_provider_model_pk": {
+          "columns": [
+            "date",
+            "source",
+            "provider",
+            "model"
+          ],
+          "name": "usage_daily_date_source_provider_model_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    }
+  },
+  "views": {},
+  "enums": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "indexes": {}
+  }
+}

+ 7 - 0
apps/server/drizzle/meta/_journal.json

@@ -29,6 +29,13 @@
       "when": 1792759407952,
       "tag": "0003_profile_versions",
       "breakpoints": true
+    },
+    {
+      "idx": 4,
+      "version": "6",
+      "when": 1792772473773,
+      "tag": "0004_mcp_servers",
+      "breakpoints": true
     }
   ]
 }

+ 6 - 2
apps/server/src/app.ts

@@ -21,6 +21,8 @@ import { SessionService } from './services/sessions'
 import { SettingsService } from './services/settings'
 import { UsageService } from './services/usage'
 import { PdfRenderer } from './api-reference/pdf'
+import { McpUpstream } from './mcp-gateway/upstream'
+import { McpServerService } from './services/mcp-servers'
 import { BrowserTokenService } from './services/browser-tokens'
 
 export interface AppLogger {
@@ -44,6 +46,8 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   const usage = new UsageService(db)
   const browserTokens = new BrowserTokenService(db)
   const apiPdf = new PdfRenderer()
+  const mcpUpstream = new McpUpstream()
+  const mcpServers = new McpServerService(db, mcpUpstream, () => settings.get(), summary => audit.log({ actor: 'admin', kind: 'admin', summary }))
   const interactions = new InteractionService(db, events, settings)
   const sessions = new SessionService(db, settings, profiles, apiKeys, files, events)
   const adminAuth = new AdminAuthService(db, log, config.adminPassword)
@@ -58,9 +62,9 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
     summary => audit.log({ actor: 'admin', kind: 'admin', summary }),
   )
   sessions.extraModels = p => providers[p]?.knownModels?.() ?? []
-  const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, usage, plugins, communitySkills, gatewayTokens, providers })
+  const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, usage, plugins, communitySkills, mcpServers, gatewayTokens, providers })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs }
 }
 
 export type App = ReturnType<typeof createApp>

+ 34 - 1
apps/server/src/db/schema.ts

@@ -1,6 +1,6 @@
 import { sql } from 'drizzle-orm'
 import { index, integer, primaryKey, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'
-import type { FileAccessPolicy, InteractionRequest, InteractionResponse, PermissionMode, ProfileSnapshot } from '@agent-server/shared'
+import type { FileAccessPolicy, InteractionRequest, InteractionResponse, McpToolPolicy, PermissionMode, ProfileSnapshot } from '@agent-server/shared'
 
 const now = sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`
 const createdAt = () => text('created_at').notNull().default(now)
@@ -33,6 +33,7 @@ export const profiles = sqliteTable('profiles', {
   skills: text('skills', { mode: 'json' }).$type<string[] | 'all'>().notNull().default(sql`'"all"'`),
   fileAccess: text('file_access', { mode: 'json' }).$type<FileAccessPolicy>().notNull(),
   knowledgeRootIds: text('knowledge_root_ids', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
+  mcpServerIds: text('mcp_server_ids', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
   isDefault: integer('is_default', { mode: 'boolean' }).notNull().default(false),
   /** 内置 Profile(通用助手)不可删除 */
   builtin: integer('builtin', { mode: 'boolean' }).notNull().default(false),
@@ -167,3 +168,35 @@ export const profileVersions = sqliteTable('profile_versions', {
   snapshot: text('snapshot', { mode: 'json' }).$type<ProfileSnapshot>().notNull(),
   createdAt: createdAt(),
 }, t => [primaryKey({ columns: [t.profileId, t.version] })])
+
+/** 第三方 MCP 服务(经 MCP Gateway 转接,ADR-0021)。headers / env 中含密钥,只在服务端使用 */
+export const mcpServers = sqliteTable('mcp_servers', {
+  id: text('id').primaryKey(),
+  name: text('name').notNull().unique(),
+  title: text('title').notNull(),
+  description: text('description').notNull().default(''),
+  transport: text('transport', { enum: ['http', 'stdio'] }).notNull(),
+  enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
+  url: text('url').notNull().default(''),
+  headers: text('headers', { mode: 'json' }).$type<Record<string, string>>().notNull().default(sql`'{}'`),
+  command: text('command').notNull().default(''),
+  args: text('args', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
+  env: text('env', { mode: 'json' }).$type<Record<string, string>>().notNull().default(sql`'{}'`),
+  allowNetwork: integer('allow_network', { mode: 'boolean' }).notNull().default(false),
+  readPaths: text('read_paths', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
+  writePaths: text('write_paths', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
+  /** 工具策略(工具名 → auto / ask / off);没有设置的工具按默认:只读工具 auto,其余 ask */
+  toolPolicies: text('tool_policies', { mode: 'json' }).$type<Record<string, McpToolPolicy>>().notNull().default(sql`'{}'`),
+  /** 最近一次列出的工具(引擎列工具时直接使用,不必每次启动服务) */
+  toolsCache: text('tools_cache', { mode: 'json' }).$type<McpCachedTool[]>().notNull().default(sql`'[]'`),
+  toolsUpdatedAt: text('tools_updated_at'),
+  createdAt: createdAt(),
+  updatedAt: updatedAt(),
+})
+
+export interface McpCachedTool {
+  name: string
+  description: string
+  inputSchema: Record<string, unknown>
+  readOnly: boolean
+}

+ 22 - 1
apps/server/src/http/routes/admin.ts

@@ -1,7 +1,7 @@
 // 管理 API:仅后台登录态可访问
 import type { FastifyInstance, FastifyRequest } from 'fastify'
 import { z } from 'zod'
-import { BUILTIN_GLOBAL_DENY, COMMUNITY_SKILL_PLUGIN, type AppSettings, type DeepPartial, type FileAccessPolicy, type PluginAction } from '@agent-server/shared'
+import { BUILTIN_GLOBAL_DENY, COMMUNITY_SKILL_PLUGIN, type AppSettings, type McpServerInput, type DeepPartial, type FileAccessPolicy, type PluginAction } from '@agent-server/shared'
 import type { App } from '../../app'
 import { AppError, notFound } from '../../lib/errors'
 import { runClaudeCli } from '../../providers/claude/cli'
@@ -177,6 +177,27 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       })
     })
 
+    // MCP 服务:内置工具(只读展示)+ 第三方服务(经 MCP Gateway 转接,ADR-0021)
+    g.get('/mcp', async () => app.mcpServers.state())
+    g.post('/mcp/servers', async (req, reply) => {
+      reply.code(201)
+      return app.mcpServers.create(req.body as McpServerInput)
+    })
+    g.put<IdParams>('/mcp/servers/:id', async req => app.mcpServers.update(req.params.id, req.body as McpServerInput))
+    g.delete<IdParams>('/mcp/servers/:id', async (req, reply) => {
+      await app.mcpServers.delete(req.params.id)
+      reply.code(204)
+    })
+    g.post<IdParams>('/mcp/servers/:id/refresh', async req => app.mcpServers.refresh(req.params.id))
+    g.put<{ Params: { id: string; tool: string } }>('/mcp/servers/:id/tools/:tool', async req => {
+      const policy = parse(z.object({ policy: z.enum(['auto', 'ask', 'off']) }), req.body).policy
+      return app.mcpServers.setToolPolicy(req.params.id, req.params.tool, policy)
+    })
+    g.post('/mcp/test', async req => {
+      const b = req.body as { input: McpServerInput; id?: string | null }
+      return app.mcpServers.test(b.input, b.id ?? null)
+    })
+
     // 第三方技能(skills.sh 目录 → GitHub,两个引擎共用)
     g.get('/community-skills', async () => app.communitySkills.list())
     g.get<{ Querystring: { q?: string } }>('/community-skills/search', async req => app.communitySkills.search(req.query.q ?? ''))

+ 2 - 0
apps/server/src/http/routes/v1.ts

@@ -10,6 +10,7 @@ import { BROWSER_TOKEN_MAX_TTL } from '../../services/browser-tokens'
 import { parse } from '../../services/catalog'
 import { principal, requireClient } from '../auth'
 import { streamSessionEvents } from '../sse'
+import { forgetGatewaySession } from '../../mcp-gateway/server'
 
 type IdParams = { Params: { id: string } }
 
@@ -116,6 +117,7 @@ export default async function v1Routes(f: FastifyInstance, { app }: { app: App }
   f.delete<IdParams>('/sessions/:id', async (req, reply) => {
     await app.sessions.delete(req.params.id, principal(req).source)
     app.gatewayTokens.forgetSession(req.params.id)
+    forgetGatewaySession(req.params.id)
     reply.code(204)
   })
 

+ 1 - 1
apps/server/src/http/server.ts

@@ -52,7 +52,7 @@ export async function buildServer(app: App, opts: { logger: FastifyServerOptions
   })
 
   await f.register(v1Routes, { prefix: '/v1', app })
-  registerGateway(f, app.gatewayTokens)
+  registerGateway(f, app.gatewayTokens, { mcp: app.mcpServers, upstream: app.mcpUpstream })
   await f.register(adminRoutes, { prefix: '/admin/v1', app })
 
   // 生产环境由服务托管后台静态资源,同源访问

+ 1 - 1
apps/server/src/lib/ids.ts

@@ -1,6 +1,6 @@
 import { ulid } from 'ulid'
 
-export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg'
+export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp'
 
 export const newId = (prefix: IdPrefix) => `${prefix}_${ulid().toLowerCase()}`
 

+ 1 - 0
apps/server/src/main.ts

@@ -14,6 +14,7 @@ const server = await buildServer(app, { logger })
 const shutdown = async (signal: string) => {
   logger.info({ signal }, 'shutting down')
   await server.close()
+  await app.mcpUpstream.closeAll()
   app.db.$client.close()
   process.exit(0)
 }

+ 93 - 0
apps/server/src/mcp-gateway/builtin.ts

@@ -0,0 +1,93 @@
+// MCP Gateway 的内置工具(两个引擎共用):文档转换、公式重算、网页抓取、资料库列表
+import path from 'node:path'
+import { z } from 'zod'
+import type { McpBuiltinToolDto } from '@agent-server/shared'
+import { CONVERT_FORMATS, convertDocument, recalcWorkbook } from '../services/office'
+import { FetchBlockedError, webFetch } from '../services/web-fetch'
+import type { GatewayRunContext } from './tokens'
+
+export type ToolContent = { type: string; [key: string]: unknown }
+export type ToolResult = { content: ToolContent[]; isError?: boolean }
+export const ok = (text: string): ToolResult => ({ content: [{ type: 'text', text }] })
+export const fail = (text: string): ToolResult => ({ content: [{ type: 'text', text }], isError: true })
+
+export interface BuiltinTool {
+  name: string
+  description: string
+  schema: z.ZodObject
+  readOnly: boolean
+  handler(args: Record<string, unknown>, ctx: GatewayRunContext): Promise<ToolResult>
+}
+
+const readonlyBlock = (ctx: GatewayRunContext) => (ctx.permissionMode === 'readonly' ? fail('当前为只读模式,不允许生成或修改文件') : null)
+
+const convertSchema = z.object({
+  input: z.string().describe('输入文件路径(相对工作区或绝对路径)'),
+  format: z.enum(CONVERT_FORMATS).describe('目标格式'),
+  out_dir: z.string().optional().describe('输出目录,默认 outputs'),
+})
+const recalcSchema = z.object({ input: z.string().describe('xlsx/xlsm 文件路径(相对工作区或绝对路径)') })
+const fetchSchema = z.object({ url: z.string().describe('http/https 网址') })
+
+export const BUILTIN_TOOLS: BuiltinTool[] = [
+  {
+    name: 'convert_document',
+    description: '用 LibreOffice 转换文档格式(例如 docx→pdf、xlsx→pdf、pptx→pdf、doc→docx、html→pdf)。'
+      + '输入和输出都必须在工作区内;输出文件名与输入相同、扩展名换成目标格式。不要在命令行里调用 soffice,它在沙箱中无法运行。',
+    schema: convertSchema,
+    readOnly: false,
+    async handler(raw, ctx) {
+      const blocked = readonlyBlock(ctx)
+      if (blocked) return blocked
+      const args = raw as z.infer<typeof convertSchema>
+      const out = await convertDocument({ workspaceDir: ctx.workspaceDir, input: args.input, format: args.format, outDir: args.out_dir })
+      return ok(`已生成:${path.relative(ctx.workspaceDir, out)}`)
+    },
+  },
+  {
+    name: 'recalc_workbook',
+    description: '用 LibreOffice 重新计算 Excel 文件中的所有公式并写回原文件,然后报告公式数量和错误单元格(#REF!、#DIV/0! 等)。'
+      + '用 openpyxl 写入公式后必须调用它,否则文件里没有计算结果。不要在命令行里调用 soffice 或 recalc.py,它们在沙箱中无法运行。',
+    schema: recalcSchema,
+    readOnly: false,
+    async handler(raw, ctx) {
+      const blocked = readonlyBlock(ctx)
+      if (blocked) return blocked
+      const args = raw as z.infer<typeof recalcSchema>
+      const r = await recalcWorkbook({ workspaceDir: ctx.workspaceDir, input: args.input, pythonRuntime: ctx.settings.paths.pythonRuntime })
+      const lines = [`已重算:${path.relative(ctx.workspaceDir, r.path)},公式 ${r.formulas} 个,错误 ${r.errorCount} 个`]
+      for (const e of r.errors) lines.push(`- ${e.sheet}!${e.cell}: ${e.error}`)
+      return r.errorCount > 0 ? fail(lines.join('\n')) : ok(lines.join('\n'))
+    },
+  },
+  {
+    name: 'web_fetch',
+    description: '抓取网页并转换为 Markdown(HTML),或原样返回文本/JSON;PDF、图片、表格等二进制文件会下载到工作区并返回路径。'
+      + '内容过长时只返回前一部分,完整内容保存在工作区。不能访问内网地址。',
+    schema: fetchSchema,
+    readOnly: true,
+    async handler(raw, ctx) {
+      const args = raw as z.infer<typeof fetchSchema>
+      try {
+        const r = await webFetch({ url: args.url, workspaceDir: ctx.workspaceDir, blockPrivate: ctx.settings.network.blockPrivateFetch })
+        const head = `URL: ${r.url}\n状态: ${r.status}\n类型: ${r.contentType}${r.savedPath ? `\n${r.truncated ? '完整内容' : '文件'}已保存到: ${r.savedPath}` : ''}${r.truncated ? '\n(内容过长,以下为前一部分)' : ''}\n\n`
+        return r.status >= 400 ? fail(head + r.text) : ok(head + r.text)
+      } catch (err) {
+        if (err instanceof FetchBlockedError) ctx.audit('network_denied', err.message, { url: args.url, engine: ctx.provider })
+        throw err
+      }
+    },
+  },
+  {
+    name: 'kb_list_roots',
+    description: '列出当前可用的本地资料库(名称、路径、说明、读写属性)。查找本地资料前先调用它。',
+    schema: z.object({}),
+    readOnly: true,
+    async handler(_args, ctx) {
+      if (!ctx.knowledgeRoots.length) return ok('当前没有可用的资料库。')
+      return ok(ctx.knowledgeRoots.map(k => `- ${k.name}:${k.path}(${k.access === 'write' ? '可读写' : '只读'})${k.description ? `\n  ${k.description}` : ''}`).join('\n'))
+    },
+  },
+]
+
+export const BUILTIN_TOOL_INFO: McpBuiltinToolDto[] = BUILTIN_TOOLS.map(t => ({ name: t.name, description: t.description, readOnly: t.readOnly }))

+ 148 - 68
apps/server/src/mcp-gateway/server.ts

@@ -1,98 +1,173 @@
-// MCP Gateway:服务进程内的 Streamable HTTP MCP 服务(/mcp),两个引擎共用同一套工具(ADR-0006)
+// MCP Gateway:服务进程内的 Streamable HTTP MCP 服务(/mcp),两个引擎共用同一套工具(ADR-0006)。
+// 提供内置工具(builtin.ts),并转接 Profile 勾选的第三方 MCP 服务(ADR-0021):工具名为 <服务名>__<工具名>,
+// 调用前按工具策略与权限模式审批,结果过长时截断并把完整内容存入工作区
+import crypto from 'node:crypto'
+import fs from 'node:fs'
 import path from 'node:path'
 import type { FastifyInstance } from 'fastify'
-import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
+import { Server } from '@modelcontextprotocol/sdk/server/index.js'
 import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
+import { CallToolRequestSchema, ListToolsRequestSchema, type Tool } from '@modelcontextprotocol/sdk/types.js'
 import { z } from 'zod'
-import { CONVERT_FORMATS, convertDocument, recalcWorkbook } from '../services/office'
-import { FetchBlockedError, webFetch } from '../services/web-fetch'
+import type { AppSettings } from '@agent-server/shared'
+import type { McpCachedTool } from '../db/schema'
+import { defaultPolicy, type McpServerService } from '../services/mcp-servers'
+import { BUILTIN_TOOLS, fail, type ToolContent, type ToolResult } from './builtin'
 import type { GatewayRunContext, RunTokenRegistry } from './tokens'
+import { isTransient, type McpServerRow, type McpUpstream } from './upstream'
 
 export const GATEWAY_SERVER = 'gateway'
-export const GATEWAY_TOOLS = ['convert_document', 'recalc_workbook', 'web_fetch', 'kb_list_roots'] as const
+export const GATEWAY_TOOLS = BUILTIN_TOOLS.map(t => t.name)
+/** 第三方工具名中服务名与工具名的分隔符 */
+export const MCP_SEP = '__'
 
-type ToolResult = { content: { type: 'text'; text: string }[]; isError?: boolean }
-const ok = (text: string): ToolResult => ({ content: [{ type: 'text', text }] })
-const fail = (text: string): ToolResult => ({ content: [{ type: 'text', text }], isError: true })
+const MAX_TEXT = 100_000
+const MAX_INLINE_BINARY = 5 * 1048576
+const CALL_TIMEOUT_MS = 10 * 60_000
+const LIST_TIMEOUT_MS = 20_000
 
-function buildServer(ctx: GatewayRunContext): McpServer {
-  const server = new McpServer({ name: 'agent-server-gateway', version: '0.1.0' })
-  const readonlyBlock = () => (ctx.permissionMode === 'readonly' ? fail('当前为只读模式,不允许生成或修改文件') : null)
-  const run = async (tool: string, args: unknown, fn: () => Promise<ToolResult>) => {
-    const started = Date.now()
-    try {
-      const r = await fn()
-      ctx.audit('gateway_call', `${tool}${r.isError ? '(失败)' : ''}`, { args, ms: Date.now() - started, engine: ctx.provider })
-      return r
-    } catch (err) {
-      ctx.audit('gateway_call', `${tool}(异常)`, { args, error: (err as Error).message })
-      return fail((err as Error).message)
+/** 引擎等待网关工具的时间(秒):交互超时 + 第三方工具最长执行时间 + 余量 */
+export const gatewayToolTimeoutSec = (s: AppSettings) => s.limits.interactionTimeoutSec + CALL_TIMEOUT_MS / 1000 + 60
+
+export interface GatewayDeps {
+  mcp: McpServerService
+  upstream: McpUpstream
+}
+
+/** 会话内"本会话允许"过的第三方工具 */
+const sessionAllowed = new Map<string, Set<string>>()
+
+function builtinTools(): Tool[] {
+  return BUILTIN_TOOLS.map(t => ({
+    name: t.name, description: t.description,
+    inputSchema: z.toJSONSchema(t.schema) as Tool['inputSchema'],
+    ...(t.readOnly ? { annotations: { readOnlyHint: true } } : {}),
+  }))
+}
+
+/** 这次对话可用的第三方工具(只读模式下只提供只读工具) */
+async function externalTools(ctx: GatewayRunContext, deps: GatewayDeps): Promise<{ row: McpServerRow; tool: McpCachedTool; exposed: string }[]> {
+  const out: { row: McpServerRow; tool: McpCachedTool; exposed: string }[] = []
+  for (const row of deps.mcp.rows(ctx.mcpServerIds)) {
+    let tools = row.toolsCache
+    if (!tools.length) {
+      tools = await Promise.race([
+        deps.mcp.listToolsFor(row).catch(() => [] as McpCachedTool[]),
+        new Promise<McpCachedTool[]>(r => setTimeout(() => r([]), LIST_TIMEOUT_MS)),
+      ])
+    }
+    for (const tool of tools) {
+      const policy = row.toolPolicies[tool.name] ?? defaultPolicy(tool)
+      if (policy === 'off' || (ctx.permissionMode === 'readonly' && !tool.readOnly)) continue
+      out.push({ row, tool, exposed: `${row.name}${MCP_SEP}${tool.name}`.slice(0, 64) })
     }
   }
+  return out
+}
 
-  server.registerTool('convert_document', {
-    description: '用 LibreOffice 转换文档格式(例如 docx→pdf、xlsx→pdf、pptx→pdf、doc→docx、html→pdf)。'
-      + '输入和输出都必须在工作区内;输出文件名与输入相同、扩展名换成目标格式。不要在命令行里调用 soffice,它在沙箱中无法运行。',
-    inputSchema: {
-      input: z.string().describe('输入文件路径(相对工作区或绝对路径)'),
-      format: z.enum(CONVERT_FORMATS).describe('目标格式'),
-      out_dir: z.string().optional().describe('输出目录,默认 outputs'),
-    },
-  }, args => run('convert_document', args, async () => {
-    const blocked = readonlyBlock()
-    if (blocked) return blocked
-    const out = await convertDocument({ workspaceDir: ctx.workspaceDir, input: args.input, format: args.format, outDir: args.out_dir })
-    return ok(`已生成:${path.relative(ctx.workspaceDir, out)}`)
-  }))
+function buildServer(ctx: GatewayRunContext, deps: GatewayDeps): Server {
+  const server = new Server({ name: 'agent-server-gateway', version: '0.1.0' }, { capabilities: { tools: {} } })
 
-  server.registerTool('recalc_workbook', {
-    description: '用 LibreOffice 重新计算 Excel 文件中的所有公式并写回原文件,然后报告公式数量和错误单元格(#REF!、#DIV/0! 等)。'
-      + '用 openpyxl 写入公式后必须调用它,否则文件里没有计算结果。不要在命令行里调用 soffice 或 recalc.py,它们在沙箱中无法运行。',
-    inputSchema: { input: z.string().describe('xlsx/xlsm 文件路径(相对工作区或绝对路径)') },
-  }, args => run('recalc_workbook', args, async () => {
-    const blocked = readonlyBlock()
-    if (blocked) return blocked
-    const r = await recalcWorkbook({ workspaceDir: ctx.workspaceDir, input: args.input, pythonRuntime: ctx.settings.paths.pythonRuntime })
-    const lines = [`已重算:${path.relative(ctx.workspaceDir, r.path)},公式 ${r.formulas} 个,错误 ${r.errorCount} 个`]
-    for (const e of r.errors) lines.push(`- ${e.sheet}!${e.cell}: ${e.error}`)
-    return r.errorCount > 0 ? fail(lines.join('\n')) : ok(lines.join('\n'))
+  server.setRequestHandler(ListToolsRequestSchema, async () => ({
+    tools: [
+      ...builtinTools(),
+      ...(await externalTools(ctx, deps)).map(({ row, tool, exposed }) => ({
+        name: exposed,
+        description: `[${row.title}] ${tool.description}`.slice(0, 2000),
+        inputSchema: (tool.inputSchema?.type === 'object' ? tool.inputSchema : { type: 'object', properties: {} }) as Tool['inputSchema'],
+        ...(tool.readOnly ? { annotations: { readOnlyHint: true } } : {}),
+      })),
+    ],
   }))
 
-  server.registerTool('web_fetch', {
-    description: '抓取网页并转换为 Markdown(HTML),或原样返回文本/JSON;PDF、图片、表格等二进制文件会下载到工作区并返回路径。'
-      + '内容过长时只返回前一部分,完整内容保存在工作区。不能访问内网地址。',
-    inputSchema: { url: z.string().describe('http/https 网址') },
-    annotations: { readOnlyHint: true },
-  }, args => run('web_fetch', args, async () => {
+  server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
+    const name = req.params.name
+    const args = (req.params.arguments ?? {}) as Record<string, unknown>
+    const started = Date.now()
+    const audit = (summary: string, detail: Record<string, unknown> = {}) =>
+      ctx.audit('gateway_call', summary, { args, ms: Date.now() - started, engine: ctx.provider, ...detail })
+
+    const builtin = BUILTIN_TOOLS.find(t => t.name === name)
+    if (builtin) {
+      const parsed = builtin.schema.safeParse(args)
+      if (!parsed.success) return fail(`参数不正确:${parsed.error.issues.map(i => `${i.path.join('.')} ${i.message}`).join(';')}`)
+      try {
+        const r = await builtin.handler(parsed.data as Record<string, unknown>, ctx)
+        audit(`${name}${r.isError ? '(失败)' : ''}`)
+        return r
+      } catch (err) {
+        audit(`${name}(异常)`, { error: (err as Error).message })
+        return fail((err as Error).message)
+      }
+    }
+
+    const found = (await externalTools(ctx, deps)).find(t => t.exposed === name)
+    if (!found) return fail(`工具不存在或当前不可用:${name}`)
+    const { row, tool } = found
+    const policy = row.toolPolicies[tool.name] ?? defaultPolicy(tool)
+    // 需要审批:跟随权限模式(需审批时询问;自动模式直接调用;只读模式已在列表中排除非只读工具)
+    if (policy === 'ask' && ctx.permissionMode === 'ask' && !sessionAllowed.get(ctx.sessionId)?.has(name)) {
+      const outcome = await ctx.interact({
+        kind: 'approval', tool: `mcp__${GATEWAY_SERVER}__${name}`, title: `${row.title} · ${tool.name}`,
+        input: args, detail: { reason: tool.description.slice(0, 300) || undefined }, options: ['allow_once', 'allow_session', 'deny'],
+      })
+      ctx.audit('approval', `MCP 工具 ${name}:${outcome.resolution}`, { args })
+      if (outcome.resolution !== 'allowed') return fail(`用户拒绝了这次调用${outcome.resolution === 'denied' && outcome.reason ? `:${outcome.reason}` : ''}`)
+      if (outcome.scope === 'session') sessionAllowed.set(ctx.sessionId, new Set([...(sessionAllowed.get(ctx.sessionId) ?? []), name]))
+    }
     try {
-      const r = await webFetch({ url: args.url, workspaceDir: ctx.workspaceDir, blockPrivate: ctx.settings.network.blockPrivateFetch })
-      const head = `URL: ${r.url}\n状态: ${r.status}\n类型: ${r.contentType}${r.savedPath ? `\n${r.truncated ? '完整内容' : '文件'}已保存到: ${r.savedPath}` : ''}${r.truncated ? '\n(内容过长,以下为前一部分)' : ''}\n\n`
-      return r.status >= 400 ? fail(head + r.text) : ok(head + r.text)
+      const call = () => deps.upstream.callTool(row, tool.name, args, extra.signal, CALL_TIMEOUT_MS) as Promise<{ content?: ToolContent[]; structuredContent?: unknown; isError?: boolean }>
+      // 偶发的网络错误:只读工具重新连接后重试一次;其他工具不重试,避免重复执行
+      const r = await call().catch(async err => {
+        if (!tool.readOnly || !isTransient(err)) throw err
+        await deps.upstream.close(row.id)
+        return call()
+      })
+      const result = postProcess(r, ctx.workspaceDir, name)
+      audit(`${name}${result.isError ? '(失败)' : ''}`, { server: row.name })
+      return result
     } catch (err) {
-      if (err instanceof FetchBlockedError) ctx.audit('network_denied', err.message, { url: args.url, engine: ctx.provider })
-      throw err
+      audit(`${name}(异常)`, { server: row.name, error: (err as Error).message })
+      return fail(`调用 ${row.title} 失败:${(err as Error).message}`)
     }
-  }))
-
-  server.registerTool('kb_list_roots', {
-    description: '列出当前可用的本地资料库(名称、路径、说明、读写属性)。查找本地资料前先调用它。',
-    inputSchema: {},
-    annotations: { readOnlyHint: true },
-  }, () => run('kb_list_roots', {}, async () => {
-    if (!ctx.knowledgeRoots.length) return ok('当前没有可用的资料库。')
-    return ok(ctx.knowledgeRoots.map(k => `- ${k.name}:${k.path}(${k.access === 'write' ? '可读写' : '只读'})${k.description ? `\n  ${k.description}` : ''}`).join('\n'))
-  }))
+  })
 
   return server
 }
 
-export function registerGateway(f: FastifyInstance, tokens: RunTokenRegistry) {
+/** 只保留引擎能处理的内容;过长的文本和过大的二进制存入工作区 .agent/mcp/ 并返回路径 */
+function postProcess(r: { content?: ToolContent[]; structuredContent?: unknown; isError?: boolean }, workspaceDir: string, tool: string): ToolResult {
+  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 content: ToolContent[] = []
+  for (const c of r.content ?? []) {
+    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) {
+      const ext = String(c.mimeType ?? '').split('/')[1]?.replace(/[^\w]/g, '') || 'bin'
+      content.push({ type: 'text', text: `(${c.type === 'image' ? '图片' : '音频'}过大,已保存到 ${save(ext, Buffer.from(c.data, 'base64'))})` })
+    } else {
+      content.push(c)
+    }
+  }
+  if (!content.length && r.structuredContent !== undefined) content.push({ type: 'text', text: JSON.stringify(r.structuredContent, null, 2).slice(0, MAX_TEXT) })
+  if (!content.length) content.push({ type: 'text', text: '(无返回内容)' })
+  return { content, ...(r.isError ? { isError: true } : {}) }
+}
+
+export function registerGateway(f: FastifyInstance, tokens: RunTokenRegistry, deps: GatewayDeps) {
   f.post('/mcp', async (req, reply) => {
     const auth = req.headers.authorization
     const ctx = tokens.get(auth?.startsWith('Bearer ') ? auth.slice(7).trim() : undefined)
     if (!ctx) return reply.code(401).send({ jsonrpc: '2.0', error: { code: -32001, message: '无效或已过期的运行令牌' }, id: null })
     // 无状态模式:每个请求一个 server/transport 实例,绑定到该 Run 的上下文
-    const server = buildServer(ctx)
+    const server = buildServer(ctx, deps)
     const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
     reply.hijack()
     reply.raw.on('close', () => {
@@ -106,3 +181,8 @@ export function registerGateway(f: FastifyInstance, tokens: RunTokenRegistry) {
   f.get('/mcp', async (_req, reply) => reply.code(405).send(notAllowed))
   f.delete('/mcp', async (_req, reply) => reply.code(405).send(notAllowed))
 }
+
+/** 会话删除时清除"本会话允许" */
+export function forgetGatewaySession(sessionId: string) {
+  sessionAllowed.delete(sessionId)
+}

+ 6 - 1
apps/server/src/mcp-gateway/tokens.ts

@@ -4,6 +4,7 @@
 import crypto from 'node:crypto'
 import type { AppSettings, KnowledgeRootDto, PermissionMode } from '@agent-server/shared'
 import type { ResolvedAccess } from '../services/access-policy'
+import type { InteractionOutcome, NewApproval } from '../services/interactions'
 
 export interface GatewayRunContext {
   sessionId: string
@@ -14,7 +15,11 @@ export interface GatewayRunContext {
   permissionMode: PermissionMode
   settings: AppSettings
   knowledgeRoots: KnowledgeRootDto[]
-  audit: (kind: 'gateway_call' | 'network_denied' | 'file_access_denied', summary: string, detail?: unknown) => void
+  /** Profile 勾选的第三方 MCP 服务 */
+  mcpServerIds: string[]
+  audit: (kind: 'gateway_call' | 'network_denied' | 'file_access_denied' | 'approval', summary: string, detail?: unknown) => void
+  /** 发起审批(与引擎侧的审批相同:显示交互卡片并等待回复) */
+  interact: (req: NewApproval) => Promise<InteractionOutcome>
 }
 
 const MAX_AGE_MS = 24 * 3600 * 1000

+ 221 - 0
apps/server/src/mcp-gateway/upstream.ts

@@ -0,0 +1,221 @@
+// 第三方 MCP 服务的连接管理(ADR-0021):MCP Gateway 作为客户端连接它们,把工具转接给两个引擎。
+// - http:Streamable HTTP(失败时退回 SSE),请求头可携带令牌
+// - stdio:本地进程,在 bubblewrap 沙箱中运行:系统目录只读、独立的可写 home、按配置开放网络与额外路径;
+//   宿主 Unix socket(docker.sock 等)不可达;密钥只通过环境变量传给该进程
+// 连接按需建立、多个对话共用,空闲 10 分钟后关闭;配置变化时重新连接
+import fs from 'node:fs'
+import path from 'node:path'
+import type { Readable } from 'node:stream'
+import { Client } from '@modelcontextprotocol/sdk/client/index.js'
+import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
+import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import { APP_VERSION, config } from '../config'
+import type { McpCachedTool, mcpServers } from '../db/schema'
+import { hostSockets } from '../services/office'
+
+export type McpServerRow = typeof mcpServers.$inferSelect
+
+const CONNECT_TIMEOUT_MS = 120_000
+const IDLE_MS = 10 * 60_000
+const STDERR_LINES = 40
+export const BWRAP = ['/usr/bin/bwrap', '/bin/bwrap'].find(p => fs.existsSync(p)) ?? null
+
+interface Conn {
+  key: string
+  client: Client
+  stderr: string[]
+  lastUsed: number
+}
+
+/** 影响连接的配置;变化时需要重新连接 */
+const connKey = (s: McpServerRow) => JSON.stringify([s.transport, s.url, s.headers, s.command, s.args, s.env, s.allowNetwork, s.readPaths, s.writePaths])
+
+/** 本地进程的独立 home(npx / uvx 缓存等),在数据目录下 */
+export const mcpHomeDir = (id: string) => path.join(config.dataDir, 'data', 'mcp', id)
+
+export function stdioSandboxArgs(s: Pick<McpServerRow, 'allowNetwork' | 'readPaths' | 'writePaths'>, home: string): string[] {
+  return [
+    '--ro-bind', '/usr', '/usr',
+    '--ro-bind', '/etc', '/etc',
+    '--symlink', 'usr/lib', '/lib',
+    '--symlink', 'usr/lib64', '/lib64',
+    '--symlink', 'usr/bin', '/bin',
+    '--symlink', 'usr/sbin', '/sbin',
+    ...hostSockets().flatMap(p => ['--ro-bind', '/dev/null', p]),
+    '--dev', '/dev',
+    '--proc', '/proc',
+    '--tmpfs', '/tmp',
+    '--bind', home, home,
+    ...s.readPaths.flatMap(p => ['--ro-bind', p, p]),
+    ...s.writePaths.flatMap(p => ['--bind', p, p]),
+    '--unshare-all',
+    ...(s.allowNetwork ? ['--share-net'] : []),
+    '--die-with-parent',
+    '--new-session',
+    '--chdir', home,
+    '--setenv', 'HOME', home,
+    '--setenv', 'TMPDIR', '/tmp',
+    '--setenv', 'LANG', 'C.UTF-8',
+    '--setenv', 'PATH', '/usr/local/bin:/usr/bin:/bin',
+  ]
+}
+
+/** 网络层的偶发错误(连接被重置、超时等),可以安全地重新连接 */
+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 ?? ''))
+
+function withTimeout<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
+  let timer: NodeJS.Timeout
+  return Promise.race([p, new Promise<T>((_, reject) => { timer = setTimeout(() => reject(new Error(`${what}超时(${Math.round(ms / 1000)} 秒)`)), ms) })])
+    .finally(() => clearTimeout(timer))
+}
+
+export class McpUpstream {
+  private conns = new Map<string, Conn>()
+  private pending = new Map<string, Promise<Conn>>()
+  private errors = new Map<string, { error: string; stderr: string | null }>()
+  private sweeper: NodeJS.Timeout
+
+  constructor() {
+    this.sweeper = setInterval(() => this.closeIdle(), 60_000)
+    this.sweeper.unref()
+  }
+
+  status(id: string): { status: 'connected' | 'idle' | 'error'; lastError: string | null; stderrTail: string | null } {
+    const c = this.conns.get(id)
+    const e = this.errors.get(id)
+    if (c) return { status: 'connected', lastError: null, stderrTail: c.stderr.length ? c.stderr.join('\n') : null }
+    return { status: e ? 'error' : 'idle', lastError: e?.error ?? null, stderrTail: e?.stderr ?? null }
+  }
+
+  /** 列出工具(包括分页) */
+  async listTools(s: McpServerRow): Promise<McpCachedTool[]> {
+    const c = await this.conn(s)
+    return listAll(c.client)
+  }
+
+  async callTool(s: McpServerRow, name: string, args: Record<string, unknown>, signal: AbortSignal, timeoutMs: number) {
+    const c = await this.conn(s)
+    c.lastUsed = Date.now()
+    try {
+      return await c.client.callTool({ name, arguments: args }, undefined, { signal, timeout: timeoutMs, resetTimeoutOnProgress: true, maxTotalTimeout: timeoutMs * 2 })
+    } finally {
+      c.lastUsed = Date.now()
+    }
+  }
+
+  /** 用一份配置临时连接并列出工具(后台"测试连接"),不影响正在使用的连接 */
+  async test(s: McpServerRow): Promise<{ tools: McpCachedTool[]; stderr: string[] }> {
+    const stderr: string[] = []
+    const client = await this.connect(s, stderr)
+    try {
+      return { tools: await listAll(client), stderr }
+    } finally {
+      await client.close().catch(() => {})
+    }
+  }
+
+  async close(id: string) {
+    const c = this.conns.get(id)
+    this.conns.delete(id)
+    this.errors.delete(id)
+    await c?.client.close().catch(() => {})
+  }
+
+  async closeAll() {
+    clearInterval(this.sweeper)
+    await Promise.all([...this.conns.keys()].map(id => this.close(id)))
+  }
+
+  private closeIdle() {
+    for (const [id, c] of this.conns) if (Date.now() - c.lastUsed > IDLE_MS) void this.close(id)
+  }
+
+  private async conn(s: McpServerRow): Promise<Conn> {
+    const key = connKey(s)
+    const existing = this.conns.get(s.id)
+    if (existing && existing.key === key) {
+      existing.lastUsed = Date.now()
+      return existing
+    }
+    if (existing) await this.close(s.id)
+    const inflight = this.pending.get(s.id)
+    if (inflight) return inflight
+    const stderr: string[] = []
+    // 远程服务偶发的网络错误重试一次
+    const connectOnce = () => this.connect(s, stderr)
+    const p = connectOnce().catch(err => (s.transport === 'http' && isTransient(err) ? connectOnce() : Promise.reject(err))).then(client => {
+      const c: Conn = { key, client, stderr, lastUsed: Date.now() }
+      this.conns.set(s.id, c)
+      this.errors.delete(s.id)
+      // 连接断开(进程退出、服务端关闭)后下次调用重新连接
+      client.onclose = () => {
+        if (this.conns.get(s.id) === c) this.conns.delete(s.id)
+      }
+      return c
+    }, (err: Error) => {
+      this.errors.set(s.id, { error: err.message, stderr: stderr.length ? stderr.join('\n') : null })
+      throw err
+    }).finally(() => this.pending.delete(s.id))
+    this.pending.set(s.id, p)
+    return p
+  }
+
+  private async connect(s: McpServerRow, stderr: string[]): Promise<Client> {
+    const client = new Client({ name: 'agent-server-gateway', version: APP_VERSION })
+    if (s.transport === 'http') {
+      const url = new URL(s.url)
+      const requestInit: RequestInit = { headers: s.headers }
+      try {
+        await withTimeout(client.connect(new StreamableHTTPClientTransport(url, { requestInit })), CONNECT_TIMEOUT_MS, '连接')
+      } catch (err) {
+        // 旧版服务只支持 SSE
+        const fallback = new Client({ name: 'agent-server-gateway', version: APP_VERSION })
+        try {
+          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, '连接')
+          return fallback
+        } catch {
+          throw err
+        }
+      }
+      return client
+    }
+    if (!BWRAP) throw new Error('服务器上没有 bubblewrap(bwrap),无法在沙箱中运行本地 MCP 服务')
+    const home = mcpHomeDir(s.id)
+    fs.mkdirSync(home, { recursive: true, mode: 0o700 })
+    const transport = new StdioClientTransport({
+      command: BWRAP,
+      args: [...stdioSandboxArgs(s, home), '--', s.command, ...s.args],
+      // 密钥只放在环境变量里(不出现在命令行参数中)
+      env: { ...s.env, HOME: home },
+      stderr: 'pipe',
+      cwd: home,
+    })
+    ;(transport.stderr as Readable | null)?.on('data', (d: Buffer) => {
+      for (const line of d.toString('utf8').split('\n')) if (line.trim()) stderr.push(line.slice(0, 500))
+      if (stderr.length > STDERR_LINES) stderr.splice(0, stderr.length - STDERR_LINES)
+    })
+    try {
+      await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, '启动')
+    } catch (err) {
+      await transport.close().catch(() => {})
+      const tail = stderr.slice(-5).join('\n')
+      throw new Error(`${(err as Error).message}${tail ? `\n${tail}` : ''}`)
+    }
+    return client
+  }
+}
+
+async function listAll(client: Client): Promise<McpCachedTool[]> {
+  const tools: McpCachedTool[] = []
+  let cursor: string | undefined
+  for (let page = 0; page < 20; page++) {
+    const r = await withTimeout(client.listTools(cursor ? { cursor } : undefined), 60_000, '列出工具')
+    for (const t of r.tools) {
+      tools.push({ name: t.name, description: t.description ?? '', inputSchema: t.inputSchema as Record<string, unknown>, readOnly: t.annotations?.readOnlyHint === true })
+    }
+    cursor = r.nextCursor
+    if (!cursor) break
+  }
+  return tools
+}

+ 3 - 1
apps/server/src/providers/claude/adapter.ts

@@ -16,7 +16,7 @@ import { ProviderRunError, type AgentProvider, type RunContext, type RunResult }
 import { DEFAULT_DISALLOWED_TOOLS, decideTool, toolTitle, type PolicyContext } from './policy'
 import { runClaudeCli } from './cli'
 import { localPlugin, resolveSkills } from './skills'
-import { GATEWAY_SERVER } from '../../mcp-gateway/server'
+import { GATEWAY_SERVER, gatewayToolTimeoutSec } from '../../mcp-gateway/server'
 
 const MAX_TOOL_OUTPUT = 64 * 1024
 
@@ -317,6 +317,8 @@ function agentEnv(ctx: RunContext): Record<string, string | undefined> {
     MPLCONFIGDIR: path.join(agentDir, 'mpl'),
     // 不覆盖 TMPDIR:沙箱在其中创建 Unix socket,路径过长会导致沙箱无法启动
     CLAUDE_AGENT_SDK_CLIENT_APP: 'agent-server/0.1.0',
+    // 网关工具(尤其是第三方 MCP)可能等待审批后再执行,毫秒
+    MCP_TOOL_TIMEOUT: String(gatewayToolTimeoutSec(ctx.settings) * 1000),
   }
   // 只使用 CLI 登录态,避免意外切换到 API 计费(ADR-0008)
   delete env.ANTHROPIC_API_KEY

+ 3 - 2
apps/server/src/providers/codex/adapter.ts

@@ -7,7 +7,7 @@ import { communitySkillsDir } from '../../config'
 import { checkAccess } from '../../services/access-policy'
 import { ProviderRunError, type AgentProvider, type RunContext, type RunResult } from '../types'
 import { bundledCodexPath, codexHome } from './cli'
-import { GATEWAY_SERVER } from '../../mcp-gateway/server'
+import { GATEWAY_SERVER, gatewayToolTimeoutSec } from '../../mcp-gateway/server'
 import { buildThreadConfig } from './permissions'
 import { ALWAYS_DISABLED, skillDisplayId, skillsToDisable } from './skills'
 import type {
@@ -118,7 +118,8 @@ export class CodexProvider implements AgentProvider {
         // 安全校验在网关中完成,不再逐次询问
         default_tools_approval_mode: 'approve',
         startup_timeout_sec: 20,
-        tool_timeout_sec: 300,
+        // 第三方 MCP 工具可能先等待审批(交互超时)再执行(网关最多 10 分钟)
+        tool_timeout_sec: gatewayToolTimeoutSec(ctx.settings),
       },
     }
     // 需审批:沙箱内的命令自动执行,越过沙箱边界时请求审批;自动/只读:从不请求,越界直接失败

+ 16 - 4
apps/server/src/services/catalog.ts

@@ -4,7 +4,7 @@ import { and, desc, eq, ne } from 'drizzle-orm'
 import { z } from 'zod'
 import type { ApiKeyDto, ApiKeyProfiles, FileAccessPolicy, KnowledgeRootDto, ProfileDto, ProfileInput, ProfileSnapshot, ProfileVersionDto, ProviderId } from '@agent-server/shared'
 import type { DB } from '../db'
-import { apiKeys, auditLogs, knowledgeRoots, profiles, profileVersions } from '../db/schema'
+import { apiKeys, auditLogs, knowledgeRoots, mcpServers, profiles, profileVersions } from '../db/schema'
 import { AppError, badRequest, notFound } from '../lib/errors'
 import { newId, nowIso } from '../lib/ids'
 import { checkKnowledgePath } from './access-policy'
@@ -32,19 +32,21 @@ export const profileInputSchema: z.ZodType<ProfileInput> = z.object({
   skills: z.union([z.literal('all'), z.array(z.string())]),
   fileAccess: fileAccessSchema,
   knowledgeRootIds: z.array(z.string()),
+  mcpServerIds: z.array(z.string()).default([]),
   isDefault: z.boolean(),
 })
 
 const toProfileDto = (r: ProfileRow): ProfileDto => ({
   id: r.id, name: r.name, description: r.description, provider: r.provider, model: r.model, systemPrompt: r.systemPrompt,
   permissionMode: r.permissionMode, allowedTools: r.allowedTools, disallowedTools: r.disallowedTools, skills: r.skills,
-  fileAccess: r.fileAccess, knowledgeRootIds: r.knowledgeRootIds, isDefault: r.isDefault, builtin: r.builtin, version: r.version, updatedAt: r.updatedAt,
+  fileAccess: r.fileAccess, knowledgeRootIds: r.knowledgeRootIds, mcpServerIds: r.mcpServerIds, isDefault: r.isDefault, builtin: r.builtin, version: r.version, updatedAt: r.updatedAt,
 })
 
-const SNAPSHOT_KEYS = ['name', 'description', 'provider', 'model', 'systemPrompt', 'permissionMode', 'allowedTools', 'disallowedTools', 'skills', 'fileAccess', 'knowledgeRootIds'] as const satisfies readonly (keyof ProfileSnapshot)[]
+const SNAPSHOT_KEYS = ['name', 'description', 'provider', 'model', 'systemPrompt', 'permissionMode', 'allowedTools', 'disallowedTools', 'skills', 'fileAccess', 'knowledgeRootIds', 'mcpServerIds'] as const satisfies readonly (keyof ProfileSnapshot)[]
 
 function snapshotOf(p: ProfileSnapshot): ProfileSnapshot {
-  return Object.fromEntries(SNAPSHOT_KEYS.map(k => [k, p[k]])) as ProfileSnapshot
+  // 早期版本的快照没有 mcpServerIds,按空列表比较
+  return Object.fromEntries(SNAPSHOT_KEYS.map(k => [k, k === 'mcpServerIds' ? p[k] ?? [] : p[k]])) as ProfileSnapshot
 }
 
 function changedFields(a: ProfileSnapshot, b: ProfileSnapshot): (keyof ProfileSnapshot)[] {
@@ -106,8 +108,17 @@ export class ProfileService {
     return toProfileDto(row)
   }
 
+  /** Profile 只能引用已登记的 MCP 服务 */
+  private checkMcp(ids: string[] | undefined) {
+    if (!ids?.length) return
+    const known = new Set(this.db.select({ id: mcpServers.id }).from(mcpServers).all().map(r => r.id))
+    const missing = ids.filter(id => !known.has(id))
+    if (missing.length) throw badRequest(`MCP 服务不存在:${missing.join('、')}`)
+  }
+
   create(input: ProfileInput, builtin = false): ProfileDto {
     const data = parse(profileInputSchema, input)
+    this.checkMcp(data.mcpServerIds)
     const id = newId('prf')
     this.db.transaction(tx => {
       if (data.isDefault) tx.update(profiles).set({ isDefault: false }).run()
@@ -121,6 +132,7 @@ export class ProfileService {
   update(id: string, input: ProfileInput): ProfileDto {
     const prev = this.get(id)
     const data = parse(profileInputSchema, input)
+    this.checkMcp(data.mcpServerIds)
     if (prev.isDefault && !data.isDefault) throw badRequest('请先把另一个 Profile 设为默认')
     if (prev.builtin && data.provider !== prev.provider) throw badRequest('内置 Profile 的引擎不能修改')
     const snapshot = snapshotOf(data)

+ 239 - 0
apps/server/src/services/mcp-servers.ts

@@ -0,0 +1,239 @@
+// 第三方 MCP 服务的登记与管理(ADR-0021)。连接由 mcp-gateway/upstream.ts 管理,运行时经 MCP Gateway 转接
+import fs from 'node:fs'
+import path from 'node:path'
+import { eq } from 'drizzle-orm'
+import { z } from 'zod'
+import type { AppSettings, McpSecretEntry, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolDto, McpToolPolicy } from '@agent-server/shared'
+import { config } from '../config'
+import type { DB } from '../db'
+import { mcpServers, profiles, type McpCachedTool } from '../db/schema'
+import { badRequest, notFound } from '../lib/errors'
+import { newId, nowIso } from '../lib/ids'
+import { isWithin, realpathLoose } from '../lib/paths'
+import { BUILTIN_TOOL_INFO } from '../mcp-gateway/builtin'
+import { BWRAP, mcpHomeDir, McpUpstream, type McpServerRow } from '../mcp-gateway/upstream'
+import { globalDenyList } from './access-policy'
+
+const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/
+const ENV_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
+const HEADER_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/
+
+const secretSchema = z.object({ name: z.string().min(1).max(200), value: z.string().max(20_000).nullable() })
+const inputSchema = z.object({
+  name: z.string().regex(NAME_RE, '名称只能用小写字母、数字、下划线、连字符(1-32 位,以字母或数字开头)'),
+  title: z.string().trim().min(1).max(60),
+  description: z.string().max(1000).default(''),
+  transport: z.enum(['http', 'stdio']),
+  enabled: z.boolean().default(true),
+  url: z.string().max(2000).default(''),
+  headers: z.array(secretSchema).max(50).default([]),
+  command: z.string().max(500).default(''),
+  args: z.array(z.string().max(2000)).max(100).default([]),
+  env: z.array(secretSchema).max(100).default([]),
+  allowNetwork: z.boolean().default(false),
+  readPaths: z.array(z.string()).max(50).default([]),
+  writePaths: z.array(z.string()).max(50).default([]),
+})
+
+/** 没有单独设置时:服务声明为只读的工具直接调用,其余跟随权限模式 */
+export const defaultPolicy = (t: Pick<McpCachedTool, 'readOnly'>): McpToolPolicy => (t.readOnly ? 'auto' : 'ask')
+
+export class McpServerService {
+  constructor(private db: DB, private upstream: McpUpstream, private settings: () => AppSettings, private audit: (summary: string) => void) {}
+
+  state(): McpStateDto {
+    return { builtin: BUILTIN_TOOL_INFO, servers: this.list(), sandboxAvailable: !!BWRAP }
+  }
+
+  list(): McpServerDto[] {
+    const usedBy = this.usage()
+    return this.db.select().from(mcpServers).orderBy(mcpServers.name).all().map(r => this.toDto(r, usedBy.get(r.id) ?? []))
+  }
+
+  get(id: string): McpServerDto {
+    return this.toDto(this.row(id), this.usage().get(id) ?? [])
+  }
+
+  /** 运行时:取 Profile 勾选且已启用的服务 */
+  rows(ids: string[]): McpServerRow[] {
+    if (!ids.length) return []
+    return this.db.select().from(mcpServers).all().filter(r => r.enabled && ids.includes(r.id))
+  }
+
+  create(input: McpServerInput): McpServerDto {
+    const data = this.validate(input, null)
+    if (this.db.select().from(mcpServers).where(eq(mcpServers.name, data.name)).get()) throw badRequest(`名称 ${data.name} 已被使用`)
+    const id = newId('mcp')
+    this.db.insert(mcpServers).values({ id, ...data }).run()
+    this.audit(`添加 MCP 服务 ${data.name}(${data.transport === 'http' ? data.url : data.command})`)
+    return this.get(id)
+  }
+
+  update(id: string, input: McpServerInput): McpServerDto {
+    const prev = this.row(id)
+    const data = this.validate(input, prev)
+    const dup = this.db.select().from(mcpServers).where(eq(mcpServers.name, data.name)).get()
+    if (dup && dup.id !== id) throw badRequest(`名称 ${data.name} 已被使用`)
+    this.db.update(mcpServers).set({ ...data, updatedAt: nowIso() }).where(eq(mcpServers.id, id)).run()
+    this.audit(`修改 MCP 服务 ${data.name}`)
+    return this.get(id)
+  }
+
+  async delete(id: string) {
+    const r = this.row(id)
+    // 从引用它的 Profile 中移除
+    for (const p of this.db.select().from(profiles).all()) {
+      if (p.mcpServerIds.includes(id)) this.db.update(profiles).set({ mcpServerIds: p.mcpServerIds.filter(x => x !== id) }).where(eq(profiles.id, p.id)).run()
+    }
+    this.db.delete(mcpServers).where(eq(mcpServers.id, id)).run()
+    await this.upstream.close(id)
+    fs.rmSync(mcpHomeDir(id), { recursive: true, force: true })
+    this.audit(`删除 MCP 服务 ${r.name}`)
+  }
+
+  /** 重新连接并更新工具列表 */
+  async refresh(id: string): Promise<McpServerDto> {
+    const r = this.row(id)
+    await this.upstream.close(id)
+    try {
+      await this.updateTools(r, await this.upstream.listTools(r))
+    } catch {
+      // 错误记录在连接状态中,随 DTO 返回
+    }
+    return this.get(id)
+  }
+
+  async listToolsFor(r: McpServerRow): Promise<McpCachedTool[]> {
+    const tools = await this.upstream.listTools(r)
+    this.updateTools(r, tools)
+    return tools
+  }
+
+  setToolPolicy(id: string, tool: string, policy: McpToolPolicy): McpServerDto {
+    const r = this.row(id)
+    this.db.update(mcpServers).set({ toolPolicies: { ...r.toolPolicies, [tool]: policy }, updatedAt: nowIso() }).where(eq(mcpServers.id, id)).run()
+    this.audit(`MCP 工具 ${r.name}__${tool} 设为 ${({ auto: '直接调用', ask: '跟随权限模式', off: '关闭' } as const)[policy]}`)
+    return this.get(id)
+  }
+
+  /** 测试一份配置(新建前或修改前);已登记的服务可以只传新值,未修改的密钥沿用原值 */
+  async test(input: McpServerInput, id: string | null): Promise<McpTestResultDto> {
+    const prev = id ? this.row(id) : null
+    const data = this.validate(input, prev, true)
+    const t0 = Date.now()
+    const tempId = id ?? `test_${Date.now()}`
+    try {
+      const { tools, stderr } = await this.upstream.test({ ...(prev ?? emptyRow()), ...data, id: tempId })
+      return { ok: true, tools: tools.map(t => toToolDto(t, prev?.toolPolicies ?? {})), error: null, stderrTail: stderr.length ? stderr.join('\n') : null, ms: Date.now() - t0 }
+    } catch (err) {
+      return { ok: false, tools: [], error: (err as Error).message, stderrTail: null, ms: Date.now() - t0 }
+    } finally {
+      // 未登记的服务测试时使用的临时数据目录
+      if (!id) fs.rmSync(mcpHomeDir(tempId), { recursive: true, force: true })
+    }
+  }
+
+  private updateTools(r: McpServerRow, tools: McpCachedTool[]) {
+    this.db.update(mcpServers).set({ toolsCache: tools, toolsUpdatedAt: nowIso() }).where(eq(mcpServers.id, r.id)).run()
+  }
+
+  private row(id: string): McpServerRow {
+    const r = this.db.select().from(mcpServers).where(eq(mcpServers.id, id)).get()
+    if (!r) throw notFound('MCP 服务')
+    return r
+  }
+
+  private usage(): Map<string, string[]> {
+    const out = new Map<string, string[]>()
+    for (const p of this.db.select({ name: profiles.name, ids: profiles.mcpServerIds }).from(profiles).all()) {
+      for (const id of p.ids) out.set(id, [...(out.get(id) ?? []), p.name])
+    }
+    return out
+  }
+
+  private toDto(r: McpServerRow, profileNames: string[]): McpServerDto {
+    const masked = (rec: Record<string, string>): McpSecretEntry[] => Object.keys(rec).map(name => ({ name, value: null, isSet: rec[name] !== '' }))
+    return {
+      id: r.id, name: r.name, title: r.title, description: r.description, transport: r.transport, enabled: r.enabled,
+      url: r.url, headers: masked(r.headers), command: r.command, args: r.args, env: masked(r.env),
+      allowNetwork: r.allowNetwork, readPaths: r.readPaths, writePaths: r.writePaths,
+      tools: r.toolsCache.map(t => toToolDto(t, r.toolPolicies)), toolsUpdatedAt: r.toolsUpdatedAt,
+      ...this.upstream.status(r.id), profileNames, createdAt: r.createdAt, updatedAt: r.updatedAt,
+    }
+  }
+
+  private validate(input: McpServerInput, prev: McpServerRow | null, forTest = false) {
+    const r = inputSchema.safeParse(input)
+    if (!r.success) {
+      const i = r.error.issues[0]!
+      throw badRequest(`${i.path.length ? `${i.path.join('.')}:` : ''}${i.message}`)
+    }
+    const d = r.data
+    if (d.transport === 'http') {
+      let u: URL
+      try {
+        u = new URL(d.url)
+      } catch {
+        throw badRequest('服务地址不是有效的 URL')
+      }
+      if (!['http:', 'https:'].includes(u.protocol)) throw badRequest('服务地址只支持 http / https')
+    } else {
+      if (!d.command.trim()) throw badRequest('请填写启动命令')
+      if (!BWRAP && !forTest) throw badRequest('服务器上没有 bubblewrap(bwrap),无法运行本地 MCP 服务')
+    }
+    for (const h of d.headers) if (!HEADER_RE.test(h.name)) throw badRequest(`请求头名称不正确:${h.name}`)
+    for (const e of d.env) if (!ENV_RE.test(e.name)) throw badRequest(`环境变量名称不正确:${e.name}`)
+    return {
+      name: d.name, title: d.title, description: d.description, transport: d.transport, enabled: d.enabled,
+      url: d.transport === 'http' ? d.url.trim() : '',
+      headers: d.transport === 'http' ? mergeSecrets(d.headers, prev?.headers ?? {}) : {},
+      command: d.transport === 'stdio' ? d.command.trim() : '',
+      args: d.transport === 'stdio' ? d.args : [],
+      env: d.transport === 'stdio' ? mergeSecrets(d.env, prev?.env ?? {}) : {},
+      allowNetwork: d.transport === 'stdio' && d.allowNetwork,
+      readPaths: d.transport === 'stdio' ? this.checkPaths(d.readPaths, 'read') : [],
+      writePaths: d.transport === 'stdio' ? this.checkPaths(d.writePaths, 'write') : [],
+    }
+  }
+
+  /** 沙箱中额外开放的路径:必须是存在的绝对路径;不能是全局禁止项、服务数据目录或会话工作区 */
+  private checkPaths(paths: string[], kind: 'read' | 'write'): string[] {
+    const s = this.settings()
+    const deny = globalDenyList(s)
+    return paths.map(p => p.trim()).filter(Boolean).map(p => {
+      if (!path.isAbsolute(p)) throw badRequest(`路径必须是绝对路径:${p}`)
+      const real = realpathLoose(p)
+      if (!fs.existsSync(real)) throw badRequest(`路径不存在:${p}`)
+      if (real === '/') throw badRequest('不能开放根目录')
+      for (const protectedDir of [config.dataDir, s.paths.workspaceRoot]) {
+        if (isWithin(real, realpathLoose(protectedDir)) || isWithin(realpathLoose(protectedDir), real)) throw badRequest(`不能开放服务数据目录或会话工作区:${p}`)
+      }
+      const hit = deny.find(d => !d.includes('*') && (isWithin(real, d) || isWithin(d, real)))
+      if (hit) throw badRequest(`${kind === 'write' ? '可写' : '只读'}路径与全局禁止项冲突:${hit}`)
+      return real
+    })
+  }
+}
+
+/** 密钥:value 为 null 表示保留原值 */
+function mergeSecrets(entries: { name: string; value: string | null }[], prev: Record<string, string>): Record<string, string> {
+  const out: Record<string, string> = {}
+  for (const e of entries) {
+    const v = e.value ?? prev[e.name]
+    if (v === undefined) throw badRequest(`请填写 ${e.name} 的值`)
+    out[e.name] = v
+  }
+  return out
+}
+
+function toToolDto(t: McpCachedTool, policies: Record<string, McpToolPolicy>): McpToolDto {
+  return { name: t.name, description: t.description, readOnly: t.readOnly, policy: policies[t.name] ?? defaultPolicy(t) }
+}
+
+function emptyRow(): McpServerRow {
+  const now = nowIso()
+  return {
+    id: '', name: '', title: '', description: '', transport: 'http', enabled: true, url: '', headers: {}, command: '', args: [], env: {},
+    allowNetwork: false, readPaths: [], writePaths: [], toolPolicies: {}, toolsCache: [], toolsUpdatedAt: null, createdAt: now, updatedAt: now,
+  }
+}

+ 1 - 1
apps/server/src/services/office.ts

@@ -47,7 +47,7 @@ export function sandboxArgs(workspaceDir: string, readOnly: string[] = []): stri
 
 let socketCache: string[] | null = null
 /** /usr 与 /etc 下的 Unix socket(进程启动时扫描一次) */
-function hostSockets(): string[] {
+export function hostSockets(): string[] {
   if (socketCache) return socketCache
   const found: string[] = []
   const walk = (dir: string, depth: number) => {

+ 30 - 14
apps/server/src/services/runs.ts

@@ -1,4 +1,5 @@
 // Run 调度:排队、并发控制、执行、中断与重启恢复(docs/design/02-architecture.md §3.2)
+import crypto from 'node:crypto'
 import fsp from 'node:fs/promises'
 import path from 'node:path'
 import { eq, inArray } from 'drizzle-orm'
@@ -15,8 +16,10 @@ import { resolveAccess } from './access-policy'
 import { parse, type AuditService, type KnowledgeService, type ProfileService } from './catalog'
 import type { EventStore } from './event-store'
 import type { FileService } from './files'
-import type { InteractionService } from './interactions'
+import type { InteractionService, NewApproval, NewQuestion } from './interactions'
 import type { CommunitySkillService } from './community-skills'
+import type { McpServerService } from './mcp-servers'
+import type { McpServerRow } from '../mcp-gateway/upstream'
 import type { PluginService } from './plugins'
 import type { UsageService } from './usage'
 import type { RunTokenRegistry } from '../mcp-gateway/tokens'
@@ -64,6 +67,7 @@ export class RunManager {
       usage: UsageService
       plugins: PluginService
       communitySkills: CommunitySkillService
+      mcpServers: McpServerService
       gatewayTokens: RunTokenRegistry
       providers: Partial<Record<ProviderId, AgentProvider>>
     },
@@ -175,10 +179,25 @@ export class RunManager {
       })
       emit('run.started', { provider: session.provider, model: session.model })
       const sessionRow = session
+      // 审批与提问:引擎与 MCP Gateway 共用,等待回复期间 Run 处于 waiting_input
+      const interact = async (req: NewApproval | NewQuestion) => {
+        a.status = 'waiting_input'
+        deps.db.update(runs).set({ status: 'waiting_input' }).where(eq(runs.id, runId)).run()
+        try {
+          return await deps.interactions.request(sessionId, runId, req, a.controller.signal)
+        } finally {
+          if (!deps.interactions.hasPending(runId) && !a.controller.signal.aborted) {
+            a.status = 'running'
+            deps.db.update(runs).set({ status: 'running' }).where(eq(runs.id, runId)).run()
+          }
+        }
+      }
       gatewayToken = deps.gatewayTokens.issue({
         sessionId, runId, provider: session.provider, workspaceDir: session.workspaceDir, access, permissionMode, settings,
         knowledgeRoots: knowledgeRoots.filter(k => k.enabled && profile.knowledgeRootIds.includes(k.id)),
+        mcpServerIds: profile.mcpServerIds,
         audit: (kind, summary, detail) => deps.audit.log({ actor: `agent:${sessionRow.provider}`, sessionId, kind, summary, detail }),
+        interact,
       }, { stablePerSession: session.provider === 'codex' })
 
       const result = await provider.run({
@@ -214,22 +233,12 @@ export class RunManager {
             scanTimer = setTimeout(() => void scanFiles().catch(() => {}), 300)
           }
         },
-        interact: async req => {
-          a.status = 'waiting_input'
-          deps.db.update(runs).set({ status: 'waiting_input' }).where(eq(runs.id, runId)).run()
-          try {
-            return await deps.interactions.request(sessionId, runId, req, a.controller.signal)
-          } finally {
-            if (!deps.interactions.hasPending(runId) && !a.controller.signal.aborted) {
-              a.status = 'running'
-              deps.db.update(runs).set({ status: 'running' }).where(eq(runs.id, runId)).run()
-            }
-          }
-        },
+        interact,
         setProviderSessionId: id => deps.sessions.setProviderSessionId(sessionId, id),
         audit: (kind, summary, detail) => deps.audit.log({ actor: `agent:${session!.provider}`, sessionId, kind, summary, detail }),
         log,
-        gateway: { url: gatewayUrl(), token: gatewayToken },
+        // 网址带上第三方 MCP 工具的版本:勾选或工具策略变化时,Codex 已加载的会话因配置不同而分叉,重新获取工具列表
+        gateway: { url: `${gatewayUrl()}?tools=${mcpToolsVersion(deps.mcpServers.rows(profile.mcpServerIds))}`, token: gatewayToken },
       })
 
       clearTimeout(scanTimer)
@@ -337,3 +346,10 @@ function safe<T>(fn: () => T): T | undefined {
     return undefined
   }
 }
+
+/** 第三方 MCP 工具的版本:服务、工具列表与策略任一变化时不同 */
+function mcpToolsVersion(rows: McpServerRow[]): string {
+  if (!rows.length) return '0'
+  const sig = rows.map(r => [r.id, r.updatedAt, r.toolsUpdatedAt]).sort()
+  return crypto.createHash('sha256').update(JSON.stringify(sig)).digest('hex').slice(0, 12)
+}

+ 19 - 0
apps/server/test/fixtures-mcp-stdio.mjs

@@ -0,0 +1,19 @@
+#!/usr/bin/env node
+// 测试用的本地 MCP 服务(stdio):在沙箱中运行,报告环境与文件访问情况
+import fs from 'node:fs'
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
+import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
+import { z } from 'zod'
+
+const server = new McpServer({ name: 'fixture-stdio', version: '1.0.0' })
+server.registerTool('whoami', { description: '报告沙箱环境', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => ({
+  content: [{ type: 'text', text: JSON.stringify({ secret: process.env.FIXTURE_SECRET ?? null, home: process.env.HOME, cwd: process.cwd() }) }],
+}))
+server.registerTool('read_file', { description: '读取文件', inputSchema: { path: z.string() }, annotations: { readOnlyHint: true } }, async ({ path }) => {
+  try {
+    return { content: [{ type: 'text', text: fs.readFileSync(path, 'utf8').slice(0, 200) }] }
+  } catch (err) {
+    return { content: [{ type: 'text', text: `ERR ${err.code}` }], isError: true }
+  }
+})
+await server.connect(new StdioServerTransport())

+ 2 - 0
apps/server/test/gateway.test.ts

@@ -27,7 +27,9 @@ function runCtx(over: Partial<GatewayRunContext> = {}): GatewayRunContext {
     sessionId: 's', runId: 'r', provider: 'codex', workspaceDir: ws, permissionMode: 'ask', settings,
     access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
     knowledgeRoots: [{ id: 'kb', name: '账单', path: '/data/bills', description: '家庭账单', access: 'read', enabled: true, check: { exists: true, readable: true, isDirectory: true, denied: null, underHome: false, message: null } }],
+    mcpServerIds: [],
     audit: (kind, summary) => audits.push(`${kind}:${summary}`),
+    interact: async () => ({ resolution: 'allowed', scope: 'once' }),
     ...over,
   }
 }

+ 196 - 0
apps/server/test/mcp.test.ts

@@ -0,0 +1,196 @@
+import fs from 'node:fs'
+import http from 'node:http'
+import type { AddressInfo } from 'node:net'
+import os from 'node:os'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import pino from 'pino'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { Client } from '@modelcontextprotocol/sdk/client/index.js'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
+import { z } from 'zod'
+import type { McpServerInput } from '@agent-server/shared'
+import { createApp, type App } from '../src/app'
+import { config } from '../src/config'
+import { buildServer } from '../src/http/server'
+import type { GatewayRunContext } from '../src/mcp-gateway/tokens'
+import { BWRAP } from '../src/mcp-gateway/upstream'
+import { resolveAccess } from '../src/services/access-policy'
+import type { InteractionOutcome, NewApproval } from '../src/services/interactions'
+
+const here = path.dirname(fileURLToPath(import.meta.url))
+let app: App
+let server: Awaited<ReturnType<typeof buildServer>>
+let base: string
+let upstream: http.Server
+let upstreamUrl: string
+const seenAuth: (string | undefined)[] = []
+
+/** 测试用的远程 MCP 服务(Streamable HTTP,无状态) */
+function startUpstream(): Promise<string> {
+  upstream = http.createServer(async (req, res) => {
+    seenAuth.push(req.headers.authorization)
+    if (req.headers.authorization !== 'Bearer up-secret') return res.writeHead(401).end()
+    const mcp = new McpServer({ name: 'fixture-http', version: '1.0.0' })
+    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) }] }))
+    const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
+    res.on('close', () => { void t.close(); void mcp.close() })
+    await mcp.connect(t)
+    let body = ''
+    req.on('data', c => (body += c))
+    req.on('end', () => void t.handleRequest(req, res, body ? JSON.parse(body) : undefined))
+  })
+  return new Promise(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}/mcp`)))
+}
+
+const httpInput = (over: Partial<McpServerInput> = {}): McpServerInput => ({
+  name: 'demo', title: '演示服务', description: '', transport: 'http', enabled: true, url: upstreamUrl,
+  headers: [{ name: 'Authorization', value: 'Bearer up-secret' }], command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [], ...over,
+})
+
+const approvals: NewApproval[] = []
+let answer: InteractionOutcome = { resolution: 'allowed', scope: 'once' }
+function runCtx(over: Partial<GatewayRunContext> = {}): GatewayRunContext {
+  const ws = path.join(config.dataDir, 'workspaces', `ses_mcp_${Math.random().toString(36).slice(2)}`)
+  fs.mkdirSync(ws, { recursive: true })
+  const settings = app.settings.get()
+  return {
+    sessionId: `ses_${Math.random().toString(36).slice(2)}`, runId: 'r', provider: 'claude', workspaceDir: ws, permissionMode: 'ask', settings,
+    access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
+    knowledgeRoots: [], mcpServerIds: [], audit: () => {},
+    interact: async req => { approvals.push(req); return answer },
+    ...over,
+  }
+}
+async function gateway(ctx: GatewayRunContext) {
+  const client = new Client({ name: 'test', version: '1' })
+  await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${app.gatewayTokens.issue(ctx)}` } } }))
+  return client
+}
+const text = (r: unknown) => ((r as { content: { text: string }[] }).content[0]!.text)
+
+beforeAll(async () => {
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: {} })
+  server = await buildServer(app, { logger: log })
+  base = await server.listen({ port: 0, host: '127.0.0.1' })
+  upstreamUrl = await startUpstream()
+})
+afterAll(async () => {
+  await app.mcpUpstream.closeAll()
+  await server.close()
+  upstream.close()
+})
+
+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']])
+    const bad = await app.mcpServers.test(httpInput({ headers: [{ name: 'Authorization', value: 'Bearer wrong' }] }), null)
+    expect(bad.ok).toBe(false)
+
+    const s = app.mcpServers.create(httpInput())
+    expect(s.headers).toEqual([{ name: 'Authorization', value: null, isSet: true }])
+    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(() => 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(/请填写/)
+  })
+
+  it('经网关转接:只提供 Profile 勾选的服务;只读工具直接调用,其余审批;只读模式只列只读工具', async () => {
+    const id = app.mcpServers.list().find(s => s.name === 'demo')!.id
+    const none = await gateway(runCtx())
+    expect((await none.listTools()).tools.map(t => t.name)).not.toContain('demo__echo')
+    await none.close()
+
+    const ctx = runCtx({ mcpServerIds: [id] })
+    const c = await gateway(ctx)
+    const names = (await c.listTools()).tools.map(t => t.name)
+    expect(names).toEqual(expect.arrayContaining(['convert_document', 'web_fetch', 'demo__echo', 'demo__write_note', 'demo__big']))
+    expect(text(await c.callTool({ name: 'demo__echo', arguments: { text: '你好' } }))).toBe('echo:你好')
+    expect(approvals).toHaveLength(0)
+
+    answer = { resolution: 'denied', reason: '不需要' }
+    const denied = await c.callTool({ name: 'demo__write_note', arguments: { note: 'a' } })
+    expect(denied.isError).toBe(true)
+    expect(text(denied)).toMatch(/拒绝.*不需要/)
+    expect(approvals[0]).toMatchObject({ kind: 'approval', tool: 'mcp__gateway__demo__write_note', title: '演示服务2 · write_note', input: { note: 'a' } })
+
+    answer = { resolution: 'allowed', scope: 'session' }
+    expect(text(await c.callTool({ name: 'demo__write_note', arguments: { note: 'b' } }))).toBe('saved:b')
+    expect(text(await c.callTool({ name: 'demo__write_note', arguments: { note: 'c' } }))).toBe('saved:c')
+    expect(approvals).toHaveLength(2) // 本会话允许后不再询问
+
+    const big = text(await c.callTool({ name: 'demo__big', arguments: {} }))
+    expect(big.length).toBeLessThan(101_000)
+    const saved = /保存到 (\S+))/.exec(big)![1]!
+    expect(fs.readFileSync(path.join(ctx.workspaceDir, saved), 'utf8')).toHaveLength(150_000)
+    await c.close()
+
+    const ro = await gateway(runCtx({ mcpServerIds: [id], permissionMode: 'readonly' }))
+    const roNames = (await ro.listTools()).tools.map(t => t.name)
+    expect(roNames).toContain('demo__echo')
+    expect(roNames).not.toContain('demo__write_note')
+    expect((await ro.callTool({ name: 'demo__write_note', arguments: { note: 'x' } })).isError).toBe(true)
+    await ro.close()
+
+    // 自动模式不询问;关闭的工具不提供
+    const before = approvals.length
+    const auto = await gateway(runCtx({ mcpServerIds: [id], permissionMode: 'auto' }))
+    expect(text(await auto.callTool({ name: 'demo__write_note', arguments: { note: 'd' } }))).toBe('saved:d')
+    expect(approvals).toHaveLength(before)
+    app.mcpServers.setToolPolicy(id, 'echo', 'off')
+    expect((await auto.listTools()).tools.map(t => t.name)).not.toContain('demo__echo')
+    await auto.close()
+    expect(seenAuth.every(a => a === 'Bearer up-secret' || a === 'Bearer wrong')).toBe(true)
+  })
+
+  it('Profile 引用 MCP 服务;删除服务时从 Profile 中移除', async () => {
+    const id = app.mcpServers.list().find(s => s.name === 'demo')!.id
+    const { id: _i, version: _v, updatedAt: _u, builtin: _b, ...input } = app.profiles.getBuiltin()
+    const p = app.profiles.create({ ...input, name: 'MCP 测试', isDefault: false, mcpServerIds: [id] })
+    expect(app.mcpServers.get(id).profileNames).toEqual(['MCP 测试'])
+    expect(() => app.profiles.create({ ...input, name: 'x', isDefault: false, mcpServerIds: ['mcp_none'] })).toThrow(/不存在/)
+    await app.mcpServers.delete(id)
+    expect(app.profiles.get(p.id).mcpServerIds).toEqual([])
+  })
+})
+
+describe.skipIf(!BWRAP)('第三方 MCP(本地进程,沙箱)', () => {
+  it('在沙箱中运行:密钥经环境变量传入、独立 home、只能读开放的路径', async () => {
+    const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-outside-'))
+    fs.writeFileSync(path.join(outside, 'secret.txt'), 'TOP-SECRET')
+    const input: McpServerInput = {
+      name: 'local', title: '本地服务', description: '', transport: 'stdio', enabled: true, url: '', headers: [],
+      command: process.execPath, args: [path.join(here, 'fixtures-mcp-stdio.mjs')],
+      env: [{ name: 'FIXTURE_SECRET', value: 's3cr3t' }], allowNetwork: false,
+      // 测试脚本与依赖所在目录(只读)
+      readPaths: [here, path.resolve(here, '..', 'node_modules'), path.resolve(here, '../../../node_modules'), path.dirname(process.execPath)], writePaths: [],
+    }
+    const s = app.mcpServers.create(input)
+    expect(s.env).toEqual([{ name: 'FIXTURE_SECRET', value: null, isSet: true }])
+    const refreshed = await app.mcpServers.refresh(s.id)
+    expect(refreshed.lastError).toBeNull()
+    expect(refreshed.tools.map(t => t.name)).toEqual(['whoami', 'read_file'])
+
+    const c = await gateway(runCtx({ mcpServerIds: [s.id] }))
+    const who = JSON.parse(text(await c.callTool({ name: 'local__whoami', arguments: {} })))
+    expect(who.secret).toBe('s3cr3t')
+    expect(who.home).toMatch(/data\/mcp\/mcp_/)
+    const denied = await c.callTool({ name: 'local__read_file', arguments: { path: path.join(outside, 'secret.txt') } })
+    expect(text(denied)).toMatch(/ERR ENOENT/)
+    expect(text(await c.callTool({ name: 'local__read_file', arguments: { path: path.join(here, 'setup.ts') } }))).toContain('AGENT_SERVER_DATA_DIR')
+    await c.close()
+
+    expect(() => app.mcpServers.create({ ...input, name: 'bad1', readPaths: [config.dataDir] })).toThrow(/服务数据目录/)
+    expect(() => app.mcpServers.create({ ...input, name: 'bad2', readPaths: ['relative/path'] })).toThrow(/绝对路径/)
+  }, 60_000)
+})

+ 1 - 0
docs/README.md

@@ -50,6 +50,7 @@ docs/
 | [0018](adr/0018-codex-curated-plugins-playground-only.md) | Codex 插件(openai-curated)只在 Playground 中使用 | Accepted |
 | [0019](adr/0019-browser-tokens-and-sdk-distribution.md) | 浏览器令牌、CORS 与 SDK 包分发 | Accepted |
 | [0020](adr/0020-codex-fork-on-config-change.md) | Codex 会话配置变化时分叉 | Accepted |
+| [0021](adr/0021-third-party-mcp-via-gateway.md) | 第三方 MCP 服务经 MCP Gateway 转接 | Accepted |
 
 ## 调研
 

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

@@ -0,0 +1,38 @@
+# ADR-0021 第三方 MCP 服务经 MCP Gateway 转接
+
+- 状态:Accepted · 日期:2026-10-24 · 补充 ADR-0006
+
+## 背景
+两个引擎原先只连接 AgentServer 内置的 MCP Gateway。个人终端里给 Claude Code / Codex 配置的 MCP 被刻意隔离(不读取个人配置、专用 CODEX_HOME),后台也没有添加第三方 MCP 的入口。需要支持两类第三方服务:远程 HTTP 服务与本地进程(npx / uvx 等启动的 stdio 服务)。
+
+## 决策
+- **统一经网关转接**:网关作为 MCP 客户端连接第三方服务,把它们的工具与内置工具一起提供给两个引擎。不把第三方服务直接写进引擎配置。
+  - 工具名为 `<服务名>__<工具名>`,引擎侧显示为 `mcp__gateway__<服务名>__<工具名>`
+  - 一处配置,两个引擎共用
+  - 密钥(请求头、环境变量)只保存在服务端,后台只写不读
+- **按 Profile 开放**:Profile 新增 `mcpServerIds`。网关按每次对话的令牌只列出、只允许调用该 Profile 勾选且已启用的服务。内置工具始终可用。
+- **工具策略**(后台可逐个修改),默认值:服务声明了 `readOnlyHint` 的工具为"直接调用",其余为"跟随权限模式"。
+  - 直接调用:不询问
+  - 跟随权限模式:需审批模式下在网关内发起审批卡片,可"本会话允许";自动模式直接调用
+  - 关闭:不提供
+  - 只读模式下只列出只读工具
+  - 审批与审计都在网关内完成
+- **本地进程放进沙箱**(bubblewrap,与 LibreOffice 转换相同的隔离思路):
+  - /usr、/etc 只读,宿主 Unix socket 不可达
+  - 独立的可写 home `data/mcp/<id>`,用于 npx / uvx 缓存
+  - 默认断网,可按服务开放网络
+  - 只能访问后台额外开放的路径;不能开放服务数据目录、会话工作区和全局禁止项
+  - 密钥只通过环境变量传给该进程,不出现在命令行参数中
+- **远程服务**:Streamable HTTP,失败时退回旧版 SSE。可配置请求头(如 Bearer 令牌)。需要 OAuth 登录的服务暂不支持。
+- **连接**:按需建立,多个对话共用,空闲 10 分钟关闭;配置变化时重新连接。工具列表缓存在数据库中,引擎列工具时不必每次启动服务。
+- **偶发网络错误**:连接重试一次;只读工具调用重新连接后重试一次;写工具不自动重试,避免重复执行。
+- **结果处理**:超过 10 万字的文本、超过 5 MB 的图片 / 音频存入工作区 `.agent/mcp/`,返回路径。
+- **超时**:
+  - 第三方工具调用最长 10 分钟(有进度时顺延)
+  - 引擎等待网关工具的时间 = 交互超时 + 10 分钟 + 余量,Claude 用 `MCP_TOOL_TIMEOUT`,Codex 用 `tool_timeout_sec`
+- **Codex 刷新工具**:已加载的 Codex 会话不会重新列工具。因此网关地址带上第三方工具的版本(勾选的服务、工具列表与策略的更新时间);变化时会话配置不同,触发分叉(ADR-0020),重新获取工具列表。
+
+## 后果
+- 后台新增"MCP 服务"页面:内置工具只读展示;第三方服务的添加、测试连接、刷新工具、逐个工具策略、启停、删除
+- 本地进程以服务账号运行在沙箱中,"允许联网"后不限制目标地址;只应添加可信的 MCP 程序,建议写明版本号
+- 已实测:官方示例服务 `@modelcontextprotocol/server-everything`(npx,沙箱内)与公开的 DeepWiki 远程服务,Claude 对话中两类工具都能调用,非只读工具会弹出审批

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

@@ -1,6 +1,6 @@
 # 08 内部平台数据接入:MCP Gateway
 
-> **状态:MCP Gateway 已上线(2026-10-23,M2 ②)**,承载内置工具;内部平台连接器(§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 的上下文
@@ -81,7 +81,7 @@ export default defineConnector({
 | `web_fetch` | 抓取 URL → 清洗为 Markdown;拦截私网地址;遵守域名黑名单 | M2 |
 | `kb_search` | 本地资料库全文检索(见 07 §4.1) | M3 |
 | `kb_list_roots` | 列出当前 Profile 可用的资料库及说明 | M2 |
-| `ask_user` | 给 Codex 用的结构化提问(对齐 Claude 的 AskUserQuestion) | M2 |
+| ~~`ask_user`~~ | 不再需要:两个引擎都有原生提问并已接入交互卡片 | — |
 
 ## 6. 后台管理
 - 连接器列表:启用状态、健康检查(每个连接器可以实现 `ping()`)、最近调用情况

+ 1 - 0
docs/design/09-admin-ui.md

@@ -28,6 +28,7 @@
 | 📚 资料库 | 资料目录列表 | 名称、路径(带目录选择器)、说明、读写属性、启用;可见性检测结果;被哪些 Profile 使用;索引状态(M3) |
 | 🔑 引擎状态 | Claude / Codex | 登录状态、账户摘要、额度、CLI 路径与版本、生效的工具清单(用于确认个人插件已被禁用);未登录时提示在终端执行 `claude` / `codex login` |
 | 🗝 API Keys | Key 列表 | 签发(明文只显示一次)、吊销、最近使用时间 |
+| 🔌 MCP 服务 | — | 内置工具(只读展示);第三方服务:添加远程 HTTP 或本地进程(沙箱)服务、测试连接、刷新工具、逐个工具设置策略(直接调用 / 跟随权限模式 / 关闭)、启停、删除;密钥只写不读;显示使用它的 Profile 与连接状态(ADR-0021) |
 | 📖 API 协议 | 右侧:调试身份 + 目录(随滚动高亮) | 对外接口(/v1)的完整协议:由 `apps/server/src/api-reference/reference.ts` 生成(测试保证与实际路由一致),跟随亮/暗主题。每个接口可以**调试**:填写参数与请求体后以"后台管理员"或某个平台的身份调用(平台身份由服务端签发 10 分钟的浏览器令牌,API Key 不离开服务端,记入审计),写操作发送前确认,发送消息会提示消耗订阅额度;事件流实时显示(最多 2 分钟 / 200 条,可停止);响应中的 ID 自动预填到其他接口的路径参数。下载 PDF 由服务端无头 Chrome 生成(A4,服务器没有 Chrome 时改用浏览器打印) |
 | 🔌 连接器 | (暂缓) | — |
 | 📜 审计 | 筛选条件 | 命令执行、连接器调用、审批、后台操作 |

+ 80 - 1
packages/shared/src/api.ts

@@ -85,6 +85,8 @@ export interface ProfileDto {
   skills: string[] | 'all'
   fileAccess: FileAccessPolicy
   knowledgeRootIds: string[]
+  /** 可以使用的第三方 MCP 服务(内置工具始终可用) */
+  mcpServerIds: string[]
   isDefault: boolean
   /** 内置 Profile(通用助手)不可删除 */
   builtin: boolean
@@ -92,7 +94,7 @@ export interface ProfileDto {
   updatedAt: string
 }
 
-export type ProfileInput = Omit<ProfileDto, 'id' | 'version' | 'updatedAt' | 'builtin'>
+export type ProfileInput = Omit<ProfileDto, 'id' | 'version' | 'updatedAt' | 'builtin' | 'mcpServerIds'> & { mcpServerIds?: string[] }
 
 /** Profile 历史版本的内容(不含"是否默认") */
 export type ProfileSnapshot = Omit<ProfileInput, 'isDefault'>
@@ -480,3 +482,80 @@ export interface ApiReferenceDto {
   events: ApiEventDef[]
   errors: ApiErrorDef[]
 }
+
+// ---- MCP 服务(经 MCP Gateway 转接,两个引擎共用,ADR-0021)----
+
+/** 工具策略:auto 直接调用;ask 跟随权限模式(需审批时询问);off 不提供 */
+export type McpToolPolicy = 'auto' | 'ask' | 'off'
+
+export interface McpToolDto {
+  name: string
+  description: string
+  /** 服务声明的只读工具(annotations.readOnlyHint);只读模式下只能调用这类工具 */
+  readOnly: boolean
+  policy: McpToolPolicy
+}
+
+/** 密钥类的值(请求头、环境变量):读取时只返回是否已设置 */
+export interface McpSecretEntry {
+  name: string
+  /** 写入时为新值;读取时为 null(不返回密钥)。更新时传 null 表示保留原值 */
+  value: string | null
+  isSet?: boolean
+}
+
+export interface McpServerInput {
+  /** 工具名前缀:小写字母、数字、下划线、连字符,1-32 位 */
+  name: string
+  title: string
+  description: string
+  transport: 'http' | 'stdio'
+  enabled: boolean
+  /** http:服务地址 */
+  url: string
+  headers: McpSecretEntry[]
+  /** stdio:命令、参数、环境变量 */
+  command: string
+  args: string[]
+  env: McpSecretEntry[]
+  /** stdio:沙箱内是否允许联网 */
+  allowNetwork: boolean
+  /** stdio:沙箱内额外只读 / 可写的路径 */
+  readPaths: string[]
+  writePaths: string[]
+}
+
+export interface McpServerDto extends McpServerInput {
+  id: string
+  tools: McpToolDto[]
+  toolsUpdatedAt: string | null
+  status: 'connected' | 'idle' | 'error'
+  lastError: string | null
+  /** 本地进程最近的错误输出 */
+  stderrTail: string | null
+  /** 勾选了这个服务的 Profile */
+  profileNames: string[]
+  createdAt: string
+  updatedAt: string
+}
+
+export interface McpBuiltinToolDto {
+  name: string
+  description: string
+  readOnly: boolean
+}
+
+export interface McpStateDto {
+  builtin: McpBuiltinToolDto[]
+  servers: McpServerDto[]
+  /** 本地进程沙箱(bubblewrap)是否可用 */
+  sandboxAvailable: boolean
+}
+
+export interface McpTestResultDto {
+  ok: boolean
+  tools: McpToolDto[]
+  error: string | null
+  stderrTail: string | null
+  ms: number
+}