瀏覽代碼

feat: 上线 MCP Gateway,两个引擎共用文档转换、公式重算、网页抓取与资料库工具

- 新增 MCP Gateway(POST /mcp):无状态 Streamable HTTP,按运行令牌绑定到
  对应 Run 的会话、工作区、访问策略与权限模式;GET/DELETE 返回 405
- 运行令牌:RunManager 每个 Run 签发,结束即作废(24 小时兜底过期)
- 工具:convert_document、recalc_workbook(由 Claude 进程内工具迁移)、
  web_fetch、kb_list_roots;只读模式拒绝生成文件;调用写入审计(gateway_call)
- web_fetch:在连接层(undici 自定义 lookup)拒绝私网地址,防 DNS 重绑定,
  每跳重定向重新校验;HTML 转 Markdown,超长截断并把全文存入工作区,
  二进制文件下载到工作区;大小与超时上限
- Claude 改为通过 HTTP MCP 使用网关;Codex 在 thread config 中配置网关
  (default_tools_approval_mode = approve,令牌随每次 thread/start / resume 更新)
- 提示词:两个引擎都提供文档转换、公式重算、网页抓取与资料库工具
- isPrivateAddress 移至 lib/net 共用;时间线工具名与审计类型更新
- 文档:08(网关实现记录)、03、11-roadmap

测试:61 个通过(新增网关:令牌鉴权与作废、工具列表、资料库、文档转换、
只读拦截、工作区外输入、内网拦截(含解析到内网的域名)、HTML 转 Markdown)
验收:生产环境 Claude 经网关完成 kb_list_roots、web_fetch、convert_document,
访问内网地址被拦截

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 周之前
父節點
當前提交
002e77bae8

+ 1 - 1
apps/admin/src/views/AuditView.vue

@@ -4,7 +4,7 @@ import PageHeader from '../components/PageHeader.vue'
 import { admin, errorMessage, type AuditRow } from '../lib/api'
 import { formatTime, toast } from '../lib/ui'
 
-const KINDS = { '': '全部', command: '命令执行', file_access_denied: '越权文件访问', network_denied: '网络拦截', approval: '审批', admin: '后台操作' } as const
+const KINDS = { '': '全部', command: '命令执行', file_access_denied: '越权文件访问', network_denied: '网络拦截', approval: '审批', gateway_call: '网关工具', admin: '后台操作' } as const
 const rows = ref<AuditRow[]>([])
 const kind = ref<keyof typeof KINDS>('')
 const expanded = ref<number | null>(null)

+ 4 - 0
apps/server/package.json

@@ -17,6 +17,7 @@
     "@fastify/cookie": "^11.1.3",
     "@fastify/multipart": "^10.1.5",
     "@fastify/static": "^10.1.7",
+    "@modelcontextprotocol/sdk": "1.32.1",
     "@openai/codex": "0.164.0",
     "better-sqlite3": "^13.0.3",
     "drizzle-orm": "^0.45.4",
@@ -25,7 +26,9 @@
     "p-queue": "^10.0.0",
     "picomatch": "^4.0.7",
     "pino": "^10.4.0",
+    "turndown": "^7.2.4",
     "ulid": "^3.0.2",
+    "undici": "^8.12.3",
     "zod": "^4.6.7"
   },
   "devDependencies": {
@@ -33,6 +36,7 @@
     "@types/mime-types": "^3.0.1",
     "@types/node": "^26.6.5",
     "@types/picomatch": "^4.0.3",
+    "@types/turndown": "^5.0.6",
     "drizzle-kit": "^0.31.11",
     "pino-pretty": "^13.2.0",
     "tsup": "^9.0.0",

+ 1 - 1
apps/server/scripts/try-claude-adapter.ts

@@ -16,7 +16,7 @@ const ac = new AbortController()
 const res = await new ClaudeProvider().run({
   sessionId: 'ses_test', runId: 'run_test', model: 'haiku', permissionMode: mode as PermissionMode,
   providerSessionId: null, workspaceDir: ws, systemPromptAppend: '', allowedTools: [], disallowedTools: [], skills: 'all',
-  plugins: [], input: { text: prompt, images: [], attachments: [] }, access, settings, signal: ac.signal,
+  plugins: [], gateway: { url: 'http://127.0.0.1:8788/mcp', token: 'none' }, input: { text: prompt, images: [], attachments: [] }, access, settings, signal: ac.signal,
   emit: (type, payload) => {
     if (type === 'message.delta' || type === 'reasoning.delta') return
     console.log(`[event] ${type}`, JSON.stringify(payload).slice(0, Number(process.env.EVENT_MAX ?? 220)))

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

@@ -13,6 +13,7 @@ import { InteractionService } from './services/interactions'
 import { PluginService } from './services/plugins'
 import { ClaudeLoginManager } from './services/claude-login'
 import { CodexSkillService } from './services/codex-skills'
+import { RunTokenRegistry } from './mcp-gateway/tokens'
 import { RunManager } from './services/runs'
 import { SessionService } from './services/sessions'
 import { SettingsService } from './services/settings'
@@ -40,12 +41,13 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   const adminAuth = new AdminAuthService(db, log, config.adminPassword)
   const plugins = new PluginService(settings, audit)
   const codexSkills = new CodexSkillService(summary => audit.log({ actor: 'admin', kind: 'admin', summary }))
+  const gatewayTokens = new RunTokenRegistry()
   const claudeLogin = new ClaudeLoginManager(() => settings.get().providers.claude.cliPath, summary => audit.log({ actor: 'admin', kind: 'admin', summary }))
   const providers: Partial<Record<ProviderId, AgentProvider>> = opts.providers ?? { claude: new ClaudeProvider(), codex: new CodexProvider(log) }
   sessions.extraModels = p => providers[p]?.knownModels?.() ?? []
-  const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, plugins, providers })
+  const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, plugins, gatewayTokens, providers })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, adminAuth, plugins, claudeLogin, codexSkills, providers, runs }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, adminAuth, plugins, claudeLogin, codexSkills, gatewayTokens, providers, runs }
 }
 
 export type App = ReturnType<typeof createApp>

+ 3 - 0
apps/server/src/config.ts

@@ -29,6 +29,9 @@ export const config = {
   logLevel: env.LOG_LEVEL ?? 'info',
 }
 
+/** 引擎访问 MCP Gateway 的地址(引擎与服务在同一台机器上) */
+export const gatewayUrl = () => `http://127.0.0.1:${config.port}/mcp`
+
 export const dbPath = () => path.join(config.dataDir, 'data', 'agent.db')
 
 function parseTrustProxy(v: string | undefined): boolean | string[] {

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

@@ -131,7 +131,7 @@ export const auditLogs = sqliteTable('audit_logs', {
   ts: text('ts').notNull().default(now),
   actor: text('actor').notNull(),
   sessionId: text('session_id'),
-  kind: text('kind', { enum: ['command', 'file_access_denied', 'network_denied', 'approval', 'admin'] }).notNull(),
+  kind: text('kind', { enum: ['command', 'file_access_denied', 'network_denied', 'approval', 'admin', 'gateway_call'] }).notNull(),
   summary: text('summary').notNull(),
   detail: text('detail', { mode: 'json' }),
 }, t => [index('audit_ts_idx').on(t.ts)])

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

@@ -11,6 +11,7 @@ import { config } from '../config'
 import { AppError } from '../lib/errors'
 import adminRoutes from './routes/admin'
 import v1Routes from './routes/v1'
+import { registerGateway } from '../mcp-gateway/server'
 
 export async function buildServer(app: App, opts: { logger: FastifyServerOptions['loggerInstance'] }) {
   const f = Fastify({ loggerInstance: opts.logger, trustProxy: config.trustProxy, bodyLimit: 2 * 1048576 })
@@ -36,6 +37,7 @@ export async function buildServer(app: App, opts: { logger: FastifyServerOptions
   })
 
   await f.register(v1Routes, { prefix: '/v1', app })
+  registerGateway(f, app.gatewayTokens)
   await f.register(adminRoutes, { prefix: '/admin/v1', app })
 
   // 生产环境由服务托管后台静态资源,同源访问

+ 13 - 0
apps/server/src/lib/net.ts

@@ -0,0 +1,13 @@
+import net from 'node:net'
+
+/** 私网、回环、链路本地、CGNAT 等不应由 Agent 访问的地址 */
+export function isPrivateAddress(ip: string): boolean {
+  if (net.isIPv4(ip)) {
+    const [a, b] = ip.split('.').map(Number) as [number, number]
+    return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
+      || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127)
+  }
+  const v6 = ip.toLowerCase()
+  if (v6.startsWith('::ffff:')) return isPrivateAddress(v6.slice(7))
+  return v6 === '::1' || v6 === '::' || v6.startsWith('fc') || v6.startsWith('fd') || v6.startsWith('fe80')
+}

+ 108 - 0
apps/server/src/mcp-gateway/server.ts

@@ -0,0 +1,108 @@
+// MCP Gateway:服务进程内的 Streamable HTTP MCP 服务(/mcp),两个引擎共用同一套工具(ADR-0006)
+import path from 'node:path'
+import type { FastifyInstance } from 'fastify'
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
+import { z } from 'zod'
+import { CONVERT_FORMATS, convertDocument, recalcWorkbook } from '../services/office'
+import { FetchBlockedError, webFetch } from '../services/web-fetch'
+import type { GatewayRunContext, RunTokenRegistry } from './tokens'
+
+export const GATEWAY_SERVER = 'gateway'
+export const GATEWAY_TOOLS = ['convert_document', 'recalc_workbook', 'web_fetch', 'kb_list_roots'] as const
+
+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 })
+
+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)
+    }
+  }
+
+  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)}`)
+  }))
+
+  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.registerTool('web_fetch', {
+    description: '抓取网页并转换为 Markdown(HTML),或原样返回文本/JSON;PDF、图片、表格等二进制文件会下载到工作区并返回路径。'
+      + '内容过长时只返回前一部分,完整内容保存在工作区。不能访问内网地址。',
+    inputSchema: { url: z.string().describe('http/https 网址') },
+    annotations: { readOnlyHint: true },
+  }, args => run('web_fetch', args, async () => {
+    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
+    }
+  }))
+
+  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) {
+  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 transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
+    reply.hijack()
+    reply.raw.on('close', () => {
+      void transport.close()
+      void server.close()
+    })
+    await server.connect(transport)
+    await transport.handleRequest(req.raw, reply.raw, req.body)
+  })
+  const notAllowed = { jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed' }, id: null }
+  f.get('/mcp', async (_req, reply) => reply.code(405).send(notAllowed))
+  f.delete('/mcp', async (_req, reply) => reply.code(405).send(notAllowed))
+}

+ 43 - 0
apps/server/src/mcp-gateway/tokens.ts

@@ -0,0 +1,43 @@
+// 每个 Run 一个短期令牌:引擎调用网关时携带,网关据此确定会话、工作区与访问策略;Run 结束即作废
+import crypto from 'node:crypto'
+import type { AppSettings, KnowledgeRootDto, PermissionMode } from '@agent-server/shared'
+import type { ResolvedAccess } from '../services/access-policy'
+
+export interface GatewayRunContext {
+  sessionId: string
+  runId: string
+  provider: 'claude' | 'codex'
+  workspaceDir: string
+  access: ResolvedAccess
+  permissionMode: PermissionMode
+  settings: AppSettings
+  knowledgeRoots: KnowledgeRootDto[]
+  audit: (kind: 'gateway_call' | 'network_denied' | 'file_access_denied', summary: string, detail?: unknown) => void
+}
+
+const MAX_AGE_MS = 24 * 3600 * 1000
+
+export class RunTokenRegistry {
+  private tokens = new Map<string, { ctx: GatewayRunContext; issuedAt: number }>()
+
+  issue(ctx: GatewayRunContext): string {
+    const token = `rt_${crypto.randomBytes(32).toString('base64url')}`
+    this.tokens.set(token, { ctx, issuedAt: Date.now() })
+    return token
+  }
+
+  get(token: string | undefined): GatewayRunContext | null {
+    if (!token) return null
+    const e = this.tokens.get(token)
+    if (!e) return null
+    if (Date.now() - e.issuedAt > MAX_AGE_MS) {
+      this.tokens.delete(token)
+      return null
+    }
+    return e.ctx
+  }
+
+  revoke(token: string) {
+    this.tokens.delete(token)
+  }
+}

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

@@ -15,7 +15,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 { OFFICE_SERVER, officeServer } from './tools'
+import { GATEWAY_SERVER } from '../../mcp-gateway/server'
 
 const MAX_TOOL_OUTPUT = 64 * 1024
 
@@ -113,7 +113,8 @@ export class ClaudeProvider implements AgentProvider {
         allowedTools: ctx.allowedTools.length ? ctx.allowedTools : undefined,
         disallowedTools: [...DEFAULT_DISALLOWED_TOOLS, ...ctx.disallowedTools],
         ...skillsOptions(ctx),
-        mcpServers: { [OFFICE_SERVER]: officeServer(ctx.workspaceDir, ctx.settings.paths.pythonRuntime) },
+        // 与 Codex 共用的 MCP Gateway(文档转换、公式重算、网页抓取、资料库)
+        mcpServers: { [GATEWAY_SERVER]: { type: 'http', url: ctx.gateway.url, headers: { Authorization: `Bearer ${ctx.gateway.token}` } } },
         sandbox: toClaudeSandbox(ctx.access, ctx.settings),
         hooks: { PreToolUse: [{ hooks: [preToolUse] }] },
         canUseTool,

+ 9 - 16
apps/server/src/providers/claude/policy.ts

@@ -4,8 +4,13 @@ import dns from 'node:dns/promises'
 import net from 'node:net'
 import path from 'node:path'
 import type { PermissionMode } from '@agent-server/shared'
+import { isPrivateAddress } from '../../lib/net'
 import { checkAccess, type AccessOp, type ResolvedAccess } from '../../services/access-policy'
-import { CONVERT_TOOL, RECALC_TOOL } from './tools'
+import { GATEWAY_SERVER } from '../../mcp-gateway/server'
+
+const GATEWAY_PREFIX = `mcp__${GATEWAY_SERVER}__`
+export const CONVERT_TOOL = `${GATEWAY_PREFIX}convert_document`
+export const RECALC_TOOL = `${GATEWAY_PREFIX}recalc_workbook`
 
 export type ToolDecision =
   | { kind: 'allow' }
@@ -78,10 +83,8 @@ export async function decideTool(tool: string, input: Record<string, unknown>, c
   }
 
   if (SAFE_TOOLS.has(tool)) return { kind: 'allow' }
-  // 文档转换只读写工作区,且在独立沙箱中执行
-  if (tool === CONVERT_TOOL || tool === RECALC_TOOL) {
-    return ctx.permissionMode === 'readonly' ? { kind: 'deny', reason: '当前为只读模式,不允许生成文件', audit: null } : { kind: 'allow' }
-  }
+  // 网关工具在服务端按会话的访问策略与权限模式自行校验(文件只读写工作区、网页抓取拦截内网)
+  if (tool.startsWith(GATEWAY_PREFIX)) return { kind: 'allow' }
   return needsApproval(tool, ctx)
 }
 
@@ -110,17 +113,6 @@ export async function privateUrlReason(raw: string): Promise<string | null> {
   return bad ? `不允许访问内网地址:${host}(${bad})` : null
 }
 
-export function isPrivateAddress(ip: string): boolean {
-  if (net.isIPv4(ip)) {
-    const [a, b] = ip.split('.').map(Number) as [number, number]
-    return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
-      || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127)
-  }
-  const v6 = ip.toLowerCase()
-  if (v6.startsWith('::ffff:')) return isPrivateAddress(v6.slice(7))
-  return v6 === '::1' || v6 === '::' || v6.startsWith('fc') || v6.startsWith('fd') || v6.startsWith('fe80')
-}
-
 /** 工具调用的人类可读摘要,用于时间线和审批卡片 */
 export function toolTitle(tool: string, input: Record<string, unknown>, cwd: string): string {
   const rel = (p: unknown) => {
@@ -140,6 +132,7 @@ export function toolTitle(tool: string, input: Record<string, unknown>, cwd: str
     case 'Agent': case 'Task': return String(input.description ?? '')
     case CONVERT_TOOL: return `${rel(input.input)} → ${String(input.format ?? '')}`
     case RECALC_TOOL: return rel(input.input)
+    case `${GATEWAY_PREFIX}web_fetch`: return String(input.url ?? '')
     default: return ''
   }
 }

+ 0 - 52
apps/server/src/providers/claude/tools.ts

@@ -1,52 +0,0 @@
-// 进程内 MCP 工具(Claude 专用);M2 起同样的能力经 MCP Gateway 提供给 Codex
-import path from 'node:path'
-import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
-import { z } from 'zod'
-import { CONVERT_FORMATS, convertDocument, recalcWorkbook } from '../../services/office'
-
-export const OFFICE_SERVER = 'office'
-export const CONVERT_TOOL = `mcp__${OFFICE_SERVER}__convert_document`
-export const RECALC_TOOL = `mcp__${OFFICE_SERVER}__recalc_workbook`
-
-export function officeServer(workspaceDir: string, pythonRuntime: string) {
-  return createSdkMcpServer({
-    name: OFFICE_SERVER,
-    version: '0.1.0',
-    tools: [
-      tool(
-        'convert_document',
-        '用 LibreOffice 转换文档格式(例如 docx→pdf、xlsx→pdf、pptx→pdf、doc→docx、html→pdf)。'
-          + '输入和输出都必须在工作区内;输出文件名与输入相同、扩展名换成目标格式。不要在 Bash 里直接调用 soffice,它在沙箱中无法运行。',
-        {
-          input: z.string().describe('输入文件路径(相对工作区或绝对路径)'),
-          format: z.enum(CONVERT_FORMATS).describe('目标格式'),
-          out_dir: z.string().optional().describe('输出目录,默认 outputs'),
-        },
-        async args => {
-          try {
-            const out = await convertDocument({ workspaceDir, input: args.input, format: args.format, outDir: args.out_dir })
-            return { content: [{ type: 'text', text: `已生成:${path.relative(workspaceDir, out)}` }] }
-          } catch (err) {
-            return { content: [{ type: 'text', text: (err as Error).message }], isError: true }
-          }
-        },
-      ),
-      tool(
-        'recalc_workbook',
-        '用 LibreOffice 重新计算 Excel 文件中的所有公式并写回原文件,然后报告公式数量和错误单元格(#REF!、#DIV/0! 等)。'
-          + '用 openpyxl 写入公式后必须调用它,否则文件里没有计算结果。不要在 Bash 里调用 soffice 或 recalc.py,它们在沙箱中无法运行。',
-        { input: z.string().describe('xlsx/xlsm 文件路径(相对工作区或绝对路径)') },
-        async args => {
-          try {
-            const r = await recalcWorkbook({ workspaceDir, input: args.input, pythonRuntime })
-            const lines = [`已重算:${path.relative(workspaceDir, r.path)},公式 ${r.formulas} 个,错误 ${r.errorCount} 个`]
-            for (const e of r.errors) lines.push(`- ${e.sheet}!${e.cell}: ${e.error}`)
-            return { content: [{ type: 'text', text: lines.join('\n') }], isError: r.errorCount > 0 }
-          } catch (err) {
-            return { content: [{ type: 'text', text: (err as Error).message }], isError: true }
-          }
-        },
-      ),
-    ],
-  })
-}

+ 12 - 0
apps/server/src/providers/codex/adapter.ts

@@ -5,6 +5,7 @@ import type { AppSettings, ProviderStatusDto, UsagePayload } from '@agent-server
 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 { buildThreadConfig } from './permissions'
 import { ALWAYS_DISABLED, skillDisplayId, skillsToDisable } from './skills'
 import type {
@@ -100,6 +101,17 @@ export class CodexProvider implements AgentProvider {
     const skills = await this.listSkills(ctx.settings, ctx.workspaceDir).catch(() => [])
     const disabled = skillsToDisable(skills, ctx.skills, codexHome(), localPluginName(ctx.settings))
     if (disabled.length) config.skills = { config: disabled }
+    // 与 Claude 共用的 MCP Gateway;令牌每个 Run 不同,thread/start 与 thread/resume 都会重新传入
+    config.mcp_servers = {
+      [GATEWAY_SERVER]: {
+        url: ctx.gateway.url,
+        http_headers: { Authorization: `Bearer ${ctx.gateway.token}` },
+        // 安全校验在网关中完成,不再逐次询问
+        default_tools_approval_mode: 'approve',
+        startup_timeout_sec: 20,
+        tool_timeout_sec: 300,
+      },
+    }
     // 需审批:沙箱内的命令自动执行,越过沙箱边界时请求审批;自动/只读:从不请求,越界直接失败
     const approvalPolicy: AskForApproval = ctx.permissionMode === 'ask' ? 'on-request' : 'never'
     const threadParams = {

+ 2 - 0
apps/server/src/providers/types.ts

@@ -32,6 +32,8 @@ export interface RunContext {
   interact(req: NewApproval | NewQuestion): Promise<InteractionOutcome>
   setProviderSessionId(id: string): void
   audit(kind: 'command' | 'file_access_denied' | 'network_denied' | 'approval', summary: string, detail?: unknown): void
+  /** MCP Gateway 地址与本次 Run 的令牌(两个引擎共用的工具) */
+  gateway: { url: string; token: string }
   log: { info(obj: object, msg?: string): void; warn(obj: object, msg?: string): void; error(obj: object, msg?: string): void }
 }
 

+ 3 - 6
apps/server/src/services/prompt.ts

@@ -9,8 +9,6 @@ export function buildSystemPrompt(opts: {
   profilePrompt: string
   sessionPrompt: string
   permissionMode: PermissionMode
-  /** 是否提供 convert_document / recalc_workbook(目前只有 Claude;M2 的 MCP Gateway 之后两个引擎都有) */
-  officeTools: boolean
 }): string {
   const py = path.join(opts.settings.paths.pythonRuntime, 'bin/python')
   const parts = [
@@ -22,11 +20,10 @@ export function buildSystemPrompt(opts: {
   - **最终交付的文件请保存到 outputs/**,用户可以在界面上直接预览和下载;中间文件随意放置
 - Python:直接使用 \`python\`(即 ${py}),已预装 pandas、numpy、pyarrow、duckdb、matplotlib、seaborn、plotly、openpyxl、xlsxwriter、python-docx、python-pptx、pypdf、pdfplumber、reportlab、markitdown。不要向这个共享环境 pip install;确实需要额外依赖时先询问用户。
 - 系统工具:pandoc、pdftotext/pdftoppm、qpdf、tesseract(含简体中文 OCR)、ripgrep(rg)。
-${opts.officeTools
-    ? `- LibreOffice 相关操作必须使用专用工具,不要在 Bash 里调用 soffice/libreoffice(包括技能自带的 recalc.py 等脚本),它们在沙箱中无法运行:
+- LibreOffice 相关操作必须使用专用工具,不要在命令行里调用 soffice/libreoffice(包括技能自带的 recalc.py 等脚本),它们在沙箱中无法运行:
   - 格式转换(docx/xlsx/pptx → pdf 等):\`convert_document\`
-  - Excel 公式重算与错误检查:\`recalc_workbook\``
-    : '- 沙箱中无法运行 LibreOffice(soffice):暂时不能做 docx/xlsx → pdf 转换和 Excel 公式重算;需要时向用户说明,可以先交付 docx/xlsx。'}
+  - Excel 公式重算与错误检查:\`recalc_workbook\`
+- 抓取网页内容用 \`web_fetch\`(返回 Markdown;PDF 等文件会下载到工作区);查找本地资料前用 \`kb_list_roots\` 查看可用的资料库。
 - Node 脚本可以直接 require 预装的包:docx、pptxgenjs、sharp、react、react-dom、react-icons(不要 npm install)。
 - matplotlib 已配置中文字体,图表可以直接使用中文。
 - 你只能访问被授权的路径;访问被拒绝时向用户说明,不要尝试绕过限制。${opts.permissionMode === 'readonly' ? '\n- 当前会话是只读模式:不能修改文件或执行命令。' : ''}

+ 13 - 1
apps/server/src/services/runs.ts

@@ -17,6 +17,8 @@ import type { EventStore } from './event-store'
 import type { FileService } from './files'
 import type { InteractionService } from './interactions'
 import type { PluginService } from './plugins'
+import type { RunTokenRegistry } from '../mcp-gateway/tokens'
+import { gatewayUrl } from '../config'
 import type { SessionRow, SessionService } from './sessions'
 import type { SettingsService } from './settings'
 import { buildSystemPrompt } from './prompt'
@@ -58,6 +60,7 @@ export class RunManager {
       interactions: InteractionService
       audit: AuditService
       plugins: PluginService
+      gatewayTokens: RunTokenRegistry
       providers: Partial<Record<ProviderId, AgentProvider>>
     },
   ) {
@@ -145,6 +148,7 @@ export class RunManager {
     deps.db.update(runs).set({ status: 'running', startedAt: nowIso() }).where(eq(runs.id, runId)).run()
 
     let scanTimer: NodeJS.Timeout | undefined
+    let gatewayToken: string | null = null
     let session: SessionRow | undefined
     const scanFiles = async () => {
       if (!session) return
@@ -166,6 +170,12 @@ export class RunManager {
         settings,
       })
       emit('run.started', { provider: session.provider, model: session.model })
+      const sessionRow = session
+      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)),
+        audit: (kind, summary, detail) => deps.audit.log({ actor: `agent:${sessionRow.provider}`, sessionId, kind, summary, detail }),
+      })
 
       const result = await provider.run({
         sessionId,
@@ -181,7 +191,6 @@ export class RunManager {
           profilePrompt: profile.systemPrompt,
           sessionPrompt: session.systemPromptAppend,
           permissionMode,
-          officeTools: session.provider === 'claude',
         }),
         allowedTools: profile.allowedTools,
         disallowedTools: profile.disallowedTools,
@@ -214,6 +223,7 @@ export class RunManager {
         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 },
       })
 
       clearTimeout(scanTimer)
@@ -237,6 +247,8 @@ export class RunManager {
         log.error({ err }, 'run failed')
         this.finish(a, 'failed', { code, message })
       }
+    } finally {
+      if (gatewayToken) deps.gatewayTokens.revoke(gatewayToken)
     }
   }
 

+ 139 - 0
apps/server/src/services/web-fetch.ts

@@ -0,0 +1,139 @@
+// 网页抓取:两个引擎共用(经 MCP Gateway 提供)。
+// 在连接层校验目标 IP(undici 自定义 lookup),防止 DNS 重绑定绕过内网拦截;每一跳重定向都重新校验。
+import crypto from 'node:crypto'
+import dns from 'node:dns'
+import fs from 'node:fs'
+import path from 'node:path'
+import TurndownService from 'turndown'
+import { Agent, fetch as undiciFetch } from 'undici'
+import { isPrivateAddress } from '../lib/net'
+
+const MAX_BYTES = 3 * 1048576
+const MAX_TEXT = 40_000
+const MAX_REDIRECTS = 5
+const TIMEOUT_MS = 20_000
+
+export class FetchBlockedError extends Error {}
+
+/** 解析域名后拒绝私网地址的连接器 */
+function guardedAgent(blockPrivate: boolean) {
+  return new Agent({
+    connect: {
+      lookup: (hostname, options, callback) => {
+        dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
+          if (err) return callback(err, '', 4)
+          const list = addresses as dns.LookupAddress[]
+          const bad = blockPrivate ? list.find(a => isPrivateAddress(a.address)) : undefined
+          if (bad) return callback(new FetchBlockedError(`不允许访问内网地址:${hostname}(${bad.address})`), '', 4)
+          const first = list[0]
+          if (!first) return callback(new Error(`无法解析:${hostname}`), '', 4)
+          // undici 传入 all: true 时期望返回数组
+          if ((options as { all?: boolean }).all) (callback as unknown as (e: null, a: dns.LookupAddress[]) => void)(null, list)
+          else callback(null, first.address, first.family)
+        })
+      },
+    },
+  })
+}
+
+export interface FetchResult {
+  url: string
+  status: number
+  contentType: string
+  /** 返回给模型的文本(可能被截断) */
+  text: string
+  truncated: boolean
+  /** 完整内容或二进制文件保存在工作区中的相对路径 */
+  savedPath: string | null
+}
+
+export async function webFetch(opts: { url: string; workspaceDir: string; blockPrivate: boolean; signal?: AbortSignal }): Promise<FetchResult> {
+  let url = parseUrl(opts.url)
+  const agent = guardedAgent(opts.blockPrivate)
+  const timeout = AbortSignal.timeout(TIMEOUT_MS)
+  const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout
+  try {
+    for (let hop = 0; ; hop++) {
+      if (opts.blockPrivate && isPrivateAddress(url.hostname.replace(/^\[|\]$/g, ''))) {
+        throw new FetchBlockedError(`不允许访问内网地址:${url.hostname}`)
+      }
+      const res = await undiciFetch(url, {
+        dispatcher: agent, redirect: 'manual', signal,
+        headers: { 'user-agent': 'Mozilla/5.0 (compatible; AgentServer/0.1; +web_fetch)', accept: 'text/html,application/xhtml+xml,application/json,text/plain,*/*;q=0.5' },
+      })
+      if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
+        if (hop >= MAX_REDIRECTS) throw new Error('重定向次数过多')
+        url = parseUrl(new URL(res.headers.get('location')!, url).toString())
+        await res.body?.cancel()
+        continue
+      }
+      const contentType = (res.headers.get('content-type') ?? 'application/octet-stream').split(';')[0]!.trim().toLowerCase()
+      const buf = await readLimited(res.body, MAX_BYTES)
+      return toResult(url.toString(), res.status, contentType, buf, opts.workspaceDir)
+    }
+  } catch (err) {
+    // undici 把 lookup 中的错误包在 cause 里
+    const cause = (err as { cause?: unknown }).cause
+    if (cause instanceof FetchBlockedError) throw cause
+    throw err
+  } finally {
+    await agent.close().catch(() => {})
+  }
+}
+
+function parseUrl(raw: string): URL {
+  let u: URL
+  try {
+    u = new URL(raw)
+  } catch {
+    throw new Error(`无效的 URL:${raw}`)
+  }
+  if (!['http:', 'https:'].includes(u.protocol)) throw new Error(`只支持 http/https:${u.protocol}`)
+  return u
+}
+
+async function readLimited(body: ReadableStream<Uint8Array> | null, max: number): Promise<Buffer> {
+  if (!body) return Buffer.alloc(0)
+  const chunks: Buffer[] = []
+  let size = 0
+  for await (const chunk of body) {
+    size += chunk.byteLength
+    if (size > max) throw new Error(`内容超过 ${Math.round(max / 1048576)} MB 上限`)
+    chunks.push(Buffer.from(chunk))
+  }
+  return Buffer.concat(chunks)
+}
+
+function toResult(url: string, status: number, contentType: string, buf: Buffer, workspaceDir: string): FetchResult {
+  const dir = path.join(workspaceDir, '.agent', 'fetch')
+  const base = `${new Date().toISOString().replace(/[:.]/g, '-')}-${crypto.createHash('sha1').update(url).digest('hex').slice(0, 8)}`
+  const save = (ext: string, data: Buffer | string) => {
+    fs.mkdirSync(dir, { recursive: true })
+    const file = path.join(dir, `${base}.${ext}`)
+    fs.writeFileSync(file, data)
+    return path.relative(workspaceDir, file)
+  }
+
+  let text: string
+  if (contentType === 'text/html' || contentType === 'application/xhtml+xml') {
+    text = htmlToMarkdown(buf.toString('utf8'))
+  } else if (contentType.startsWith('text/') || contentType === 'application/json' || contentType.endsWith('+json') || contentType === 'application/xml') {
+    text = buf.toString('utf8')
+  } else {
+    // 二进制(PDF、图片、表格等)保存到工作区,由 Agent 用相应工具处理
+    const ext = { 'application/pdf': 'pdf', 'image/png': 'png', 'image/jpeg': 'jpg', 'text/csv': 'csv' }[contentType]
+      ?? (/spreadsheetml/.test(contentType) ? 'xlsx' : /wordprocessingml/.test(contentType) ? 'docx' : 'bin')
+    const saved = save(ext, buf)
+    return { url, status, contentType, text: `已下载 ${contentType}(${buf.length} 字节)到 ${saved}`, truncated: false, savedPath: saved }
+  }
+  if (text.length <= MAX_TEXT) return { url, status, contentType, text, truncated: false, savedPath: null }
+  const saved = save('md', text)
+  return { url, status, contentType, text: text.slice(0, MAX_TEXT), truncated: true, savedPath: saved }
+}
+
+const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' })
+turndown.remove(['script', 'style', 'noscript', 'iframe', 'svg', 'form'])
+
+export function htmlToMarkdown(html: string): string {
+  return turndown.turndown(html).replace(/\n{3,}/g, '\n\n').trim()
+}

+ 3 - 0
apps/server/test/api.test.ts

@@ -112,6 +112,9 @@ describe('会话与 Run', () => {
     await waitFor(() => app.runs.activeForSession(sid) === undefined || undefined)
     const run = (await server.inject({ method: 'GET', url: `/v1/runs/${runId}`, headers: auth() })).json()
     expect(run.status).toBe('completed')
+    // 每个 Run 签发网关令牌,结束后作废
+    expect(fake.lastCtx?.gateway.token).toMatch(/^rt_/)
+    expect(app.gatewayTokens.get(fake.lastCtx!.gateway.token)).toBeNull()
     expect(run.usage.outputTokens).toBe(2)
 
     const types = (await events(sid)).map(e => e.type)

+ 3 - 0
apps/server/test/codex.test.ts

@@ -24,6 +24,7 @@ function makeCtx(text: string, opts: { providerSessionId?: string; mode?: 'ask'
   const ctx: RunContext = {
     sessionId: 's', runId: 'r', model: 'gpt-test', permissionMode: opts.mode ?? 'ask', providerSessionId: opts.providerSessionId ?? null,
     workspaceDir: ws, systemPromptAppend: '说明', allowedTools: [], disallowedTools: [], skills: 'all', plugins: [],
+    gateway: { url: 'http://127.0.0.1:9/mcp', token: 'rt_test' },
     input: { text, images: [], attachments: [] },
     access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: ['**/*.env'] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
     settings, signal: opts.signal ?? new AbortController().signal,
@@ -60,6 +61,8 @@ describe('CodexProvider(模拟 app-server)', () => {
     expect(cfg.shell_environment_policy.inherit).toBe('core')
     // Profile 选择 all:只禁用始终禁用的系统技能
     expect(cfg.skills).toEqual({ config: [{ path: '/sys/skill-installer/SKILL.md', enabled: false }] })
+    // 网关以 HTTP MCP 方式配置,携带本次 Run 的令牌
+    expect(cfg.mcp_servers.gateway).toMatchObject({ url: 'http://127.0.0.1:9/mcp', http_headers: { Authorization: 'Bearer rt_test' }, default_tools_approval_mode: 'approve' })
   })
 
   it('拒绝审批时工具标记为 denied', async () => {

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

@@ -0,0 +1,119 @@
+import fs from 'node:fs'
+import http from 'node:http'
+import path from 'node:path'
+import type { AddressInfo } from 'node:net'
+import { Client } from '@modelcontextprotocol/sdk/client/index.js'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import pino from 'pino'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { createApp, type App } from '../src/app'
+import { buildServer } from '../src/http/server'
+import type { GatewayRunContext } from '../src/mcp-gateway/tokens'
+import { resolveAccess } from '../src/services/access-policy'
+import { config } from '../src/config'
+
+let app: App
+let server: Awaited<ReturnType<typeof buildServer>>
+let base: string
+let site: http.Server
+let siteUrl: string
+const audits: string[] = []
+
+function runCtx(over: Partial<GatewayRunContext> = {}): GatewayRunContext {
+  const ws = path.join(config.dataDir, 'workspaces', `ses_gw_${Math.random().toString(36).slice(2)}`)
+  fs.mkdirSync(path.join(ws, 'outputs'), { recursive: true })
+  const settings = app.settings.get()
+  return {
+    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 } }],
+    audit: (kind, summary) => audits.push(`${kind}:${summary}`),
+    ...over,
+  }
+}
+
+async function connect(token: string) {
+  const client = new Client({ name: 'test', version: '0' })
+  await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${token}` } } }))
+  return client
+}
+const text = (r: Awaited<ReturnType<Client['callTool']>>) => (r.content as { text: string }[]).map(c => c.text).join('\n')
+
+beforeAll(async () => {
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: {} })
+  server = await buildServer(app, { logger: log })
+  await server.listen({ host: '127.0.0.1', port: 0 })
+  base = `http://127.0.0.1:${(server.server.address() as AddressInfo).port}`
+  site = http.createServer((_req, res) => {
+    res.setHeader('content-type', 'text/html; charset=utf-8')
+    res.end('<html><head><script>evil()</script></head><body><h1>标题</h1><p>正文 <b>加粗</b></p></body></html>')
+  })
+  await new Promise<void>(r => site.listen(0, '127.0.0.1', r))
+  siteUrl = `http://localhost:${(site.address() as AddressInfo).port}/page`
+})
+afterAll(async () => {
+  await server.close()
+  site.close()
+})
+
+describe('MCP Gateway', () => {
+  it('无效令牌被拒绝', async () => {
+    const res = await fetch(`${base}/mcp`, { method: 'POST', headers: { authorization: 'Bearer nope', 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, body: '{}' })
+    expect(res.status).toBe(401)
+    await expect(connect('nope')).rejects.toThrow()
+  })
+
+  it('列出工具;资料库列表;文档转换写入工作区', async () => {
+    const ctx = runCtx()
+    const client = await connect(app.gatewayTokens.issue(ctx))
+    const tools = (await client.listTools()).tools.map(t => t.name).sort()
+    expect(tools).toEqual(['convert_document', 'kb_list_roots', 'recalc_workbook', 'web_fetch'])
+    expect(text(await client.callTool({ name: 'kb_list_roots', arguments: {} }))).toContain('账单:/data/bills(只读)')
+
+    fs.writeFileSync(path.join(ctx.workspaceDir, 'a.txt'), '你好')
+    const r = await client.callTool({ name: 'convert_document', arguments: { input: 'a.txt', format: 'pdf' } })
+    expect(text(r)).toBe('已生成:outputs/a.pdf')
+    expect(fs.existsSync(path.join(ctx.workspaceDir, 'outputs/a.pdf'))).toBe(true)
+    expect(audits.some(a => a === 'gateway_call:convert_document')).toBe(true)
+    await client.close()
+  }, 60_000)
+
+  it('只读模式不能生成文件;工作区外的输入被拒绝', async () => {
+    const ctx = runCtx({ permissionMode: 'readonly' })
+    const client = await connect(app.gatewayTokens.issue(ctx))
+    const r = await client.callTool({ name: 'convert_document', arguments: { input: 'a.txt', format: 'pdf' } })
+    expect(r.isError).toBe(true)
+    expect(text(r)).toContain('只读模式')
+    const ctx2 = runCtx()
+    const c2 = await connect(app.gatewayTokens.issue(ctx2))
+    const r2 = await c2.callTool({ name: 'convert_document', arguments: { input: '/etc/hostname', format: 'pdf' } })
+    expect(text(r2)).toContain('工作区内')
+    await client.close()
+    await c2.close()
+  })
+
+  it('web_fetch 拦截内网地址(包括解析到内网的域名),放开后能转成 Markdown', async () => {
+    const ctx = runCtx()
+    const client = await connect(app.gatewayTokens.issue(ctx))
+    const blocked = await client.callTool({ name: 'web_fetch', arguments: { url: siteUrl } })
+    expect(blocked.isError).toBe(true)
+    expect(text(blocked)).toMatch(/不允许访问内网地址:localhost/)
+    expect(audits.some(a => a.startsWith('network_denied:'))).toBe(true)
+    await client.close()
+
+    const open = runCtx({ settings: { ...app.settings.get(), network: { ...app.settings.get().network, blockPrivateFetch: false } } })
+    const c2 = await connect(app.gatewayTokens.issue(open))
+    const r = text(await c2.callTool({ name: 'web_fetch', arguments: { url: siteUrl } }))
+    expect(r).toContain('# 标题')
+    expect(r).toContain('正文 **加粗**')
+    expect(r).not.toContain('evil')
+    await c2.close()
+  })
+
+  it('令牌作废后不能再调用', async () => {
+    const token = app.gatewayTokens.issue(runCtx())
+    app.gatewayTokens.revoke(token)
+    await expect(connect(token)).rejects.toThrow()
+  })
+})

+ 1 - 1
docs/design/03-providers.md

@@ -129,7 +129,7 @@ Thread 参数:`approvalPolicy = on-request`、`cwd = workspaceDir`,沙箱使
 - **提问**:`item/tool/requestUserInput` 映射为提问卡片,回复 `{ answers: { [questionId]: { answers: [...] } } }`
 - **用量**:`thread/tokenUsage/updated` 给出线程累计与最近一次增量,本次 Run 用量 = 最新累计 −(首次通知的累计 − 首次增量)
 - **登录**:`account/login/start { type: "chatgptDeviceCode" }` 返回网址和短码,管理员在 OpenAI 页面输入,完成后收到 `account/login/completed`;AgentServer 不接触凭证。终端方式:`agent-codex login --device-auth`
-- **已知差距**:LibreOffice 在 Codex 沙箱中同样无法运行(禁止 Unix socket),`convert_document` / `recalc_workbook` 将在 MCP Gateway(M2 ②)中提供给 Codex;在此之前提示词会告知模型这两项不可用
+- **工具**:LibreOffice 在 Codex 沙箱中同样无法运行(禁止 Unix socket),`convert_document` / `recalc_workbook` / `web_fetch` / `kb_list_roots` 由 MCP Gateway 提供,与 Claude 共用(08)
 
 ## 4. 能力差异与对齐
 

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

@@ -1,6 +1,15 @@
 # 08 内部平台数据接入:MCP Gateway
 
-> **状态:暂缓实现。** 当前阶段只对上层提供 API。本文保留设计,供以后接入时使用;其中 MCP Gateway 本身会在 M2 先上线,承载内置工具 `web_fetch` 和 `kb_*`(§5)。
+> **状态:MCP Gateway 已上线(2026-10-23,M2 ②)**,承载内置工具;内部平台连接器(§3)仍暂缓。
+>
+> 已实现:
+> - `POST /mcp`:无状态 Streamable HTTP(`@modelcontextprotocol/sdk` 1.32.1),每个请求按令牌绑定到对应 Run 的上下文
+> - 运行令牌:RunManager 每个 Run 签发(`rt_` + 32 字节随机数),Run 结束即作废,24 小时兜底过期
+> - 工具:`convert_document`、`recalc_workbook`(原 Claude 进程内工具迁移至此)、`web_fetch`、`kb_list_roots`;只读模式下生成文件的工具被拒绝;每次调用写入审计(gateway_call)
+> - `web_fetch`:undici 自定义 lookup 在**连接层**拒绝私网 / 回环 / 链路本地 / CGNAT 地址(防 DNS 重绑定),每跳重定向重新校验;HTML 转 Markdown(去除 script/style),超过 40 KB 截断并把全文存到工作区 `.agent/fetch/`,PDF 等二进制下载到工作区;单次 3 MB、20 秒上限
+> - Claude:`mcpServers.gateway = { type: 'http', url, headers }`,网关工具在 PreToolUse 中直接放行(校验在网关内)
+> - Codex:thread config `mcp_servers.gateway = { url, http_headers, default_tools_approval_mode: 'approve', tool_timeout_sec: 300 }`,令牌随 thread/start / resume 每次更新
+> - 原 §5 规划的 `ask_user` 不再需要:Codex 原生支持 `item/tool/requestUserInput`
 
 ## 1. 目标
 让 Agent 能查询(必要时操作)你的内部平台数据,同时做到:

+ 1 - 1
docs/design/11-roadmap.md

@@ -17,7 +17,7 @@
 2. 让 Agent 读取 `~/.ssh` 和 `~/.claude/.credentials.json`,通过 Read 工具和 Bash 两条路径都被拦截,并写入审计日志。
 3. 在后台新增一个资料库并在 Profile 中启用,Agent 能检索其中的内容;未启用的资料库读取会被拒绝。
 
-### M2:Codex 与客户端包
+### M2:Codex 与客户端包(进行中:① Codex 接入 ✅、② MCP Gateway ✅;之后:第三方技能(skills.sh)与 Codex openai-curated 插件目录、④ 用量统计、③ 业务平台接入)
 - CodexAdapter:app-server 进程管理、个人配置覆盖与自检、协议类型生成、事件映射、审批桥接、权限配置映射
 - MCP Gateway 上线(内置 `web_fetch`、`kb_list_roots`、`ask_user`)
 - Profile 管理(另存为、版本历史)

+ 4 - 1
packages/chat-ui/src/util.ts

@@ -11,7 +11,10 @@ export function basename(p: string) {
 }
 
 const TOOL_LABELS: Record<string, string> = {
-  'mcp__office__convert_document': 'Convert',
+  'mcp__gateway__convert_document': 'Convert',
+  'mcp__gateway__recalc_workbook': 'Recalc',
+  'mcp__gateway__web_fetch': 'Fetch',
+  'mcp__gateway__kb_list_roots': 'Knowledge',
   'AskUserQuestion': 'Question',
   'WebFetch': 'Fetch',
   'WebSearch': 'Search',

+ 32 - 0
pnpm-lock.yaml

@@ -71,6 +71,9 @@ importers:
       '@fastify/static':
         specifier: ^10.1.7
         version: 10.1.7
+      '@modelcontextprotocol/sdk':
+        specifier: 1.32.1
+        version: 1.32.1(zod@4.6.7)
       '@openai/codex':
         specifier: 0.164.0
         version: 0.164.0
@@ -95,9 +98,15 @@ importers:
       pino:
         specifier: ^10.4.0
         version: 10.4.0
+      turndown:
+        specifier: ^7.2.4
+        version: 7.2.4
       ulid:
         specifier: ^3.0.2
         version: 3.0.2
+      undici:
+        specifier: ^8.12.3
+        version: 8.12.3
       zod:
         specifier: ^4.6.7
         version: 4.6.7
@@ -114,6 +123,9 @@ importers:
       '@types/picomatch':
         specifier: ^4.0.3
         version: 4.0.3
+      '@types/turndown':
+        specifier: ^5.0.6
+        version: 5.0.6
       drizzle-kit:
         specifier: ^0.31.11
         version: 0.31.11
@@ -1157,6 +1169,10 @@ packages:
     engines: {node: '>=8'}
     dev: false
 
+  /@mixmark-io/domino@2.2.0:
+    resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
+    dev: false
+
   /@modelcontextprotocol/sdk@1.32.1(zod@4.6.7):
     resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==}
     engines: {node: '>=18'}
@@ -1859,6 +1875,10 @@ packages:
     resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==}
     dev: true
 
+  /@types/turndown@5.0.6:
+    resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==}
+    dev: true
+
   /@unocss/autocomplete@66.11.0:
     resolution: {integrity: sha512-+lxwoW2J8iLfBJkjb1eEzamhWAtrsEEUaiGbdOyl5IQN8CekigUfFJ8UVlJklCVaRyiK1RbIvVWK+SC/kBUkvg==}
     dependencies:
@@ -4270,6 +4290,13 @@ packages:
     optionalDependencies:
       fsevents: 2.3.3
 
+  /turndown@7.2.4:
+    resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==}
+    engines: {node: '>=18', npm: '>=9'}
+    dependencies:
+      '@mixmark-io/domino': 2.2.0
+    dev: false
+
   /type-is@2.1.0:
     resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
     engines: {node: '>= 18'}
@@ -4316,6 +4343,11 @@ packages:
   /undici-types@8.9.0:
     resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
 
+  /undici@8.12.3:
+    resolution: {integrity: sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==}
+    engines: {node: '>=22.19.0'}
+    dev: false
+
   /unocss@66.11.0(vite@8.3.5):
     resolution: {integrity: sha512-fUhpMwVzMVxy4IcaL2sXjJV5Q3UpEKv3U4wfYioHmv7+C07Y8WAmjVgROoXKAUKs+pWlQKwtPMw4ZjMpQX8blA==}
     peerDependencies: