MGW MCP 网关技术文档
AI 全栈学习手册 AI 知识库

MCP 网关 · 技术文档

面向企业的 Model Context Protocol 工具治理平台。对外暴露唯一一个 MCP Server, 对下聚合任意多个上游 MCP Server,在同一处集中完成鉴权、RBAC、限流、熔断、并发控制、 审计留痕、PII 脱敏与凭据加密。本文档面向具备一定工程基础的读者, 目标是在一次通读内建立起对整个代码库的心智模型。

NestJS 10 · TypeORM · SQLite Vue 3 · Vite · UnoCSS MCP SDK 1.0.4 协议版本 2025-06-18 11 张表 10 个管理页 11 个错误码 0
01

项目概览

先建立整体印象:它是什么、替谁解决什么问题、由哪些能力构成。

它到底是什么

MCP 网关是一个单进程、单文件数据库、零外部依赖的 Node.js 服务。 它扮演双重角色:

  • 对上游(AI 客户端 / 模型):它是一个标准的 MCP Server,端点为 POST /mcp, 用 Authorization: Bearer mgw_live_… 鉴权,支持 initialize / tools/list / tools/call / ping,并通过 SSE 推送 notifications/tools/list_changed
  • 对下游(真实工具服务):它是一个 MCP Client 池,可同时连接多个上游 MCP Server,支持 stdio(拉起子进程)与 http(Streamable HTTP)两种 transport。

所有上游工具被聚合进一张命名空间化的注册表,全名规则为 upstream__tool(双下划线分隔)。模型看到的是扁平的一张工具清单, 网关负责把 wiki__search 还原成「wiki 上游的 search 工具」并转发过去。

解决什么问题

把 MCP Server 直接开放给模型,在企业环境里会立刻撞上四类问题:

问题裸接 MCP 的现状网关的做法
权限失控 任何拿到 Server 的模型都能调用全部工具,实习生和 CTO 的权限完全一样 API Key 绑定角色 → 角色绑定通配符规则(deny / allow 有序列表)→ tools/listtools/call 共用同一份 allowedSet,做到「可见即可调」
无法追责 模型调了什么工具、传了什么参数、成功还是失败,全都没有记录 每次调用写 audit_logs:请求 ID、用户、团队、角色、工具、参数摘要、 耗时、排队时长、错误码、调用方 IP、客户端信息
故障扩散 某个上游挂了,所有会话一起卡死;某个模型疯狂重试,把上游彻底打死 三态熔断(closed / half-open / open)+ 令牌桶限流 + 每上游并发信号量, 单点故障被隔离在网关内部
凭据裸奔 上游的 token 写在配置文件里明文提交;PII 直接进日志 上游凭据用 AES-256-GCM 加密后入库;审计入库前完成手机 / 邮箱 / 身份证 / 银行卡正则脱敏

核心能力一览

鉴权
API Key(SHA-256 哈希存储)+ 角色绑定;吊销后 ≤2s 全链路失效;一键轮换带宽限期
RBAC
通配符规则有序匹配,首条命中即决定;支持 observe 影子模式做灰度预演
限流
令牌桶,按「Key × 工具」双维度计桶;工具级可覆写 QPM
熔断
三态状态机 + half-open 独占探针;客户端错误不计入熔断统计
并发控制
每上游信号量 + 等待队列;stdio 上游强制收敛到 ≤2 并发
审计
内存攒批 → 单事务批量落库 → 失败落 JSONL → 启动回放;只增不改
脱敏
入库前递归扫描 JSON,四类 PII 正则替换为掩码并打 redacted 标记
凭据加密
AES-256-GCM,密文格式 iv(12) | tag(16) | ciphertext 后 base64
SSRF 防护
出站 URL 必须落在 CIDR 白名单;169.254.0.0/16127.0.0.0/8 永久拒绝
变更留痕
管理员每次写操作进 change_logs,含 before / after 快照与人类可读 diff
可观测
/healthz + /metrics(JSON) + /metrics.prom(Prometheus) + 事件循环延迟监控
运维自治
SQLite 在线热备份(cron 调度 + 滚动保留)、一键权限回滚、IM 告警推送

关键数字速览

数据表
11
单文件 SQLite(WAL)
管理页面
10
+ 独立登录页
Admin 控制器
9
按业务域划分
治理闸
6
顺序不可调换
错误码
11
对模型的稳定契约
审计事件
11
tool_call 等
Prometheus 指标
11
全部 mgw_ 前缀
内置角色
3
intern / support / admin
NOTE

阅读本文档的建议路径:如果你只有 10 分钟,按 01 概览03 架构总览06 治理链 三章走一遍即可掌握主干; 其余章节都是这条主干的展开,需要时按目录跳转或用 ⌘K 检索。

02

快速开始

从零到跑通第一次工具调用,最快 3 分钟。

环境要求

依赖版本说明
Node.js≥ 20.18.1 start.sh 会检查主版本号,低于 20 直接退出。MCP SDK 与 better-sqlite3 均要求 20+
npm随 Node两个子包各自持有 package-lock.json,不是 workspace monorepo
构建工具链python3 / make / g++ 仅首次安装需要:better-sqlite3 是原生模块,会本地编译
lsof可选用于端口预检;缺失时脚本仅告警并跳过

一键启动(推荐)

仓库根目录的 start.sh 封装了「加载 env → 端口预检 → 装依赖 → seed → 双进程并跑」全流程:

cd mcp-agent

# 首次:准备环境变量(可选,不准备也能跑,会走内置默认值)
cp backend/.env.example backend/.env

./start.sh              # 启动:后端 :8080 + 前端 :5173
./start.sh --stop       # 停止已在运行的实例(含残留端口占用)
./start.sh --force      # 端口被占用时自动停旧实例再启动
./start.sh --no-seed    # 跳过 seed(确认库结构已就绪时用)
./start.sh -h           # 帮助

启动成功后终端会打印:

════════════════════════ MCP 网关已启动 ════════════════════════
  ▶ 管理后台   http://127.0.0.1:5173
                 登录 Token: mgw_admin_dev_token (生产请通过 ADMIN_TOKEN 环境变量修改)
  ▶ MCP 端点   http://127.0.0.1:8080/mcp
                 鉴权头: Authorization: Bearer <mgw_live_…>
  ▶ 健康检查   http://127.0.0.1:8080/healthz
  按 Ctrl+C 一键停止  ·  或另开终端执行 ./start.sh --stop

start.sh 的几个值得注意的实现细节:

  • env 加载方式:后端不内置 dotenv,直接读进程环境变量。 脚本用 set -a; source backend/.env; set +a 在当前 shell 里加载后自动 export 给子进程, 加载顺序为 backend/.env 优先、仓库根 .env 兜底。
  • 递归杀进程树kill_tree() 先用 pgrep -P 找出子进程, 先杀子再杀父,避免留下孤儿 node 占着端口。
  • 优雅停止TERM → 最多等 5s → 仍存活则 KILLCtrl+C--stop 走同一条路径。
  • 彩色前缀分流:两路输出分别经 awk 打上 [backend](青)与 [frontend](品红)前缀后合并到同一终端, 并且 fflush() 保证实时性。
  • seed 幂等:不会覆盖已有 settings / 角色 / Key,重复执行安全。

手动启动(分别跑两个进程)

# ── 终端 A:后端 ─────────────────────────────
cd backend
npm install                       # 首次,会编译 better-sqlite3
cp .env.example .env              # 按需填写 MGW_SECRET / ADMIN_TOKEN
set -a && source .env && set +a   # 手动喂入环境变量
npm run seed                      # 建库 + 内置角色 / settings / 首个 Key
npm run start:dev                 # → http://127.0.0.1:8080

# ── 终端 B:前端 ─────────────────────────────
cd frontend
npm install
npm run dev                       # → http://127.0.0.1:5173
脚本作用
backendnpm run start:devnest start --watch,开发热重载
npm run buildnest builddist/
npm startnode dist/main,生产启动
npm run seedts-node -r tsconfig-paths/register src/database/seed.ts
npm run typechecktsc --noEmit
npm run lintESLint 检查 src/**/*.ts
frontendnpm run devVite 开发服务器(:5173,代理 /admin/api/mcp 到 :8080)
npm run buildvue-tsc --noEmit && vite builddist/
npm run preview预览构建产物
npm run typecheckvue-tsc --noEmit

登录管理后台

  1. 打开 http://127.0.0.1:5173 未登录会被路由守卫重定向到 #/login?redirect=/dashboard
  2. 输入管理员标识与 Token Token 默认 mgw_admin_dev_token(开发默认值,生产必须改); 用户名任意填写,仅用于 change_logs.actor 留痕。
  3. 凭据落在 localStorage 键名 mgw_admin_tokenmgw_admin_user, 由 frontend/src/api/client.tstokenStore 统一读写。
  4. 之后所有请求自动带头 Authorization: Bearer <token>x-admin-user: <name>; 收到 401 时 setUnauthorizedHandler 会清空凭据并跳回登录页。

发起第一次 MCP 调用

先在「API Key」页签发一把 Key(明文只显示一次),然后:

KEY="mgw_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
BASE="http://127.0.0.1:8080/mcp"

# 1) initialize —— 协商协议版本,拿到 Mcp-Session-Id
curl -i -X POST "$BASE" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc":"2.0","id":1,"method":"initialize",
    "params":{
      "protocolVersion":"2025-06-18",
      "capabilities":{},
      "clientInfo":{"name":"curl","version":"0"}
    }
  }'
# → 响应头含 mcp-session-id: <uuid>

SID="<上一步返回的 mcp-session-id>"

# 2) notifications/initialized —— 握手完成通知(无响应体)
curl -X POST "$BASE" \
  -H "Authorization: Bearer $KEY" -H "mcp-session-id: $SID" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'

# 3) tools/list —— 只返回该 Key 角色「有权看到」的工具
curl -X POST "$BASE" \
  -H "Authorization: Bearer $KEY" -H "mcp-session-id: $SID" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

# 4) tools/call —— 走完整治理链
curl -X POST "$BASE" \
  -H "Authorization: Bearer $KEY" -H "mcp-session-id: $SID" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc":"2.0","id":3,"method":"tools/call",
    "params":{"name":"wiki__search","arguments":{"q":"MCP"}}
  }'

# 5) 订阅工具变更通知(SSE 长连接,25s 心跳)
curl -N "$BASE" -H "Authorization: Bearer $KEY" -H "mcp-session-id: $SID"

在 Claude Desktop / Cursor 等客户端里,配置形如:

{
  "mcpServers": {
    "corp-gateway": {
      "type": "streamable-http",
      "url": "http://127.0.0.1:8080/mcp",
      "headers": { "Authorization": "Bearer mgw_live_xxxxxxxxxxxx" }
    }
  }
}

冒烟自检

scripts/ 下按能力档位准备了 5 个冒烟脚本,可直接对着运行中的网关打:

脚本覆盖范围
smoke-test.sh基础连通:healthz → initialize → tools/list → tools/call 全链路
smoke-p0.shP0 六项:影子模式、权限回滚、影响面预览、Key 即时吊销、事件循环监控、stdio crash loop
smoke-p1.shP1 首批:熔断事件面板、数据库自动备份、Prometheus 指标
smoke-p1b.shP1 收尾:备份失败告警、HTTP 入口埋点、熔断 SSE 推流、Grafana 模板
smoke-domestic.sh国产化 / 内网环境适配验证
mock-mcp-server.mjs本地假上游,用于无真实 MCP Server 时联调
seed-calls.py / seed-dashboard.sh灌入模拟调用数据,让仪表盘与报表页有内容可看
TIP

想快速看到仪表盘效果,跑一遍 python3 scripts/seed-calls.py 即可灌入历史调用样本; 它会直接写 backend/data/gateway.db,执行前请先停掉网关。

03

架构总览

一张图看懂请求怎么走、模块怎么分、依赖朝哪个方向。

全景拓扑

一张图看清六件事:谁在调从哪进过哪些闸由谁支撑打到哪里去账记在哪里。 实线是请求主流程,虚线是「闸 → 支撑服务」的依赖关系;虚线边框的盒子在 NestJS 进程之外

MCP 网关全景架构图 自上而下六层:L0 调用方(AI 客户端、管理员浏览器、监控探针);L1 入口层(transport 与 admin 双通道、system 探针);L2 核心治理链,即 gateway/router.service 内依次执行的注册表查找、RBAC、限流、熔断、并发、转发审计六个环节;L3 支撑服务(registry、rbac、ratelimit、breaker、concurrency、audit);L4 上游连接层 upstream-manager;L5 上游 MCP Server,分 stdio 与 http 两种 transport。右侧栏为横切治理与存储层。 L0 · 调用方 AI 客户端 / 模型 Claude · Cursor · 自研 Agent 管理员浏览器 Vue3 SPA · 10 个管理页 监控 / 探针 Prometheus · K8s Bearer mgw_live_… Bearer ADMIN_TOKEN 无鉴权 NestJS 单进程 · 零外部依赖 · 单文件数据库 L1 · 入口层 transport/ mcp.controller · session.manager ① 鉴权 · 会话校验 · Origin / Accept 校验 admin/ 9 个 controller · ChangeLogService AdminTokenGuard · timingSafeEqual system /healthz · /metrics /metrics.prom · 无鉴权 L2 · 核心治理链 gateway/router.service routeCall() · 系统的十字路口 ② 注册表 全名还原 ③ RBAC allowedSet ④ 限流 令牌桶 ⑤ 熔断 三态机 ⑥ 并发 信号量 → 转发 审计落账 横切治理 audit · 攒批落库 redact · PII 脱敏 metrics + logger notify/im · 告警 L3 · 支撑服务 registry 版本戳缓存 tool_snapshots rbac allowedSet 通配符规则 ratelimit 令牌桶 Key × 工具 breaker 三态机 half-open 探针 concurrency 信号量 + 等待队列 audit 攒批落库 + redact 存储层 SQLite · WAL 模式 TypeORM · 11 张表 data/gateway.db L4 · 上游连接层 gateway/upstream-manager.service 六道闸全过 → 转发 MCP Client 池 · 动态 import ESM SDK · AbortController 超时 · 健康检查与自动重连 stdio 子进程 Streamable HTTP L5 · 上游 MCP SERVER 上游 MCP Server · stdio 子进程拉起 · env 白名单过滤 并发硬限 ≤ 2 上游 MCP Server · http Streamable HTTP · SSRF CIDR 白名单 超时 + 熔断隔离 … N 个 后台动态增删 SSE 热更新广播
请求主流程 闸 → 支撑服务 进程内模块 核心枢纽 进程之外 闸的出口 鉴权在入口层(transport/mcp.controller.ts)就完成了, ② ~ ⑥ 五道闸在 routeCall() 内顺序执行, 脱敏与审计则作为横切能力挂在回程上——三者不在同一层, 详见 06 治理链

双通道物理隔离

这是整个系统最重要的一条架构约束:面向模型的数据面与面向管理员的控制面,从入口就完全分开。

维度MCP 通道 /mcp管理通道 /admin/api/*
使用者AI 客户端 / 模型运维与管理员(人类)
凭据Authorization: Bearer mgw_live_…(每 Key 独立、可吊销、绑角色) Authorization: Bearer <ADMIN_TOKEN> + x-admin-user(全局单一令牌)
校验方式SHA-256 哈希查库 → 命中后取角色 / profiletimingSafeEqual 常量时间比较,防时序侧信道
失败语义MCP 规范:工具业务失败用 isError: true,让模型有机会改策略标准 HTTP 状态码 + JSON 错误体
审计事件channel = 'mcp'channel = 'admin',写操作另进 change_logs
例外/healthz/metrics/metrics.prom 三个探针端点不鉴权(供 K8s / Prometheus 抓取)
RULE

管理 Token 绝不能作为 MCP Key 使用,反之亦然。两套凭据在代码里没有交集: AdminTokenGuard 只挂在 admin/ 下的控制器上, ApiKeyService.verify() 只在 transport/mcp.controller.ts 里被调用。

一次 tools/call 的完整时序

  1. HTTP 入口 · transport/mcp.controller.ts MetricsMiddleware 先埋点(QPS / 延时直方图)→ 校验 Origin(防 DNS rebinding) → 校验 Accept 头必须同时包含 application/jsontext/event-stream → 解析 AuthorizationApiKeyService.verify()。 失败返回 401 且带 WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource"
  2. 会话校验 · transport/session.manager.tsinitialize 外的所有方法都要求合法的 Mcp-Session-Id; 会话是进程内 Map,含 keyId / role / userName / SSE Subject / lastActiveAt
  3. 协议版本协商 客户端声明 2025-06-18 则原样回应;否则回落到 2025-03-26initialize 响应带 capabilities.tools.listChanged = trueserverInfo = { name: 'mcp-gateway', version: '2.0.0' }
  4. 方法分派 dispatch() 是一个显式 switch,支持 initialize / notifications/initialized(兼容裸 initialized)/ ping / tools/list / tools/call;未知方法回 JSON-RPC -32601
  5. 进入治理链 · gateway/router.service.ts routeCall(fullName, args, caller, ctx) 依次过六道闸,详见 06 治理链。任何一道失败都走统一的 fail(): 写审计 + 打指标 + 返回结构化 GatewayToolError
  6. 转发上游 · gateway/upstream-manager.service.ts 从 Client 池取出对应连接,用 AbortController 施加超时后调 client.callTool(), 把上游返回的原名工具结果映射回全名。
  7. 回程落账 · governance/audit.service.ts 结果先经 RedactService 递归脱敏,再进内存队列攒批; 队列满 flushSize 或定时器到 flushMs 时单事务批量 insert。
  8. 响应客户端 成功 → result.content;工具业务失败 → result.isError = true + [错误码] 中文简述;协议级错误 → JSON-RPC error

模块划分与依赖方向

backend/src/app.module.ts 装配 8 个业务模块,其中 3 个标注 @Global()

模块全局导出内容 / 职责
ConfigModule@Global 三级配置合并(env > gateway.json > 默认),导出 ConfigService
DatabaseModule@Global TypeORM 数据源(SQLite / WAL),注册 11 个实体
GatewayModule@Global ToolRegistryService · UpstreamManagerService · GatewayRouterService。 transport 与 admin 都依赖这三者,故设为全局
GovernanceModule@Global Redact · Audit · RateLimit · Breaker · BreakerEvents · Concurrency · Backup · Metrics 八个治理服务
AuthModuleApiKeyService · RbacService · CryptoService
TransportModuleMcpController · SessionManager(对客户端的 MCP 端点)
AdminModule9 个控制器 + AdminTokenGuard + ChangeLogService
NotifyModuleImService(IM Webhook 告警,带同类抑制)

依赖是单向向外的,不存在环:

  • router → 依赖 registry + upstream-manager + 全部治理服务
  • upstream-manager → 依赖 registry(刷新后写回工具列表)
  • registry → 只依赖 TypeORM Repository,不反向依赖任何网关服务
  • ssrf.ts纯函数,被 upstream-manager 在注册 URL 时调用,零依赖
  • admin/* → 依赖 Repository + gateway/* + auth/crypto + transport/session.manager反向依赖网关内部细节,保持管理面与运行面解耦

热更新:改完就生效,不重启

管理后台的写操作完成后会触发一条广播链,让在线的 AI 客户端立刻感知:

STEP 1管理员写操作PUT /admin/api/…
STEP 2落库 + 变更留痕ChangeLogService.record()
STEP 3刷新内存态registry.bump()
rulesVersion++
STEP 4广播 SSEbroadcastListChanged()
STEP 5客户端重拉tools/list

两个版本戳是这套机制的关键:registryVersion(工具集变化)与 rulesVersion(权限规则变化)。RBAC 的 allowedSet 缓存键同时包含这两者与 60s TTL, 任一变化即令缓存自然失效,无需显式清理。

关键设计取舍

决策选了什么为什么,代价是什么
存储单文件 SQLite(WAL)而非 PostgreSQL 换来「一个二进制 + 一个 db 文件」的部署极简性;代价是单写者, 因此审计必须攒批(见 11 审计
治理链编排显式顺序调用,而非中间件洋葱模型 顺序有强语义(权限在限流前、限流在熔断前),显式代码让顺序不可被误改; 代价是新增一道闸需要改 routeCall
会话进程内 Map,不做 Redis 共享 与单进程定位一致,零外部依赖;代价是不能水平扩容, 多实例部署需要 sticky session 或改造为共享存储
MCP SDK 加载动态 await import() SDK 是 ESM-only,而 NestJS 项目编译为 CommonJS,只能在运行时动态导入; 代价是类型需要 typeof import(…) 间接声明
前端路由hash 模式(createWebHashHistory 静态托管(Nginx / 任意静态服务器)无需 rewrite 规则即可直达子路由
UI 组件全部自研 + UnoCSS shortcuts,不引组件库 黑白毛玻璃主题需要极致的令牌控制;代价是 Modal / Toast / Spinner 都要自己写
建表MGW_DB_SYNCHRONIZE=1 默认开启 MVP 零迁移上手;生产建议置 0 并改用 migrations
04

技术栈

每一个依赖都有明确职责,没有「先加上再说」的库。版本号与 package.json 一致。

后端运行时依赖

版本职责与选型理由
@nestjs/common / core / platform-express10.4.4 应用骨架。模块化 + 装饰器让「治理链每一步是一个可注入服务」这件事表达得很自然
@nestjs/typeorm + typeorm10.0.2 / 0.3.20 实体映射与 Repository。11 个实体集中在 database/entities/
better-sqlite311.3.0 同步 API 的 SQLite 驱动,性能显著优于 sqlite3; 提供 db.backup() 在线热备份能力(P1-A 的基础)
@modelcontextprotocol/sdk1.0.4 官方 MCP SDK。ESM-only,通过动态 import() 在 CJS 环境加载; 用到 Client / StdioClientTransport / StreamableHTTPClientTransport
zod3.23.8 Admin API 请求体校验。配合 ZodExceptionFilter 把 ZodError 归一化为 400 + 友好消息
prom-client15.1.3 Prometheus 指标。使用独立 Registry(非全局默认),避免与 Nest 内部指标串味; 统一 mgw_ 前缀
node-cron4.6.0 备份调度。settings 里的 backup.cron / backup.tz 改动后热重建任务
rxjs7.8.1 SSE 推流。Subject 承载 tools/list_changed 与熔断事件; merge(stream$, interval(25s)) 实现心跳
reflect-metadata0.2.2NestJS / TypeORM 装饰器元数据必需

开发依赖:@nestjs/cli 10.4.5 · typescript 5.6.3 · ts-node 10.9.2 · tsconfig-paths 4.2.0 · @types/node 20.16.11 · @types/better-sqlite3 7.6.11 · eslint + @typescript-eslint/*

前端依赖

版本职责
vue3.5.13Composition API + <script setup lang="ts">
vue-router4.4.5hash 模式;视图全部懒加载,首屏只下登录 + 布局 + 仪表盘
pinia2.2.6唯一 store 是 auth(token / user / authenticated)
vite5.4.11开发服务器 + 构建;/admin/api/mcp 代理到 :8080
unocss0.64.1原子化 CSS。presetUno({ dark: 'class' }) + presetAttributify + transformerDirectives + transformerVariantGroup
vue-tsc2.1.6模板级类型检查,build 前强制执行
NOTE

没有 UI 组件库。项目未引入 Element Plus / Ant Design Vue / Naive UI, 所有 Modal、Toast、Spinner、Icon、Empty、PageHeader 均为自研组件 + UnoCSS shortcuts 组合。 这是黑白毛玻璃主题能做到「改一处 shortcuts 即换肤」的前提。详见 16 设计体系

SQLite 运行参数

backend/src/database/data-source.ts 在建立连接后执行四条 PRAGMA:

PRAGMA journal_mode = WAL;      -- 写前日志:读写并发,读不阻塞写
PRAGMA busy_timeout  = 5000;    -- 写锁竞争时最多等 5s,而非立刻 SQLITE_BUSY
PRAGMA foreign_keys  = ON;      -- SQLite 默认关闭外键约束,必须显式打开
PRAGMA synchronous   = NORMAL;  -- WAL 模式下的推荐档位:性能与崩溃安全性的平衡点

由此产生的文件布局:data/gateway.db(主库)+ gateway.db-wal(预写日志)+ gateway.db-shm(共享内存索引)。备份时必须三个文件一起考虑, 这也是为什么项目用 db.backup() API 而不是简单 cp(见 12 数据模型)。

协议与传输

MCP 协议版本
2025-06-18(首选)· 2025-03-26(回落)
传输形态
Streamable HTTP:单一 POST /mcp 承载 JSON-RPC,GET /mcp 升级为 SSE 通知流,DELETE /mcp 终止会话
RPC 语义
JSON-RPC 2.0;工具业务失败用 result.isError = true 而非 error 字段(MCP 规范要求)
会话标识
Mcp-Session-Id 响应头下发,后续请求需回传
心跳
SSE 每 25s 一条 : ping 注释帧,防中间代理断连
Admin API
REST + JSON,路径前缀 /admin/api,Bearer Token 鉴权
指标暴露
/metrics(JSON,供前端仪表盘)· /metrics.prom(Prometheus text format 0.0.4)
05

目录结构

知道东西放在哪,比知道东西是什么更重要。这份树标注了每个目录的职责边界。

仓库顶层

mcp-agent/ ├── backend/ # NestJS 网关后端(独立 npm 包) ├── frontend/ # Vue3 管理后台(独立 npm 包) ├── docs/ # 产品方案 / 技术架构 / 部署运维 / grafana 模板 ├── scripts/ # 冒烟脚本、mock 上游、数据灌入、日志格式化 ├── tech/ # 技术文章与本文档站 ├── .run/ # start.sh 写入的 PID 文件(gitignore) ├── start.sh # 一键启停(env 加载 / 端口预检 / seed / 双进程) ├── MCP网关产品技术PRD.md # 需求来源 └── README.md # 权威说明(本文档与之保持一致)

注意:backendfrontend 各自持有 package.jsonpackage-lock.json不是 npm workspace monorepo, 没有根级 package.json。要装依赖必须分别进入两个目录。

后端源码 backend/src/

src/ ├── main.ts # Nest 引导:全局管道/过滤器/中间件装配、优雅停机 ├── app.module.ts # 8 个模块装配点 ├── bootstrap.service.ts # 启动编排:seed 检查→上游连接→注册表冷启动→审计回放→定时器 │ ├── config/ # ── 配置 ── │ ├── config.module.ts # @Global │ └── config.service.ts # 三级合并 + *_FILE 支持 + 类型化 getter │ ├── database/ # ── 持久化 ── │ ├── data-source.ts # TypeORM DataSource + 4 条 PRAGMA │ ├── database.module.ts # @Global │ ├── seed.ts # 幂等初始化:角色/规则/settings/首把 Key │ └── entities/ # 8 个文件 → 11 张表 │ ├── index.ts # ALL_ENTITIES 导出数组 │ ├── upstream.entity.ts # Upstream │ ├── tool.entity.ts # ToolSnapshot + ToolOverride │ ├── identity.entity.ts # Role + RoleRule + ApiKey │ ├── audit.entity.ts # AuditLog + ChangeLog │ ├── setting.entity.ts # Setting + DEFAULT_SETTINGS │ ├── breaker-event.entity.ts # CircuitBreakerEvent │ └── backup.entity.ts # Backup │ ├── auth/ # ── 身份与权限 ── │ ├── apikey.service.ts # 签发/校验/吊销/轮换,吊销缓存 ≤2s 生效 │ ├── rbac.service.ts # 通配符→正则、首条命中、allowedSet 缓存、影子模式 │ └── crypto.service.ts # SHA-256 / AES-256-GCM / timingSafeEqual / Key 生成 │ ├── gateway/ # ── 网关核心(@Global)── │ ├── registry.service.ts # 命名空间聚合 + 版本戳 + 快照落库 │ ├── upstream-manager.service.ts# MCP Client 池 + 健康检查 + 重连 + crash loop │ ├── router.service.ts # ★ 治理链十字路口 │ └── ssrf.ts # 纯函数:CIDR 白名单校验 │ ├── governance/ # ── 治理服务(@Global)── │ ├── ratelimit.service.ts # 令牌桶 │ ├── breaker.service.ts # 三态熔断状态机 │ ├── breaker-events.service.ts# 熔断事件落库 + SSE Subject │ ├── concurrency.service.ts # 信号量 + 等待队列 │ ├── audit.service.ts # 攒批落库 + fallback JSONL + 回放 │ ├── redact.service.ts # PII 递归脱敏 │ ├── backup.service.ts # db.backup() + cron + 滚动保留 + 恢复 │ ├── metrics.service.ts # prom-client 独立 Registry(11 指标) │ └── metrics.middleware.ts # HTTP 入口 QPS / 延时直方图 │ ├── transport/ # ── 对客户端的 MCP 端点 ── │ ├── mcp.controller.ts # POST/GET/DELETE /mcp,协议协商与方法分派 │ └── session.manager.ts # 会话 Map + TTL 回收 + SSE 广播 + 优雅排空 │ ├── admin/ # ── 管理面 REST API ── │ ├── admin.module.ts # 装配 9 控制器 + Guard + ChangeLog │ ├── guards/admin-token.guard.ts # timingSafeEqual 校验 │ ├── upstream.controller.ts # 上游 CRUD / probe / refresh │ ├── tool.controller.ts # 工具目录 / 覆写 │ ├── permission.controller.ts# 角色 / 规则 / dryRun / 回滚 / 影响面预览 / 矩阵 │ ├── key.controller.ts # Key 签发 / 列表 / 吊销 / 轮换 │ ├── audit.controller.ts # 审计查询 / 详情 / CSV 导出 / 变更记录 │ ├── stats.controller.ts # 仪表盘概览 / 时序 │ ├── breaker.controller.ts # 熔断状态 / 事件 / 汇总 / SSE │ ├── backup.controller.ts # 备份列表 / 汇总 / 立即执行 / 下载 │ ├── system.controller.ts # healthz / metrics / registry / settings │ └── change-log.service.ts # before/after 快照 + diffSummary │ ├── notify/ │ └── im.service.ts # IM Webhook 推送 + 5min 同类抑制 │ └── common/ # ── 横切工具 ── ├── errors.ts # ErrorCode / ERROR_TEXT / GatewayToolError ├── logger.ts # 单行 JSON 结构化日志 → stderr ├── safe-message.ts # 错误消息净化(剥离堆栈/IP/SQL/路径/Bearer) ├── zod-exception.filter.ts # ZodError → 400 └── event-loop-monitor.ts # 事件循环 p99 监控 + 告警冷却

前端源码 frontend/src/

src/ ├── main.ts # createApp + pinia + router + unocss + base.css ├── App.vue # 挂载 .mgw-backdrop 背景层 + 路由 fade 过渡 + ToastHost ├── api/ │ ├── client.ts # tokenStore / ApiError / http.{get,post,put,del} / downloadCsv │ └── index.ts # 全部 TS 接口定义 + 按域分组的 api 对象 ├── stores/auth.ts # 唯一 Pinia store ├── router/index.ts # hash 路由 + 守卫 + navItems 派生 ├── layouts/AdminLayout.vue # 侧栏(248px/折叠76px) + 顶栏(h-16) + 健康徽章(8s 轮询) ├── components/ # 自研 6 件:Modal / ToastHost / Icon / Empty / Spinner / PageHeader ├── composables/ # 可复用逻辑(轮询、SSE 订阅等) ├── styles/base.css # CSS 变量 / 背景光晕 / 滚动条 / selection / 过渡 ├── utils/ # 格式化等纯函数 └── views/ # 11 个视图 = 登录页 + 10 个管理页 ├── Login.vue Dashboard.vue Upstreams.vue Tools.vue ├── Permissions.vue Keys.vue Audit.vue Changes.vue └── Reports.vue Breaker.vue Backups.vue

构建配置在 frontend/ 根:vite.config.ts(含 dev 代理)、 uno.config.ts(主题令牌与 shortcuts)、tsconfig.jsonindex.html

运行时产物

backend/data/gateway.db
主数据库(+ -wal / -shm 伴随文件)
backend/data/backup/
备份产物,命名 gateway-YYYYMMDD-HHMMSS-<trigger>.db,trigger 为 manualcron
backend/data/audit-fallback.jsonl
审计落库失败时的兜底队列,启动时回放
backend/config/gateway.json
中间层配置文件(优先级低于环境变量)
backend/.env
本地环境变量,不入库;模板为 .env.example
backend/dist/ · frontend/dist/
构建产物
.run/gateway.pid
start.sh 记录的 pipeline PID(空格分隔两个)
06

治理链:系统的十字路口

backend/src/gateway/router.service.ts 是整个网关最值得逐行阅读的文件。 它把六道治理闸按固定顺序串起来,顺序本身就是设计。

顺序为什么不可调换

源码里的类注释把理由写得很直白:

QUOTE

「GatewayRouter:整个系统的十字路口。治理链顺序不可调换:权限在限流前(无权限不该消耗令牌), 限流在熔断前(被限流不该污染熔断统计),熔断在获取并发槽前(熔断态不该排队)。」

把这三条反过来会立刻出问题:

  • 限流放在权限前 → 一个无权调用 finance__* 的实习生,光是被拒绝就在消耗财务工具的令牌桶,形成廉价 DoS。
  • 熔断放在限流前 → 被限流打回的请求计入失败统计,上游明明健康却被误判熔断。
  • 并发槽放在熔断前 → 上游已经 open,请求还在队列里排着占内存,等 60s 后统一超时。

六道闸全景

GATE 1注册表查找registry.get()
TOOL_NOT_FOUND
GATE 2RBAC 权限rbac.buildAllowedSet()
FORBIDDEN / WOULD_FORBID
GATE 3令牌桶限流rateLimit.take()
RATE_LIMITED
GATE 4三态熔断breaker.allow()
UPSTREAM_DOWN
GATE 5并发信号量concurrency.acquire()
UPSTREAM_BUSY
GATE 6超时转发upstream.callTool()
UPSTREAM_TIMEOUT / ERROR
NOTE

常被描述为「8 步治理链」的另外两步,实际发生在 routeCall外层与内层, 理解这个分层能避免读代码时找不到位置:

鉴权(第 0 步)在 transport/mcp.controller.ts 完成, 请求还没进 router 就已经拿到 caller = { keyId, role, userName, team, profile }
脱敏(第 7 步)在 governance/audit.service.tstoEntity() 内部完成,属于审计落库的前置处理,不在主链路上阻塞响应。

入口签名

async routeCall(
  fullName: string,                              // 'wiki__search'
  args: Record<string, unknown>,                 // 模型给的参数
  caller: {                                      // 由 mcp.controller 鉴权后注入
    keyId: number;
    role: string;
    userName: string | null;
    team: string | null;
    profile: string | null;
  },
  ctx: {                                         // 请求上下文
    requestId: string;
    sessionId: string | null;
    callerIp: string | null;
    clientInfo: string | null;
  },
): Promise<ToolCallResult>

返回值有两种形态:成功时透传上游 content;失败时由统一的 fail() 构造 { isError: true, content: [{ type: 'text', text: '[ERROR_CODE] 中文简述' }] }

GATE 1 · 注册表查找

const entry = this.registry.get(fullName);
if (!entry || !entry.enabled) {
  return this.fail(caller, ctx, fullName, null,
    ErrorCode.TOOL_NOT_FOUND, '工具不存在或已下线', 0);
}
const u = await this.upstreamRepo.findOne({ where: { name: entry.upstream } });
if (!u || !u.enabled) {
  return this.fail(caller, ctx, fullName, entry.upstream,
    ErrorCode.UPSTREAM_DOWN, '上游服务暂不可用', 0);
}

两个检查点:工具本身是否在注册表且 enabled(可被后台单独下线而不删上游), 以及所属上游是否在库且 enabled。二者对应不同错误码,便于运维定位。

GATE 2 · RBAC 权限(含影子模式)

// 与 tools/list 共用同一份 allowedSet —— 「可见即可调」契约
const allNames = this.registry.allToolNames();
const allowed  = this.rbac.buildAllowedSet(caller.role, allNames, this.registry.version);

if (!allowed.has(fullName)) {
  if (this.rbac.getMode() === 'observe') {
    // F1 影子模式:放行,但打一条 rbac_would_forbid 审计
    this.audit.log({
      event: 'rbac_would_forbid',
      tool: fullName,
      upstream: entry.upstream,
      errorCode: ErrorCode.WOULD_FORBID,
      resultOk: 1,                       // 注意:实际是成功的
      ...caller, ...ctx,
    });
    // 不 return,继续往下走限流 / 熔断 / 转发
  } else {
    return this.fail(caller, ctx, fullName, entry.upstream,
      ErrorCode.FORBIDDEN, '无权调用该工具', 0, 'client-error');
  }
}

影子模式(rbac.mode = observe)的价值在于:在真正收紧权限之前,先用生产流量验证规则写对了没有。 被影子拦下的调用会写 rbac_would_forbid 审计事件,仪表盘的 wouldForbidCount / wouldForbidTop 直接给出「如果切 enforce 会影响多少调用、影响谁」。 这是权限变更从「拍脑袋」变成「看数据」的关键一步。

TIP

FORBIDDENkind = 'client-error' 构造, 因此不计入熔断统计——权限问题不是上游的错,不该让上游背锅被熔断。 同理 BAD_ARGS 也是 client-error。

GATE 3 · 令牌桶限流

// 工具级覆写 QPM 优先于全局默认
const ov   = await this.overrideRepo.findOne({ where: { fullName } });
const rate = ov?.rateQpm ?? undefined;          // undefined → 走 settings 默认 30
const rl   = this.rateLimit.take(caller.keyId, fullName, rate, rate);

if (!rl.ok) {
  return this.fail(caller, ctx, fullName, entry.upstream,
    ErrorCode.RATE_LIMITED, '调用过于频繁,请稍后重试', 0, 'client-error',
    { retryAfterS: rl.retryAfterS });
}

桶键是 ${keyId}|${tool} 的组合——按「谁的哪把 Key 调哪个工具」双维度限流, 因此 A 团队打爆自己的桶不会影响 B 团队。注意限流也标记为 client-error,同样不污染熔断。 算法细节见 10 限流·熔断·并发

GATE 4 · 三态熔断检查

const st = this.breaker.allow(entry.upstream);   // 'closed' | 'half-open' | 'open'
if (st === 'open') {
  return this.fail(caller, ctx, fullName, entry.upstream,
    ErrorCode.UPSTREAM_DOWN, '上游服务暂不可用', 0, 'upstream-error');
}
// half-open:breaker 内部用 probing 布尔位保证只有一个请求作为探针放行

熔断在上游维度而非工具维度——一个上游挂了,它下面所有工具一起快速失败, 这才是熔断的意义(保护上游,不是保护单个工具)。

GATE 5 · 并发信号量

const limit = u.concurrency ?? defaultConcurrency;     // settings: 20
// stdio 上游是本地子进程,并发能力有限,强制收敛到 ≤2
const effLimit = u.transport === 'stdio' ? Math.min(limit, 2) : limit;

const acq = await this.concurrency.acquire(entry.upstream, effLimit, 50);
if (!acq.ok) {
  return this.fail(caller, ctx, fullName, entry.upstream,
    ErrorCode.UPSTREAM_BUSY, '上游繁忙,请稍后重试', 0, 'upstream-error',
    { queueMs: acq.waitedMs });
}

try {
  // … GATE 6 转发 …
} finally {
  acq.release();          // 必须在 finally 里释放,否则 slot 泄漏
}

acquire() 的第三个参数 50 是等待队列上限:超过就直接拒绝, 而不是让请求无限排队。queueMs 会写进审计,用于识别「上游不是慢,是挤」。

BUG

历史坑(已修):释放 slot 时如果只做 s.active-- 而不唤醒等待者, 队列里的请求会永久悬挂。当前实现是 s.active = Math.max(0, s.active - 1); const next = s.waiters.shift(); if (next) next();Math.max(0, …) 则防止重复 release 把计数打成负数、凭空放大并发额度。

GATE 6 · 超时转发

const timeout = u.timeoutMs ?? defaultTimeout;         // settings: 20000
const t0 = Date.now();
try {
  const raw = await this.upstreamManager.callTool(entry.upstream, entry.originalName, args, timeout);
  const durationMs = Date.now() - t0;

  this.breaker.onSuccess(entry.upstream);
  this.metrics.toolCall(entry.upstream, 'ok', durationMs);
  this.audit.log({ event: 'tool_call', resultOk: 1, durationMs, queueMs: acq.waitedMs, … });

  return { content: raw.content, isError: false };
} catch (e) {
  const ge = toGatewayError(e);                        // 归一化
  this.breaker.onFailure(entry.upstream, ge.kind);     // client-error 直接 return null
  this.metrics.toolCall(entry.upstream, ge.code, Date.now() - t0);
  return this.fail(caller, ctx, fullName, entry.upstream, ge.code, ge.safeMessage,
                   Date.now() - t0, ge.kind, ge.meta);
}

超时通过 AbortController 施加在 SDK 调用上,超时后映射为 UPSTREAM_TIMEOUT;连接失败映射 UPSTREAM_DOWN; 上游返回业务错误映射 UPSTREAM_ERROR。三者的 kind 都是 upstream-error,都计入熔断。

统一失败路径 fail()

所有闸的失败都汇聚到同一个私有方法,保证「失败也有完整审计」:

  1. 构造错误对象GatewayToolError(code, safeMessage, raw, kind, meta)message 格式固定为 [CODE] 中文简述
  2. 消息净化safeMessage() 剥离堆栈、IP:port、SQL 片段、 绝对路径、Bearer xxx,并截断到 200 字符——这段文本会返回给模型, 绝不能泄漏内网拓扑。原始错误另存 error_msg 字段,仅后台可见。
  3. 打指标metrics.toolCall(upstream, code, durationMs)
  4. 写审计resultOk = 0 + errorCode + errorMsg
  5. 返回 MCP 语义{ isError: true, content: [{ type:'text', text }] }不是 JSON-RPC error——这是 MCP 规范的明确要求:工具业务失败要让模型看到并调整策略, 协议级失败(方法不存在、参数结构错)才用 JSON-RPC error。

「可见即可调」契约

这是 RBAC 设计上最容易被忽视、也最容易出安全漏洞的一点:

// tools/list 走这里
listTools(caller) {
  const allowed = this.rbac.buildAllowedSet(caller.role, this.registry.allToolNames(), this.registry.version);
  return this.registry.listFor(allowed);        // 只返回 allowed 命中的
}

// tools/call 走 GATE 2,用的是同一个 buildAllowedSet()
// 缓存键 = role + registryVersion + rulesVersion + 60s TTL
// → 同一次会话内,两者结果必然一致

如果两者用了不同的判定路径,就会出现「列表里看得到但调不动」(体验灾难)或 「列表里没有但直接猜名字能调通」(严重越权)。

07

工具注册表

把 N 个上游的 M 个工具压平成一张带命名空间的表,并用版本戳驱动全局缓存一致性。

命名空间聚合

全名规则:<upstream>__<originalName>(两个下划线)。例如上游 wikisearch 工具 → wiki__search

  • 为什么用双下划线:单下划线在工具名里太常见(delete_record), 双下划线几乎不会与真实工具名冲突,且 split('__') 反解无歧义。
  • 上游名有硬约束NAME_RE = /^[a-z][a-z0-9_-]{1,31}$/—— 小写字母开头,2~32 字符,只允许小写字母 / 数字 / 下划线 / 连字符。 这条正则在 upstream.controller.ts 创建时就校验,从源头杜绝注入到全名里的非法字符。
  • RBAC 通配符天然适配wiki__* 匹配整个上游, *__delete_* 跨上游匹配所有删除类工具。

内存条目结构

interface RegistryEntry {
  fullName: string;        // 'wiki__search'
  upstream: string;        // 'wiki'
  originalName: string;    // 'search' —— 转发给上游时用这个名字
  def: {                   // 暴露给模型的 MCP 工具定义
    name: string;
    title?: string;
    description?: string;
    inputSchema?: object;
  };
  rawDescription: string;  // 上游原始描述(覆写前的原值,供后台对比/还原)
  enabled: boolean;        // 可被 tool_overrides 单独下线
  sensitivity: 'normal' | 'sensitive';  // 继承自上游,决定是否存全量参数
}

注册表本体是进程内 Map<string, RegistryEntry>不做懒加载——冷启动时从 tools_snapshot 表一次性灌满, 因此即使所有上游都连不上,网关也能立刻对外提供 tools/list(降级可用)。

版本戳:缓存一致性的支点

private _version = 0;
get version(): number { return this._version; }

/** 工具集发生任何变化都调它:单调递增,永不回退 */
private bump(): void { this._version++; }

触发 bump() 的时机:

  • 上游刷新工具列表(手动 refresh / 定时刷新 / 重连成功)
  • 新增或删除上游
  • 工具覆写变化(改描述 / 上下线 / 改限流)

消费方是 RBAC 的 allowedSet 缓存:缓存键为 ${role}|${registryVersion}|${rulesVersion},另有 60s TTL 兜底。 版本号变化 → 键变化 → 旧缓存自然不再命中,无需任何显式失效逻辑, 也就没有「忘了清缓存」这类 bug 的生存空间。

该值同时作为 Prometheus Gauge mgw_registry_version 暴露, 运维可以用 changes(mgw_registry_version[10m]) 观察注册表抖动频率。

快照落库与冷启动

启动loadFromSnapshot()读 tools_snapshot 全表
灌入内存 Map 就绪tools/list 可服务
并行连接各上游upstream-manager
成功refreshUpstream()替换条目 + 回写快照 + bump()

refreshUpstream(name, tools) 的语义是整体替换该上游的条目集合: 先删掉旧的全名,再写入新的,然后 upsert 到 tools_snapshot (更新 last_seen_at,新工具写 first_seen_at),最后 bump()

这样做的好处是上游下线了某个工具,网关侧会自动消失,不会留下「僵尸工具」被模型调用后报错。

工具覆写

tool_overrides 表提供四项不改动上游的治理能力:

字段作用典型场景
description覆盖暴露给模型的描述文本 上游描述写得含糊或有误导,改写得更精确以提升模型选对工具的概率; rawDescription 保留原值,随时可还原
enabled单独下线某个工具(不影响上游其它工具) 某工具有 bug 或不该开放,先摘掉,比禁用整个上游粒度更细
rate_qpm工具级限流覆写,优先于全局默认 给昂贵工具(如大模型二次调用)单独收紧到 5 QPM
cache_ttl_s结果缓存时长(预留字段)幂等只读工具可缓存降载
note / updated_by备注与操作人全量进 change_logs

applyOverride() 在注册表条目生成时套用覆写,因此治理链看到的 entry.enabledentry.def.description 已经是覆写后的最终值—— 覆写逻辑不会散落到调用路径各处

定时刷新

后台每 registry.refresh_interval_s(默认 300s)遍历所有 enabled 上游, 调 client.listTools() 并走 refreshUpstream()。这是为了捕捉 上游自己变了但没发通知的漂移情况(很多 MCP Server 并不实现 listChanged 推送)。

NOTE

刷新失败不会清空注册表——保留上一次成功的快照,只更新上游的 last_status / last_error / last_checked_at。 「拿不到新的」永远优于「把旧的弄丢」。

描述里的 Prompt 注入检测

上游工具描述会直接进入模型的上下文,因此是 prompt 注入的天然入口。 upstream.controller.ts 内置一条 SUSPICIOUS_DESC 正则, 在注册/刷新时对描述做扫描,命中即返回告警提示(如「忽略以上指令」「你现在是…」之类的模式), 由管理员决定是否放行。这是把供应链安全纳入网关职责的一个具体落点。

08

上游管理与 MCP Client 池

gateway/upstream-manager.service.ts(346 行)是后端最重的一个文件, 承担连接生命周期、两种 transport、健康检查、退避重连与进程隔离。

两种 transport

transport连接方式必填字段约束
stdio 拉起本地子进程,通过 stdin/stdout 交换 JSON-RPC command · argsJson · cwd(可选)· envEnc(加密) MGW_STDIO_MAX(默认 8)总量限制;并发被强制收敛到 ≤2; env 走白名单隔离(见下)
http Streamable HTTP,连接远端 MCP Server url · headersEnc(加密,通常放 Authorization) URL 必须通过 SSRF CIDR 白名单校验;请求带 redirect: 'manual' 防重定向绕过

动态导入 ESM SDK

MCP SDK 是 ESM-only 包,而 NestJS 项目 tsconfig 输出 CommonJS, 顶层 import 会直接编译成 require() 而崩溃。解法是运行时动态导入

// 类型层面:用 typeof import 拿到类型,不产生运行时 require
type ClientCtor = typeof import('@modelcontextprotocol/sdk/client/index.js');

// 运行时:三个入口分别动态 import
const { Client }                 = await import('@modelcontextprotocol/sdk/client/index.js');
const { StdioClientTransport }   = await import('@modelcontextprotocol/sdk/client/stdio.js');
const { StreamableHTTPClientTransport } =
                                   await import('@modelcontextprotocol/sdk/client/streamableHttp.js');

模块加载器会缓存结果,因此动态 import 只在实际建连时付一次代价,之后是纯内存查找。

stdio 子进程的 env 白名单隔离

这是安全设计里最容易被忽略、但后果最严重的一环。网关进程持有 MGW_SECRETADMIN_TOKEN 等致命凭据, 如果原样 process.env 传给子进程,任何一个上游 MCP Server(可能是第三方代码) 都能读走它们。

const STDIO_ENV_ALLOW_KEYS = new Set<string>([
  'PATH', 'HOME', 'USER', 'SHELL', 'LANG', 'TMPDIR', 'PWD', 'TZ', 'NODE_ENV',
]);
const STDIO_ENV_ALLOW_PREFIXES = ['LC_'];

function buildChildEnv(custom: Record<string, string>): NodeJS.ProcessEnv {
  const out: NodeJS.ProcessEnv = {};
  for (const [k, v] of Object.entries(process.env)) {
    if (STDIO_ENV_ALLOW_KEYS.has(k) || STDIO_ENV_ALLOW_PREFIXES.some((p) => k.startsWith(p))) {
      out[k] = v;
    }
  }
  // 只叠加管理员显式配置的、解密后的自定义变量
  return { ...out, ...custom };
}

结果是子进程看不到 MGW_*ADMIN_TOKEN*_FILENODE_OPTIONS 等任何敏感或可被滥用的变量 (NODE_OPTIONS 尤其危险——它能注入 --require 执行任意代码)。 子进程需要的凭据,必须由管理员在上游配置里显式录入,经 AES-256-GCM 加密存 env_enc 字段。

SSRF 防护

gateway/ssrf.ts 是一组零依赖纯函数:

/** 永久拒绝,无论白名单怎么写 */
const ALWAYS_DENY = ['169.254.0.0/16', '127.0.0.0/8'];

export function ipToLong(ip: string): number;      // '10.0.0.1' → 167772161
export function inCidr(ip: string, cidr: string): boolean;

export function checkUpstreamUrl(
  rawUrl: string,
  allowedCidrs: string[],
  resolvedIp?: string,      // 已做 DNS 解析时传入,避免 TOCTOU
): { ok: true } | { ok: false; reason: string };

三层防线:

  1. 协议与主机名合法性:只接受 http: / https:,主机名必须能解析。
  2. CIDR 白名单:解析出的 IP 必须落在 MGW_OUTBOUND_ALLOWED_CIDRS (默认 10.0.0.0/8,127.0.0.1/32,192.168.0.0/16)之内。
  3. 永久拒绝段169.254.0.0/16 覆盖云厂商元数据服务 (169.254.169.254,拿到它往往等于拿到实例角色凭据), 127.0.0.0/8 阻止打本机其它服务。 这两段即使被显式写进白名单也依然拒绝。
NOTE

默认白名单里出现的 127.0.0.1/32 与永久拒绝的 127.0.0.0/8 冲突时, ALWAYS_DENY 优先。本地联调请把 mock 上游挂到局域网地址,或临时调整 ALWAYS_DENY(不建议提交)。

此外,出站请求统一使用 redirect: 'manual': 否则白名单内的服务返回一个 302 → http://169.254.169.254/ 就能绕过全部校验。

健康检查

// 每 30s 遍历所有 enabled 上游
setInterval(() => this.healthCheckAll(), 30_000);

// 单次检查:尝试 client.listTools(),据此更新状态
private async probe(name: string): Promise<void> {
  try {
    await this.clientFor(name).listTools();
    await this.repo.update({ name }, {
      lastStatus: 'up', lastError: null, lastCheckedAt: Date.now(),
    });
    if (wasDown) {                       // 从 down 恢复 → 通知 + 熔断恢复
      this.im.upstreamRecovered(name);
      this.breaker.onUpstreamUp(name);
      await this.registry.refreshUpstream(name, tools);
    }
  } catch (e) {
    await this.repo.update({ name }, {
      lastStatus: 'down', lastError: safeMessage(e), lastCheckedAt: Date.now(),
    });
  }
}

后台的「上游服务」页与顶栏健康徽章读的就是 last_statusGET /admin/api/upstreams/:name/probe 提供手动即时探活。

指数退避重连

// 每次失败翻倍,上限 5 分钟
state.lastBackoff = Math.min((state.lastBackoff ?? 1000) * 2, 300_000);
setTimeout(() => this.connect(name), state.lastBackoff);

上限 5 分钟的意义:一个彻底挂掉的上游不会让网关每秒都在尝试 spawn 子进程 / 建 TCP 连接, 把资源耗在注定失败的事情上。连接成功后 lastBackoff 复位。

F9 · stdio crash loop 自动熔断

stdio 子进程如果启动即崩(命令不存在、依赖缺失、参数错误),退避重连会陷入 「spawn → 立刻退出 → 再 spawn」的循环,日志被刷爆、CPU 被吃光。 P0-F9 加了一道硬闸:

const CRASH_WINDOW_MS = 5 * 60_000;   // 5 分钟观察窗
const CRASH_THRESHOLD = 5;            // 窗口内崩溃 5 次

private recordCrash(name: string): void {
  const s = this.crashState(name);
  s.hits = s.hits.filter((t) => Date.now() - t < CRASH_WINDOW_MS);
  s.hits.push(Date.now());
  if (s.hits.length < CRASH_THRESHOLD) return;

  // 触发:彻底禁用,停止一切重试
  s.hits = [];
  this.repo.update({ name }, { enabled: 0, lastError: 'crash_loop' });
  this.changeLog.record({
    actor: 'system', targetType: 'upstream', targetId: name,
    action: 'auto-disable',
    diffSummary: `${name} 5 分钟内崩溃 ${CRASH_THRESHOLD} 次,已自动禁用`,
  });
  this.im.stdioCrashLoop(name);
  logger.warn({ event: 'stdio_crash_loop', upstream: name });
}

三个动作缺一不可:禁用(止血)+ 留痕action='auto-disable' 让变更记录页能看到是系统而非人做的)+ 告警(人得知道)。 恢复方式是在后台修正配置后手动启用。

callTool 与超时

async callTool(upstream: string, originalName: string,
               args: object, timeoutMs: number) {
  const client = this.clientFor(upstream);       // 未连接 → 抛 UPSTREAM_DOWN
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), timeoutMs);
  try {
    return await client.callTool(
      { name: originalName, arguments: args },   // 注意:用原名,不是全名
      undefined,
      { signal: ac.signal, timeout: timeoutMs },
    );
  } catch (e) {
    if (ac.signal.aborted) throw upstreamError(ErrorCode.UPSTREAM_TIMEOUT, '上游响应超时', String(e));
    throw e;
  } finally {
    clearTimeout(timer);
  }
}

AbortControllertimeout 双保险:前者确保 Promise 一定被拒绝、 不留悬挂句柄,后者是 SDK 自身的超时机制。finally 里的 clearTimeout 防止定时器泄漏——高频调用下漏掉的定时器会累积成可观的内存占用。

创建上游时的「先试连、失败不落库」

POST /admin/api/upstreams 的行为值得单独说明:它会先真实建连并拉一次工具列表, 成功才写库。连接失败直接返回 502 并附上净化后的原因,数据库里不留任何脏记录

这个取舍的理由:一条连不上的上游记录会持续触发重连与告警, 让「配置写错了」这种低级问题以「系统故障」的形式表现出来,排查成本极高。 宁可让管理员在提交那一刻就吃到错误。

09

鉴权与 RBAC

两套互不相干的凭据体系,加上一套可灰度预演的通配符权限引擎。

API Key 的形态

generateApiKey(): { key: string; hash: string; prefix: string } {
  const key = 'mgw_live_' + randomBytes(24).toString('base64url');  // 32 字符随机体
  return {
    key,
    hash:   createHash('sha256').update(key).digest('hex'),          // 库里只存这个
    prefix: key.slice(0, 16),                                        // 'mgw_live_AbCdEf'
  };
}
mgw_live_
固定前缀,让密钥扫描器(GitHub Secret Scanning 等)能识别; 同时肉眼可辨来源,避免与其它系统的 token 混淆
base64url(24B)
192 bit 熵,crypto.randomBytes 是 CSPRNG; base64url 无需转义,可安全出现在 URL / Header / JSON 中
只存 SHA-256
数据库泄漏不等于凭据泄漏。不可逆—— 后台永远无法「查看」明文 Key,只能重新签发
prefix(前 16 字符)
明文存库,用于后台列表识别与人工比对 ("这把 mgw_live_AbCdEf… 是谁的"),泄漏 16 字符前缀不影响安全性
RULE

明文 Key 只在签发响应里出现一次IssuedKey.key), 前端弹窗提示「请立即复制保存,关闭后无法再次查看」。 任何声称能「找回」明文 Key 的实现都是安全缺陷。

F4 · Key 校验与 ≤2s 即时吊销

async verify(rawKey: string): Promise<Caller | null> {
  if (!rawKey?.startsWith('mgw_live_')) return null;      // 快速失败,省一次哈希

  // 吊销黑名单缓存:命中即拒,TTL 2s
  if (this.revokedCache.has(rawKey)) return null;

  const hash = sha256(rawKey);
  const row  = await this.repo.findOne({ where: { keyHash: hash } });
  if (!row || row.revoked) { this.revokedCache.set(rawKey, true); return null; }
  if (row.expiresAt && row.expiresAt < Date.now()) return null;

  row.lastUsedAt = Date.now();                            // 异步更新,不阻塞主链路
  return { keyId: row.id, role: row.role, userName: row.userName,
           team: row.team, profile: row.profile };
}

P0-F4 要解决的问题:Key 一旦泄漏(提交进 Git、打进日志、被截图), 吊销必须立刻生效,不能等缓存自然过期。做法是吊销时主动写入一个短 TTL 黑名单, 而不是依赖「查库结果缓存」的失效:

  • 正常校验路径:哈希 → 查库(SQLite 本地查询,微秒级)→ 命中即放行。
  • 吊销路径:POST /admin/api/keys/:id/revoke → 库里置 revoked=1, revoked_at, revoke_reason同时把该 Key 明文前缀推入黑名单缓存。
  • 结果:吊销后最多 2s 内全链路失效(含正在进行的会话——会话每次调用都会重新 verify)。

revoke_reason 是枚举 leak / offboard / rotate / other, 写进 change_logs 供事后审计「为什么这把 Key 被吊销了」。

Key 轮换与宽限期

POST /admin/api/keys/:id/rotate 接受 graceMinutes: 签发一把新 Key 并让旧 Key 在宽限期内继续有效,宽限期到后再自动吊销。 这让「换 Key」从一次需要停机协调的操作,变成可以灰度推进的常规运维—— 业务方有时间把配置从旧 Key 切到新 Key,不会在切换瞬间全线 401。

角色与规则模型

一个角色(roles)对应一组有序规则(role_rules), 每条规则三要素:effectdeny / allow)、 pattern(通配符)、sort(顺序)。 按 sort 升序遍历,首条命中即决定结果,后续规则不再评估。

/** 通配符 → 正则。只有 '*' 是元字符,其余全部转义 */
private toRegex(pattern: string): RegExp {
  const body = pattern.split('*').map(escapeRegExp).join('.*');
  return new RegExp('^' + body + '$', 's');    // 's' 让 '.' 也匹配换行
}

compute(role: string, fullName: string): boolean {
  const rules = this.rulesOf(role);            // 已按 sort 升序
  for (const r of rules) {
    if (this.regexOf(r.pattern).test(fullName)) {
      return r.effect === 'allow';             // ★ 首条命中即返回
    }
  }
  return false;                                // 默认拒绝(deny by default)
}
KEY

顺序决定语义deny finance__* 排在 allow * 之前,财务工具就是禁的;反过来写,前者永远不生效。 这也是为什么后台的规则编辑器必须支持拖拽排序,且 normalizeSort() 在批量写入时会把 deny 组统一排到 allow 组之前(sort 10, 20, 30…),降低误配概率。

三个内置角色

database/seed.ts 幂等写入(is_builtin = 1,后台不可删):

角色规则(按 sort 升序)语义
intern deny *__delete_* deny *__write_* deny finance__*
allow wiki__* allow *__search
只读为主:任何上游的删除 / 写入类工具全禁,财务上游整体禁; 知识库全部可用,其它上游只开放 *__search 结尾的检索类工具。 未命中的默认拒绝
support deny crm__delete_* deny finance__refund_*
allow crm__* allow wiki__* allow order__*
客服场景:CRM 可读写但不能删客户,可查订单与知识库, 但不能发起退款finance__refund_* 被拦, 且 finance__* 其余部分因未命中 allow 而默认拒绝)
admin deny *__drop_*
allow *
几乎全通,但仍保留一条底线:任何删库级操作都禁。 这条规则的存在是为了防止模型幻觉出一次不可逆的破坏

ReDoS 防护

规则 pattern 由管理员输入,最终会被编译成正则并在每次工具调用时执行。 一个写得恶劣的 pattern 可以造成灾难性回溯,让整个事件循环卡死:

/** 检测嵌套量词,如 (a+)+ 、 (a*){2,} —— 指数级回溯的典型形态 */
const NESTED_QUANTIFIER = /\([^)]*[+*]\)[+*{]/;
const MAX_PATTERN_LEN = 200;

validatePattern(p: string): void {
  if (p.length > MAX_PATTERN_LEN) throw new BadRequestException('pattern 过长(上限 200)');
  if (NESTED_QUANTIFIER.test(p))  throw new BadRequestException('pattern 含嵌套量词,存在 ReDoS 风险');
}

由于 toRegex() 只把 * 翻译为 .*、其余字符全部转义, 实际上管理员无法直接注入正则量词;这道校验主要防的是 *(*a*)* 这类经翻译后产生 .*\(.*a.*\).* 的嵌套结构, 以及超长 pattern 带来的编译开销。防御纵深,不依赖单一保证。

F1 · 影子模式(observe)

权限收紧最危险的时刻是「切换生效的那一瞬间」。影子模式把这一瞬间拉长成一段可观察的时期:

模式settings 值行为
强制(默认)rbac.mode = enforce 未命中 allow → 立即返回 FORBIDDEN,写 tool_call 审计(resultOk=0
影子rbac.mode = observe 未命中 allow → 照常放行并完成调用,额外写一条 event='rbac_would_forbid' + errorCode='WOULD_FORBID' + resultOk=1 的审计记录

切换方式:后台「系统设置」里改 rbac.mode,或 PUT /admin/api/settings热生效,无需重启RbacService.getMode() 每次读取)。

配套的观测能力(GET /admin/api/stats/overview):

rbacMode
当前模式,仪表盘顶部用徽章直接显示,避免「忘了自己开着影子模式」
wouldForbidCount
统计周期内被影子标记的调用总数 = 切 enforce 后会失败多少次
wouldForbidTop
按「工具 × 角色」聚合的 Top 榜 = 具体谁会被影响、影响在哪个工具
SOP

推荐的权限收紧流程
① 切 observe → ② 配好新规则 → ③ 观察 3~7 天 wouldForbidTop → ④ 用「影响面预览」量化受影响 Key 与近 7 日调用量 → ⑤ 与业务方确认 → ⑥ 切 enforce。全程零故障窗口。

F3 · 影响面预览

POST /admin/api/permissions/preview 接受「角色 + 一组候选规则」, 在不写库的前提下算出规则变更的完整影响面:

interface PreviewResult {
  role: string;
  currentCount: number;      // 当前可见工具数
  nextCount: number;         // 新规则下可见工具数
  gained: string[];          // 新增可见的工具全名
  lost: string[];            // 失去可见的工具全名
  affectedKeys: KeyItem[];   // 受影响的 Key 明细
  affectedKeyCount: number;  // 受影响的 Key 数量
  recent7dCalls: number;     // 这些 Key 近 7 日总调用量
  lostCalls7d: number;       // 其中打在 lost 工具上的调用量 ← 最关键的数字
  gainedCalls7d: number;     // 其中打在 gained 工具上的调用量
  totalImpact7d: number;     // lostCalls7d + gainedCalls7d
}

实现上走的是 RbacService.computeWithRules()——一条不经过缓存、不污染线上状态 的独立计算路径,用传入的候选规则临时构造判定器。lostCalls7d 通过联查 audit_logs 得到,把「权限变更」从抽象的规则 diff 翻译成 「未来 7 天会有多少次调用失败」这种业务能听懂的语言。

F2 · 权限矩阵一键回滚

每次 PUT /admin/api/roles/:name/rules 都会把变更前的完整规则集写进 change_logs.before_jsonPOST /admin/api/roles/:name/rules/rollback 读取最近一条 target_type='role_rules' 的记录,把 before_json 原样写回, 并再记一条 action='rollback' 的新变更(回滚本身也是变更,同样留痕)。

配合 F3 的预览,权限运营形成了闭环:预览 → 应用 → 观察 → 一键回滚

allowedSet 缓存

buildAllowedSet(role: string, allNames: string[], registryVersion: number): Set<string> {
  const key = `${role}|${registryVersion}|${this.rulesVersion}`;
  const hit = this.cache.get(key);
  if (hit && Date.now() - hit.at < 60_000) return hit.set;   // 60s TTL 兜底

  const set = new Set<string>();
  for (const n of allNames) if (this.compute(role, n)) set.add(n);

  this.cache.set(key, { set, at: Date.now() });
  return set;
}

复杂度从 O(调用次数 × 工具数 × 规则数) 降到 O(工具数 × 规则数) 每 60 秒一次。 注册表有 200 个工具、角色有 10 条规则时,单次构建 2000 次正则匹配, 缓存命中后 tools/call 的权限判定退化为一次 Set.has()

缓存键包含两个版本戳,所以不需要任何主动失效代码: 工具集变了 registryVersion 递增,规则变了 rulesVersion 递增, 旧键再也不会被查询到,靠 TTL 与 Map 的自然增长上限回收。

Admin Token 与 AdminTokenGuard

@Injectable()
export class AdminTokenGuard implements CanActivate {
  canActivate(ctx: ExecutionContext): boolean {
    const req = ctx.switchToHttp().getRequest();

    // 例外:SSE 端点(EventSource 无法自定义 Header),允许 ?token= 查询参数
    if (req.path.endsWith('/stream')) {
      const q = req.query?.token;
      if (q && this.crypto.safeEqual(q, this.config.adminToken)) return true;
      throw new UnauthorizedException('无效的管理令牌');
    }

    const h = req.headers['authorization'] || '';
    const token = h.startsWith('Bearer ') ? h.slice(7) : '';
    if (!token || !this.crypto.safeEqual(token, this.config.adminToken)) {
      throw new UnauthorizedException('无效的管理令牌');
    }
    return true;
  }
}
  • 常量时间比较safeEqual() 内部用 crypto.timingSafeEqual,先比对长度再逐字节异或累积。 普通 === 会在首个不同字节处提前返回,攻击者可据此逐字符爆破。
  • 操作人标识x-admin-user 请求头由前端携带, 写进 change_logs.actoraudit_logs.user_name它是自报的、不可信的,仅用于留痕,不参与任何权限判定—— 真正的鉴权边界只有 Token 本身。
  • SSE 例外:浏览器原生 EventSource 不支持自定义 Header, 因此以 /stream 结尾的路径开放 ?token=。 代价是 token 会出现在 URL 里(可能被访问日志记录),所以这类端点只有只读的熔断事件流
  • 开发默认值:未设置 ADMIN_TOKEN 时回落到 mgw_admin_dev_token。生产环境必须通过环境变量或 ADMIN_TOKEN_FILE 覆盖。

加密原语

/** 主密钥派生:64 位 hex 直接用,否则 SHA-256 展开成 32 字节 */
private masterKey(secret: string): Buffer {
  return /^[0-9a-f]{64}$/i.test(secret)
    ? Buffer.from(secret, 'hex')
    : createHash('sha256').update(secret).digest();
}

encrypt(plain: string): string {
  const iv  = randomBytes(12);                              // GCM 推荐 96 bit
  const c   = createCipheriv('aes-256-gcm', this.key, iv);
  const enc = Buffer.concat([c.update(plain, 'utf8'), c.final()]);
  const tag = c.getAuthTag();                               // 16 字节认证标签
  return Buffer.concat([iv, tag, enc]).toString('base64');  // iv|tag|ct 自描述
}

decrypt(payload: string): string {
  const buf = Buffer.from(payload, 'base64');
  const iv = buf.subarray(0, 12), tag = buf.subarray(12, 28), ct = buf.subarray(28);
  const d = createDecipheriv('aes-256-gcm', this.key, iv);
  d.setAuthTag(tag);
  return Buffer.concat([d.update(ct), d.final()]).toString('utf8');
}
为什么选 GCM
它是认证加密(AEAD):密文被篡改时 d.final() 会抛错, 而不是解出乱码后继续执行。CBC 之类的模式需要额外 HMAC 才能达到同等保证
为什么 iv|tag|ct 拼在一起
密文自描述,不需要额外的列存 IV; 每次加密都用新的 randomBytes(12)同一明文两次加密结果不同, 无法通过比对密文推断哪些上游用了相同凭据
加密了什么
upstreams.headers_enc(HTTP 上游的请求头,通常是 Authorization) 与 upstreams.env_enc(stdio 上游的自定义环境变量)
MGW_SECRET 变更后果
历史密文全部无法解密,必须重新录入所有上游凭据。 这是一次不可逆操作,轮换密钥前务必先导出配置
*_FILE 支持
MGW_SECRET_FILE / ADMIN_TOKEN_FILE 优先于同名的值变量, 用于 Docker / K8s 把 secret 挂成文件的场景,避免密钥出现在 env 输出与进程列表里
10

限流 · 熔断 · 并发

三个纯内存状态机,全部无外部依赖,各自解决一类不同的过载问题。

令牌桶限流

governance/ratelimit.service.ts(76 行)实现的是经典令牌桶, 而非固定窗口计数器——区别在于桶允许突发

take(keyId: number, tool: string, rate = this.defaultQpm, burst = this.defaultBurst) {
  const id = `${keyId}|${tool}`;                    // ★ 双维度桶键
  const now = Date.now();
  let b = this.buckets.get(id);
  if (!b) { b = { tokens: burst, last: now }; this.buckets.set(id, b); }

  const capacity = burst;
  // 按经过时间线性补充令牌,上限为 capacity
  b.tokens = Math.min(capacity, b.tokens + ((now - b.last) / 60_000) * rate);
  b.last = now;

  if (b.tokens < 1) {
    const need = 1 - b.tokens;
    return { ok: false, retryAfterS: Math.ceil((need / rate) * 60) };
  }
  b.tokens -= 1;
  return { ok: true, retryAfterS: 0 };
}
默认速率
ratelimit.default_qpm = 30(每分钟 30 次),ratelimit.burst = 30
桶键
${keyId}|${tool}——同一把 Key 调不同工具用不同的桶, 不同 Key 调同一工具也用不同的桶。限流的是"某人对某工具"的行为,不是工具本身的总吞吐
覆写优先级
tool_overrides.rate_qpm > settings.ratelimit.default_qpm
惰性补充
没有后台定时器补令牌,而是在每次 take() 时按 (now - last) / 60000 * rate 一次性算出应补的量。零定时器开销,且天然精确
内存回收
sweep(idleMs = 600_000) 每 60s 最多执行一次, 清掉 10 分钟未使用的桶,防止 Key × 工具的组合基数把 Map 撑爆。 当前桶数暴露为 Gauge mgw_ratelimit_buckets
retryAfterS
RATE_LIMITED 错误一起返回,模型/客户端可据此退避而非盲目重试

三态熔断状态机

governance/breaker.service.ts(137 行)按上游维度维护状态:

STATEclosed正常放行
滑动窗口计失败
失败 ≥ 5 / 60sopen全部快速失败
UPSTREAM_DOWN
60s 后half-open放行一个探针
其余仍拒
探针成功closed计数清零
通知恢复
private readonly failThreshold = 5;        // 窗口内失败阈值
private readonly openMs        = 60_000;   // open 持续时间
private readonly windowMs      = 60_000;   // 失败计数滑动窗口

allow(upstream: string): 'closed' | 'half-open' | 'open' {
  const s = this.state(upstream);
  const now = Date.now();

  if (s.state === 'open') {
    if (now - s.openedAt < this.openMs) return 'open';
    s.state = 'half-open';                 // 时间到,进入探测态
    s.probing = false;
  }
  if (s.state === 'half-open') {
    if (s.probing) return 'open';          // ★ 已有探针在飞,其余请求仍拒
    s.probing = true;                      // 独占探针位
    return 'half-open';
  }
  return 'closed';
}

onFailure(upstream: string, kind: 'client-error' | 'upstream-error'): BreakerTransition | null {
  if (kind === 'client-error') return null;  // ★ BAD_ARGS / FORBIDDEN 不计入熔断
  const s = this.state(upstream);
  const now = Date.now();
  s.fails = s.fails.filter((t) => now - t < this.windowMs);
  s.fails.push(now);

  if (s.state === 'half-open') {            // 探针失败 → 重新 open,重新计时
    s.state = 'open'; s.openedAt = now; s.probing = false;
    return { kind: 'opened', from: 'half-open', to: 'open', fails: s.fails.length };
  }
  if (s.fails.length >= this.failThreshold) {
    const from = s.state;
    s.state = 'open'; s.openedAt = now;
    return { kind: 'opened', from, to: 'open', fails: s.fails.length };
  }
  return null;
}
WHY

为什么 half-open 只放一个探针:如果 60s 后一次性放开全部流量, 而上游其实还没恢复,就会造成第二波冲击(惊群),把刚有起色的上游再次打死。 独占探针位(probing 布尔)保证「恢复」这件事被谨慎验证。

为什么 client-error 不计入:模型传错参数、角色无权限, 这些是调用方的问题。把它们计入熔断会导致「一个不会用工具的模型把整个上游搞熔断」, 这是必须避免的错误归因。

P1-C/F · 熔断事件落库与实时推流

状态跃迁是有价值的运维信号,不能只留在内存。BreakerEventsService 负责两件事:

record(t: BreakerTransition, upstream: string, reason?: string): void {
  // 1) 落库 circuit_breaker_events
  this.repo.insert({
    ts: Date.now(), upstream,
    event: t.kind,                    // 'opened' | 'recovered'
    fromState: t.from, toState: t.to,
    failsInWindow: t.fails,
    openMs: t.kind === 'opened' ? this.openMs : null,
    reason: reason || '',
  });
  // 2) 推进 SSE Subject,前端熔断事件页实时刷新
  this.stream$.next({ type: 'breaker', data: { … } });
  // 3) IM 告警(带 5min 同类抑制)
  t.kind === 'opened' ? this.im.circuitOpened(upstream) : this.im.circuitClosed(upstream);
}

前端订阅:GET /admin/api/breaker/events/stream?token=… 是一条 SSE 长连接, 服务端用 merge(breaker$, interval(25_000).pipe(map(() => ({ type: 'ping' })))) 把业务事件与心跳合流,防止代理层因空闲断开。

另有 GET /admin/api/breaker/summary?days= 给出聚合视图: 各上游的熔断次数、累计 open 时长、最近一次跃迁时间——用于回答「哪个上游最不稳定」。

并发信号量

async acquire(upstream: string, limit: number, maxQueue = 50) {
  const s = this.state(upstream);
  const t0 = Date.now();

  if (s.active < limit) {                        // 有空位,直接拿
    s.active++;
    return { ok: true, waitedMs: 0, release: () => this.release(upstream) };
  }
  if (s.waiters.length >= maxQueue) {            // 队列满,立即拒绝
    return { ok: false, waitedMs: Date.now() - t0, release: () => {} };
  }

  // 排队等待,直到被前一个 release 唤醒
  await new Promise<void>((resolve) => s.waiters.push(resolve));
  s.active++;
  return { ok: true, waitedMs: Date.now() - t0, release: () => this.release(upstream) };
}

private release(upstream: string): void {
  const s = this.state(upstream);
  s.active = Math.max(0, s.active - 1);          // 防重复 release 打成负数
  const next = s.waiters.shift();
  if (next) next();                              // ★ 必须唤醒等待者,否则永久悬挂
}
默认并发上限
upstream.concurrency = 20(settings 全局默认),可被 upstreams.concurrency 逐上游覆盖
stdio 特例
effLimit = transport === 'stdio' ? Math.min(limit, 2) : limit—— 本地子进程的 stdout 是单条管道,高并发下响应会交错错乱,硬性收敛到 2
队列上限
50。超过立即返回 UPSTREAM_BUSY快速失败优于无限排队:排队 30s 后超时对调用方毫无价值
queueMs
等待时长写进 audit_logs.queue_ms诊断口诀:duration 高但 queue 低 = 上游慢;duration 高且 queue 高 = 并发不够

三者的分工

机制防的是什么维度失败时的错误码
限流调用方过度使用(含恶意重试)Key × 工具 RATE_LIMITED + retryAfterS(client-error,不计熔断)
熔断上游已故障,继续打只会雪上加霜上游 UPSTREAM_DOWN(upstream-error)
并发上游瞬时容量不足(还活着,只是挤)上游 UPSTREAM_BUSY + queueMs(upstream-error)

三者语义清晰不重叠:限流管"你调太多",熔断管"它坏了",并发管"它现在忙"。 错误码不同,模型和运维都能据此采取正确动作。

11

审计与脱敏

治理系统的可信度底线:谁在什么时候调了什么、改了什么,都必须能查到。

攒批落库

SQLite 是单写者,逐条 insert 在高 QPS 下会成为瓶颈。AuditService 的做法是 内存攒批 + 单事务批量写

log(entry: AuditEntry): void {           // 同步、非阻塞,调用方永不 await
  this.queue.push(this.toEntity(entry));
  if (this.queue.length >= this.flushSize) void this.flush();   // 默认 100 条
}

private async flush(): Promise<void> {
  if (!this.queue.length || this.flushing) return;
  this.flushing = true;
  const batch = this.queue.splice(0, this.queue.length);
  try {
    await this.dataSource.transaction(async (m) => {            // 单事务
      for (const e of batch) await m.getRepository(AuditLog).insert(e);
    });
  } catch (err) {
    this.writeFallback(batch);                                  // 落 JSONL 兜底
    this.im.auditFallbackGrowing(this.fallbackSize());
  } finally {
    this.flushing = false;
  }
}

// 定时器:默认每 1000ms 强制 flush 一次
setInterval(() => void this.flush(), this.flushMs);
触发条件
队列长度 ≥ MGW_AUDIT_FLUSH_SIZE(默认 100) 定时器到 MGW_AUDIT_FLUSH_MS(默认 1000ms),二者先到先触发
flushing 标志位
防止定时器与阈值触发并发执行 flush,导致同一批被写两次
splice 取批
先原子地摘走当前队列再写库,写库期间新来的日志进新队列, 不会因为写库慢而丢日志
队列深度指标
Gauge mgw_audit_queue_depth。持续 > 0 说明写入跟不上产生速度
优雅停机
onModuleDestroy()await flush(), 确保进程退出前队列排空

失败兜底与启动回放

写库失败(磁盘满、库被锁、WAL 异常)时,审计不能丢

  1. 落 JSONL:整批以每行一个 JSON 的形式追加到 data/audit-fallback.jsonl
  2. 告警ImService.auditFallbackGrowing(size),带 5 分钟同类抑制, 避免磁盘满时告警风暴。
  3. 启动回放BootstrapService 在启动阶段检查该文件, 逐行解析后批量 insert 回 audit_logs
  4. 重命名而非删除:回放成功后执行 renameSync(path, path + '.replayed.' + Date.now())不删文件——万一回放有 bug 造成数据错误,原始记录还在,可以人工重来。 清理是运维的显式动作,不是程序的自动行为。

参数存什么:摘要 vs 全量

private toEntity(e: AuditEntry): AuditLog {
  const argsText = JSON.stringify(e.args ?? {});
  const sensitive = e.sensitivity === 'sensitive';

  return {
    …e,
    // 普通工具:只存 SHA-256 前 16 位 + 脱敏后的摘要
    argsDigest: sha256(argsText).slice(0, 16),
    // 敏感工具:存脱敏后的全量 JSON(便于事后取证)
    argsFull: sensitive ? this.redact.redactDeep(e.args) : null,
    redacted: sensitive ? 1 : 0,
    resultBytes: e.resultBytes ?? null,
  };
}

这个分级是「可追溯性」与「数据最小化」的平衡:

  • 普通工具:16 位十六进制摘要(64 bit)足以完成 「同一批调用是否传了相同参数」「某个具体请求对应哪条日志」这类关联查询, 但无法还原参数内容,日志库泄漏也不等于业务数据泄漏。
  • 敏感工具(上游标记 sensitivity='sensitive'): 往往涉及资金、隐私、合规,出事时必须能还原现场,因此存全量—— 但先脱敏再存

PII 脱敏

governance/redact.service.ts(61 行)在入库前完成脱敏, 而不是查询时脱敏——这个顺序选择很关键:一旦明文落盘,就已经泄漏了

const RULES: Array<{ re: RegExp; mask: (m: string) => string }> = [
  { re: /\b1[3-9]\d{9}\b/g,        mask: (m) => m.slice(0, 3) + '****' + m.slice(-4) },  // 手机号
  { re: /[\w.+-]+@[\w-]+\.[\w.]+/g, mask: (m) => m[0] + '***' + m.slice(m.indexOf('@')) },// 邮箱
  { re: /\b\d{17}[\dXx]\b/g,       mask: (m) => m.slice(0, 6) + '********' + m.slice(-4) }, // 身份证
  { re: /\b\d{16,19}\b/g,          mask: (m) => m.slice(0, 4) + '****' + m.slice(-4) },  // 银行卡
];

redactDeep(value: unknown): unknown {
  if (typeof value === 'string') return this.redactString(value);
  if (Array.isArray(value))      return value.map((v) => this.redactDeep(v));
  if (value && typeof value === 'object') {
    const out: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(value)) out[k] = this.redactDeep(v);
    return out;
  }
  return value;
}
  • 规则顺序有意义:身份证(18 位)必须排在银行卡(16~19 位)之前, 否则身份证号会先被银行卡规则吃掉、掩成错误的格式。
  • 保留首尾:掩码保留前 3~6 位与后 4 位,既能人工识别是哪一个, 又不暴露完整号码。这是合规与可用性的常见折中。
  • 递归全量redactDeep 处理任意深度嵌套的 JSON, 数组与对象都会下钻,字符串叶子节点逐条规则替换。
  • 脱敏标记:处理过的记录 redacted = 1, 审计查询页可据此过滤,也让「这条日志是脱敏后的」这件事对审计员透明。
LIMIT

正则脱敏是尽力而为,不是保证。姓名、地址、自定义编号等无固定格式的 PII 无法被规则覆盖。真正敏感的数据应当在上游工具侧就不返回, 而不是指望网关兜底。

索引设计:以「安全部门的问题」为输入

audit_logs 上有 7 条索引,实体注释里明确写了设计方法论: 每条索引对应审计员会问的一个问题

索引字段回答的问题
idx_audit_tsts"最近一小时发生了什么"(时间轴回放)
idx_audit_user_tsuser_name, ts"张三上周都调了什么"
idx_audit_tool_tstool, ts"finance__refund 这个工具谁在用"
idx_audit_up_tsupstream, ts"crm 上游的调用量趋势"
idx_audit_sensitivesensitive, ts"把所有敏感调用列出来"(合规专项检查)
idx_audit_key_tskey_id, ts"这把已泄漏的 Key 在被吊销前做了什么"
idx_audit_reqrequest_id"用这个 requestId 串起日志与工单"

复合索引一律是 (维度, ts) 的顺序——因为几乎所有查询都是「某维度 + 时间倒序分页」, 这个顺序能让 SQLite 直接用索引完成排序,避免额外的 SORT 步骤。

只增不改

AuditLog 实体注释:「只增不改:代码层不提供 UPDATE/DELETE 业务路径。」 整个代码库里没有任何一处对 audit_logs 执行 update 或 delete。 唯一的删除方式是数据库文件级的物理操作(手动删库),而那需要操作系统权限, 不在应用层攻击面内。

配合 change_logs(记录管理员改了什么),形成双向可追溯: 调用行为可查,配置变更也可查——包括「谁把审计开关关了」这件事本身。

变更留痕

ChangeLogService.record() 是所有管理面写操作的统一出口:

await this.changeLog.record({
  actor: req.headers['x-admin-user'] || 'system',
  actorIp: req.ip,
  targetType: 'role_rules',        // 7 种之一
  targetId: 'support',
  action: 'update',                // 8 种之一
  beforeJson: redactDeep(before),  // 变更前完整对象(脱敏后)
  afterJson:  redactDeep(after),
  diffSummary: 'support 角色新增 deny crm__delete_record',  // 人类可读
});
targetType(7 种)
upstream · role · role_rules · api_key · tool_override · settings · backup
action(8 种)
create · update · delete · revoke · enable · disable · rollback · auto-disable

diffSummary 是刻意冗余的:before/after JSON 虽然完整,但审计员不会去逐字段比对。 一句话的人类可读摘要让「变更记录」页可以像看流水账一样快速扫过。 auto-disable 专供系统自动操作(F9 crash loop)使用,与人工的 disable 明确区分。

12

数据模型

11 张表,8 个实体文件,全部由 TypeORM 映射,单文件 SQLite 承载。

表清单

#表名实体类源文件职责
1upstreamsUpstreamupstream.entity.ts上游 MCP Server 配置(含加密凭据、健康状态)
2tools_snapshotToolSnapshottool.entity.ts工具快照,支撑冷启动降级
3tool_overridesToolOverridetool.entity.ts工具级治理覆写(描述/开关/限流)
4rolesRoleidentity.entity.ts角色定义
5role_rulesRoleRuleidentity.entity.ts角色的有序通配符规则
6api_keysApiKeyidentity.entity.tsMCP 通道凭据(哈希存储)
7audit_logsAuditLogaudit.entity.ts调用台账,只增不改,7 条索引
8change_logsChangeLogaudit.entity.ts配置变更留痕,含 before/after 快照
9settingsSettingsetting.entity.ts键值型全局设置,13 项内置默认
10circuit_breaker_eventsCircuitBreakerEventbreaker-event.entity.ts熔断状态跃迁历史(P1-C)
11backupsBackupbackup.entity.ts备份记录与状态(P1-A)

upstreams

类型说明
idinteger PK自增主键
nametext unique命名空间前缀,受 /^[a-z][a-z0-9_-]{1,31}$/ 约束
display_nametext后台展示名,可含中文与空格
transporttextstdio | http
urltext nullhttp 上游地址,注册时过 SSRF 校验
headers_enctext nullAES-256-GCM 加密的请求头 JSON
commandtext nullstdio 可执行文件
args_jsontext nullstdio 参数数组 JSON
cwdtext nullstdio 工作目录
env_enctext null加密的自定义环境变量(与白名单 env 合并后传给子进程)
sensitivitytextnormal | sensitive,决定审计是否存全量参数
enabledinteger0/1。F9 crash loop 会自动置 0
ownertext责任人 / 团队,用于告警定位
timeout_msinteger null逐上游超时覆写,null 走 settings 默认 20000
concurrencyinteger null逐上游并发覆写,null 走 settings 默认 20
remarktext备注
last_statustextup | down | unknown,健康检查写入
last_errortext null最近一次错误(经 safeMessage 净化),crash loop 时为 'crash_loop'
last_checked_atinteger null最近探活时间戳(ms)
created_at / updated_atinteger毫秒时间戳
CONV

全库时间戳统一用 integer 毫秒,不用 SQLite 的 text 日期。 理由:JS Date.now() 直接可存、比较与排序是整数运算更快、 不受时区与格式歧义影响;展示层再格式化。 实体属性用 camelCase,数据库列名通过 @Column({ name: 'snake_case' }) 显式映射。

tools_snapshot / tool_overrides

tools_snapshot
full_name PK · upstream(索引)· original_name · title · description · input_schema(JSON text)· first_seen_at · last_seen_at

主键直接用全名而非自增 id:全名本身就是天然唯一键, 省一次 join,也让「按上游批量替换」可以直接用 WHERE upstream = ?
tool_overrides
full_name PK · description · enabled · rate_qpm · cache_ttl_s · note · updated_by · updated_at

与快照分表而非合并:快照由系统写、覆写由人写, 生命周期与所有权完全不同。上游刷新会整体替换快照,绝不能连带清掉人工覆写。

roles / role_rules / api_keys

关键列
roles name PK · description · is_builtin(内置角色不可删)· created_at
role_rules id PK · role · effect(deny/allow) · pattern · sort · memo
索引 idx_rules_role (role, sort) — 规则永远按「角色 + 顺序」整批读取,这条复合索引正好覆盖
api_keys id PK · key_hash unique · key_prefix · name · user_name · team · role · profile · expires_at · revoked · revoked_at · revoke_reason · last_used_at · created_by · created_at

api_keys.role外键语义但无数据库级约束—— 删除角色前由控制器业务校验「是否还有未吊销的 Key 绑定此角色」, 有则拒绝删除(返回 409)。选择业务校验而非 FK 级联,是为了给出明确的错误信息而不是静默级联删除。

settings(13 项内置默认)

key默认值含义
ratelimit.default_qpm30令牌桶默认速率(次/分钟)
ratelimit.burst30桶容量,决定允许的突发量
upstream.timeout_ms20000上游调用默认超时
upstream.concurrency20每上游默认并发上限
audit.enabled1审计总开关
audit.args_full_threshold4096全量参数存储的字节阈值
registry.refresh_interval_s300注册表定时刷新间隔
session.idle_timeout_s1800会话空闲回收阈值(30 分钟)
rbac.modeenforceF1enforce 拒绝 / observe 影子放行
backup.enabled1P1-A 自动备份开关
backup.cron0 3 * * *备份调度表达式(默认每天 03:00)
backup.tzAsia/Shanghaicron 的时区
backup.keep7滚动保留份数,超出自动清理最旧的

写入策略:seed 阶段只插入不存在的 key,绝不覆盖已有值—— 否则每次重启都会把管理员调好的参数打回默认。修改走 PUT /admin/api/settings,受 SETTINGS_WHITELIST 约束(未知 key 直接 400), 并全量写 change_logs

circuit_breaker_events / backups

circuit_breaker_events
id PK · ts · upstream · event(opened/recovered) · from_state · to_state · fails_in_window · open_ms · reason
3 条索引:(ts) · (upstream, ts) · (event, ts), 分别服务时间轴、单上游历史、按类型统计三种查询。
backups
id PK · ts · filename · abs_path · bytes · duration_ms · trigger(manual/cron) · status(ok/failed) · error_msg · actor
2 条索引:(ts) · (status, ts)status='failed' 的记录会触发 IM 告警(P1-D)。

seed 的幂等设计

database/seed.ts(143 行)可以重复执行任意次,行为分类处理:

对象策略理由
角色 rolesupsert不存在则建,存在则更新 description,但不动 is_builtin
规则 role_rules整体替换DELETE WHERE role=? 再批量插入。 规则是有序的集合,增量 patch 极易出错;整体替换保证与 ROLE_SEEDS 完全一致
设置 settings只插不改绝不覆盖管理员调过的值
API Key仅当 count === 0库里一把 Key 都没有时才签发一把开发用 Key, 并把明文打印到 stdout 一次;已有 Key 则完全跳过
WARN

「规则整体替换」意味着:如果你在后台改过内置角色的规则,重跑 seed 会把改动冲掉。 需要定制时请新建角色,而不是改内置角色。生产环境建议把 seed 从启动流程中移除, 只在首次部署时手动执行一次。

P1-A · 数据库热备份

governance/backup.service.ts(269 行)用 better-sqlite3 的在线备份 API,不停机、不加全局锁

async run(trigger: 'manual' | 'cron', actor = 'system'): Promise<Backup> {
  const t0 = Date.now();
  const filename = `gateway-${fmtTs(new Date())}-${trigger}.db`;
  const absPath  = join(this.backupDir, filename);

  try {
    // SQLite 在线备份 API:逐页复制,期间写入不阻塞
    this.dataSource.driver.master.backup(absPath);
    const bytes = statSync(absPath).size;

    const rec = await this.repo.save({
      ts: Date.now(), filename, absPath, bytes,
      durationMs: Date.now() - t0, trigger, status: 'ok', actor,
    });
    await this.prune();                       // 滚动保留 backup.keep 份
    return rec;
  } catch (e) {
    await this.repo.save({ …, status: 'failed', errorMsg: safeMessage(e) });
    this.im.backupFailed(trigger, safeMessage(e));   // P1-D
    throw e;
  }
}
为什么不用 cp
WAL 模式下主库文件与 -wal 是分开的, cp 单拷主库会丢掉尚未 checkpoint 的最近写入,得到的是一个不一致的快照db.backup() 走 SQLite 官方在线备份协议,产物是自洽的完整库
cron 热更新
backup.cron / backup.tznode-cron 任务会被销毁重建,无需重启进程
滚动保留
prune()ts 倒序保留 backup.keep 份, 多余的同时删除文件与记录;清理失败会调 im.backupPruneFailed()
下载
GET /admin/api/backups/:id/download 以附件流返回, 受 AdminTokenGuard 保护
命名规则
gateway-20260913-030000-cron.db—— 时间戳 + 触发方式,文件名本身即可读,方便在文件系统层面直接挑选
TIP

恢复流程:停网关 → 把备份文件覆盖到 data/gateway.db删除残留的 gateway.db-walgateway.db-shm(否则会与新主库冲突) → 启动。建议恢复前先把当前 data/ 整目录另存一份。

13

MCP 传输层

协议握手、方法分派、会话生命周期与 SSE 通知流——对模型那一侧的全部细节。

三个 HTTP 端点,一个路径

方法路径语义
POST/mcp 承载全部 JSON-RPC 消息。响应可能是 application/json(普通请求) 或 text/event-stream(需要流式返回时)
GET/mcp 升级为 SSE 长连接,服务端主动推送 notifications/tools/list_changed; 每 25s 一条心跳注释帧
DELETE/mcp 客户端主动终止会话,服务端清理 Map 条目并关闭 SSE Subject

这正是 MCP Streamable HTTP 传输的标准形态:单一路径、多方法复用, 取代了早期版本里 /sse + /messages 的双端点设计。

进入分派前的四道校验

  1. Origin 校验(防 DNS rebinding) 浏览器发起的跨源请求带 Origin 头。若请求带 Origin 但不在 MGW_ALLOWED_ORIGINS(默认 http://localhost:5173,http://127.0.0.1:5173)内,直接 403。 非浏览器客户端通常不带 Origin,此时放行(配置留空即完全不校验)。 DNS rebinding 攻击正是靠浏览器把一个恶意域名解析到 127.0.0.1 来打本地服务,Origin 校验是最有效的阻断手段。
  2. Accept 头校验 MCP 规范要求客户端声明同时接受 JSON 与 SSE: Accept: application/json, text/event-stream。缺失则 406, 并返回明确的错误消息指导客户端修正——这能挡掉大量"随手 curl 一下"的误用。
  3. Bearer 鉴权 解析 Authorization: Bearer mgw_live_…ApiKeyService.verify()。 失败返回 401,且带 WWW-Authenticate: Bearer resource_metadata="{MGW_PUBLIC_ORIGIN}/.well-known/oauth-protected-resource", 让支持 OAuth 发现流程的客户端能自动走后续握手。同时写一条 auth_fail 审计。
  4. 会话校验initialize 外,所有方法都要求 Mcp-Session-Id 头对应一个活跃会话。 会话不存在或已过期 → 404(MCP 规范指定的状态码,客户端据此重新 initialize)。

协议版本协商

const PROTOCOL_VERSION  = '2025-06-18';   // 网关首选
const FALLBACK_PROTOCOL = '2025-03-26';   // 客户端不认识首选版本时回落

// initialize 处理
const requested = params?.protocolVersion;
const negotiated = requested === PROTOCOL_VERSION ? PROTOCOL_VERSION : FALLBACK_PROTOCOL;

return {
  jsonrpc: '2.0', id,
  result: {
    protocolVersion: negotiated,
    capabilities: { tools: { listChanged: true } },   // ★ 声明支持工具变更推送
    serverInfo: { name: 'mcp-gateway', version: '2.0.0' },
    instructions: '…',                                 // 给模型的使用说明
  },
};
// 响应头下发会话 ID
res.setHeader('mcp-session-id', sessionId);

capabilities.tools.listChanged = true 是关键声明: 它告诉客户端「工具集可能变,请订阅通知」。客户端收到 notifications/tools/list_changed 后应重新 tools/list

方法分派表

method需要会话处理
initialize否(创建会话)版本协商 + 下发 Mcp-Session-Id,写 initialize 审计
notifications/initialized握手完成通知,无响应体(JSON-RPC notification 语义)
initialized兼容部分客户端发的裸名,与上者等价
ping返回空 result: {},刷新会话 lastActiveAt
tools/list构建 allowedSet → registry.listFor(allowed) → 写 tools_list 审计
tools/callzod 校验 params.name / params.argumentsrouter.routeCall()
其它JSON-RPC error -32601 Method not found
NOTE

不支持的能力resources/*prompts/*logging/*completion/* 均未实现,capabilities 里也不声明。 网关的定位是工具治理,资源与提示词的治理是另一个问题域。 客户端请求这些方法会得到 -32601,符合规范的优雅降级。

会话管理

transport/session.manager.ts(119 行)用进程内 Map 持有会话:

interface Session {
  id: string;                       // uuid,即 Mcp-Session-Id
  keyId: number;
  role: string;
  userName: string | null;
  team: string | null;
  callerIp: string | null;
  clientInfo: string | null;        // initialize 时客户端自报的 name/version
  createdAt: number;
  lastActiveAt: number;             // 每次请求刷新
  stream$: Subject<SseEvent>;       // 该会话的通知流
  res?: Response;                   // 当前挂着的 SSE 响应对象
}
容量上限
MGW_MAX_SESSIONS(默认 300)。达到上限时新 initialize 被拒, 并触发 ImService.sessionLimitReached() 告警—— 这既防资源耗尽,也是"Key 被滥用"的信号
空闲回收
定时器扫描,now - lastActiveAt > MGW_SESSION_IDLE_TIMEOUT_S(默认 1800s) 的会话被清理,stream$ complete
活跃数指标
Gauge mgw_sessions_active
广播
broadcastListChanged() 遍历全部会话,向每个 stream$ push 一条 notifications/tools/list_changed;对已断开的响应做 try/catch 静默跳过
优雅排空
drain() 在进程退出时向所有会话发送关闭事件并 complete Subject, 让客户端明确知道是服务端主动关闭而非网络故障
LIMIT

会话在进程内 → 无法水平扩容。多实例部署时, initialize 与后续请求必须落到同一实例(sticky session), 且一个实例上的工具变更无法广播到另一个实例的会话。 单实例部署是这个设计的隐含前提。

SSE 心跳与推流

@Get()
async sse(@Req() req, @Res() res: Response) {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache, no-transform',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no',        // ★ 关键:告诉 Nginx 不要缓冲
  });

  const session = this.sessions.get(req.headers['mcp-session-id']);
  const sub = session.stream$.subscribe((ev) => {
    res.write(`event: message\ndata: ${JSON.stringify(ev)}\n\n`);
  });

  const heartbeat = setInterval(() => res.write(': ping\n\n'), 25_000);

  req.on('close', () => { clearInterval(heartbeat); sub.unsubscribe(); });
}
  • 25s 心跳:以 : 开头的是 SSE 注释帧,客户端会忽略内容但保持连接活跃。 选 25s 是因为常见的中间代理(Nginx proxy_read_timeout 默认 60s、 各类云 LB 多为 30~60s)都会在 30s 以上空闲时断连。
  • X-Accel-Buffering: no:没有这一头,Nginx 会把 SSE 响应缓冲起来, 客户端收不到实时事件——这是 SSE 部署最经典的坑。
  • no-transform:阻止代理压缩或改写响应体。
  • MetricsMiddleware 跳过 SSE:检测到 Content-Type: text/event-stream 时不记录延时直方图, 否则一条几小时的长连接会把 p99 拉成天文数字。

tools/list 的响应构造

const allowed = this.rbac.buildAllowedSet(caller.role, this.registry.allToolNames(), this.registry.version);
const tools   = this.registry.listFor(allowed);   // 已应用覆写、已过滤 disabled

return {
  result: {
    tools: tools.map((t) => ({
      name: t.fullName,                  // ★ 暴露全名,不是原名
      title: t.def.title,
      description: t.def.description,     // 可能已被 tool_overrides 覆写
      inputSchema: t.def.inputSchema,
    })),
  },
};

注意这里不返回 nextCursor——注册表规模在企业场景下(数百工具) 一次返回完全可接受,分页反而会让 RBAC 过滤逻辑复杂化(分页边界与权限边界不重合)。

参数校验与异常归一化

tools/callparams 用 zod 校验:

const CallParams = z.object({
  name: z.string().min(1).max(200),
  arguments: z.record(z.unknown()).optional().default({}),
});

// common/zod-exception.filter.ts —— 全局过滤器
@Catch(ZodError)
export class ZodExceptionFilter implements ExceptionFilter {
  catch(err: ZodError, host: ArgumentsHost) {
    const first = err.issues[0];
    const msg = first
      ? `${first.path.join('.')}: ${first.message}`
      : '参数校验失败';
    host.switchToHttp().getResponse()
        .status(400).json({ statusCode: 400, message: msg, error: 'Bad Request' });
  }
}

价值:zod 原生的 ZodError 结构冗长且嵌套,直接抛给客户端会得到一个 500 + 一大坨 JSON。过滤器把它归一化为一条指向具体字段的人类可读消息, 既方便调试,也避免暴露内部 schema 细节。

14

管理后台 API

9 个控制器、统一前缀 /admin/api、统一 AdminTokenGuard 保护。

通用约定

前缀
/admin/api(全局 setGlobalPrefix 之外单独挂载)
鉴权
类级 @UseGuards(AdminTokenGuard)Authorization: Bearer <ADMIN_TOKEN>
操作人
x-admin-user 头,写进 change_logs.actor
请求体校验
zod schema + ZodExceptionFilter → 400
分页
?page=1&size=50,响应 { items, total, page, size }
时间范围
?from=&to=(毫秒时间戳)或 ?days=7
错误响应
{ statusCode, message, error };消息经 safeMessage() 净化
写操作副作用
落库 → ChangeLogService.record() → 刷新内存态 → SessionManager.broadcastListChanged()

上游管理 upstream.controller.ts

方法路径说明
GET/admin/api/upstreams列表,含解密后不含凭据的安全视图 + 实时健康状态
POST/admin/api/upstreams创建。先真实试连并拉工具列表,失败返回 502 且不落库;名称过 NAME_RE;URL 过 SSRF;凭据 AES 加密;描述过 SUSPICIOUS_DESC 注入检测
PUT/admin/api/upstreams/:name更新(含凭据轮换);写 change_logs(before/after 脱敏)
DEL/admin/api/upstreams/:name删除:断开连接 → 移除注册表条目 → bump() → 删库
POST/admin/api/upstreams/:name/probe即时探活,返回 { ok, latencyMs, toolCount, error }
POST/admin/api/upstreams/:name/refresh强制刷新工具列表,返回新增/移除/变更的工具 diff

工具目录 tool.controller.ts

方法路径说明
GET/admin/api/tools 工具列表,支持 ?q=(全名/描述模糊)、?upstream=?overridden=1(只看有覆写的)
GET/admin/api/tools/:fullName 详情,同时返回 rawDescription(原值)与当前生效描述,便于对比与还原
PUT/admin/api/tools/:fullName/description覆写描述
PUT/admin/api/tools/:fullName/enabled单独上/下线该工具
PUT/admin/api/tools/:fullName/ratelimit设置工具级 rate_qpm

权限矩阵 permission.controller.ts(327 行,最重的控制器)

方法路径说明
GET/admin/api/roles角色列表,附带每个角色的规则数与绑定 Key 数
POST/admin/api/roles新建角色(is_builtin = 0
DEL/admin/api/roles/:name 删除。两条硬校验:内置角色不可删仍有未吊销 Key 绑定时不可删(409)
GET/admin/api/roles/:name/rules读取有序规则集
PUT/admin/api/roles/:name/rules 整体替换规则集。支持 ?dryRun=1只算不落库,返回与正式写入相同的 diff 结果。 写入时 normalizeSort() 自动把 deny 组排到 allow 组之前;每条 pattern 过 ReDoS 校验
POST/admin/api/roles/:name/rules/rollback F2:从 change_logs.before_json 恢复上一版规则,并再记一条 action='rollback'
POST/admin/api/permissions/preview F3:影响面预览,返回 PreviewResult(12 个字段,见 09 鉴权与 RBAC
GET/admin/api/permissions/matrix 角色 × 工具(或 × 上游)的权限矩阵,供前端网格视图渲染

API Key key.controller.ts

方法路径说明
POST/admin/api/keys 签发。响应里含唯一一次明文;入参:name / userName / team / role / profile / expiresAt
GET/admin/api/keys 列表,支持 ?user= ?role= ?revoked=0|1;只返回 key_prefixkey_hash,永不返回明文
POST/admin/api/keys/:id/revoke F4 吊销。reason 枚举 leak/offboard/rotate/other;写吊销黑名单缓存,≤2s 全链路生效
POST/admin/api/keys/:id/rotate 轮换。graceMinutes 内新旧 Key 并存,宽限期到后自动吊销旧 Key;返回新 Key 明文

审计与变更 audit.controller.ts

方法路径说明
GET/admin/api/audit 13 个过滤参数user · tool · upstream · role · sensitive · ok · keyword · from · to · event · errorCode · page · size
GET/admin/api/audit/:id 单条详情,含 argsFull(若为敏感工具)与未净化的 errorMsg 原始堆栈
GET/admin/api/audit/export CSV 导出,接受与列表相同的过滤参数。上限 10 万行,响应带 UTF-8 BOM \uFEFF(否则 Excel 打开中文乱码)
GET/admin/api/audit/changes 变更记录查询。过滤参数用 snake_case?target_type=role_rules&actor=
GOTCHA

/admin/api/audit/changes 的查询参数是 target_type(下划线), 与其它端点的 camelCase 风格不一致——因为该参数名直接对应数据库列名。 前端 api/index.ts 已做映射,直接调 API 时需注意。

统计 stats.controller.ts

方法路径返回
GET/admin/api/stats/overview?days=7 totalCalls · totalFails · sensitiveCount · topTools · byUpstream · failTop · topUsers · p95ByUpstream · rbacMode · wouldForbidCount · wouldForbidTop
GET/admin/api/stats/timeseries?days=7&bucket=hour 时间序列,bucket 支持 hour | day无数据的桶会补齐零值, 前端画图不需要额外处理断点

P95 的算法值得一提——SQLite 没有 PERCENTILE_CONT,实现用的是有序偏移取值

-- 先数总量
SELECT COUNT(*) AS n FROM audit_logs
 WHERE upstream = ? AND ts BETWEEN ? AND ? AND duration_ms IS NOT NULL;

-- 再取第 floor(n * 0.95) 条(已按耗时升序)
SELECT duration_ms FROM audit_logs
 WHERE upstream = ? AND ts BETWEEN ? AND ? AND duration_ms IS NOT NULL
 ORDER BY duration_ms
 LIMIT 1 OFFSET ?;   -- ? = floor(n * 0.95)

两次查询都命中 idx_audit_up_ts;相比把全部耗时拉进内存排序, 这让 P95 的内存占用与数据量无关。

熔断 breaker.controller.ts

方法路径说明
GET/admin/api/breaker/status 所有上游的实时熔断态:{ upstream, state, fails, openedAt, retryAt }
GET/admin/api/breaker/events 历史跃迁记录,支持 ?upstream=&event=&from=&to=&page=&size=
GET/admin/api/breaker/summary?days=7 按上游聚合:熔断次数、累计 open 时长、最近一次跃迁
SSE/admin/api/breaker/events/stream?token=… P1-F 实时推流。@Sse() 装饰器 + merge(breaker$, interval(25_000).pipe(map(() => ({ type: 'ping' })))); 因 EventSource 无法带 Header,此路径用 query 传 token

备份 backup.controller.ts

方法路径说明
GET/admin/api/backups备份记录列表(分页)
GET/admin/api/backups/summary 汇总:总份数、总字节、最近成功时间、连续失败次数、下次 cron 触发时间
POST/admin/api/backups/run 立即执行一次(trigger='manual'actorx-admin-user
GET/admin/api/backups/:id/download 以附件流下载 .db 文件

系统 system.controller.ts

方法路径鉴权说明
GET/healthz 健康探针,8 个字段(见 18 可观测性)。供 K8s liveness/readiness
GET/metrics JSON 格式指标,供前端仪表盘直接消费
GET/metrics.prom Prometheus text format 0.0.4(P1-B
GET/admin/api/system/registryToken 注册表状态:版本号、工具总数、按上游分组的条目数、各上游连接态
POST/admin/api/system/registry/refreshToken 强制全量刷新所有上游的工具列表
GET/admin/api/settingsToken 读取全部 settings(合并默认值)
PUT/admin/api/settingsToken 批量更新。未知 key 直接 400SETTINGS_WHITELIST 校验), 防止拼写错误静默生成一条无效配置;成功则热生效并写 change_logs
SEC

三个探针端点不鉴权是刻意设计(K8s / Prometheus 抓取器通常不便携带凭据), 代价是它们暴露了运行态信息。生产部署必须用网络层手段限制访问: Nginx location /metrics { allow 10.0.0.0/8; deny all; }, 或 K8s NetworkPolicy 只放行监控命名空间。

15

前端架构

Vue3 + Vite + UnoCSS + Pinia,无 UI 组件库,10 个管理页 + 1 个登录页。

应用装配

// frontend/src/main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router';
import 'virtual:uno.css';        // UnoCSS 生成的原子类
import './styles/base.css';     // 全局观感(背景光晕/滚动条/字体)

createApp(App).use(createPinia()).use(router).mount('#app');

App.vue 只做三件事:挂载 .mgw-backdrop 背景层、 包裹 <router-view><transition name="fade" mode="out-in">、 渲染全局 <ToastHost />子组件不得自行定义全局背景色

路由

const routes: RouteRecordRaw[] = [
  { path: '/login', name: 'login', component: () => import('../views/Login.vue'),
    meta: { public: true } },
  { path: '/', component: () => import('../layouts/AdminLayout.vue'), children: [
      { path: '', redirect: '/dashboard' },
      { path: 'dashboard',   name: 'dashboard',   component: () => import('../views/Dashboard.vue'),
        meta: { title: '仪表盘',     icon: 'gauge'  } },
      // … 其余 9 页 …
  ]},
  { path: '/:pathMatch(.*)*', redirect: '/dashboard' },
];

router.beforeEach((to) => {
  const auth = useAuthStore();
  if (to.meta.public) {
    if (to.name === 'login' && auth.authenticated) return { name: 'dashboard' };
    return true;
  }
  if (!auth.authenticated) return { name: 'login', query: { redirect: to.fullPath } };
  return true;
});

/** 侧边栏导航项从路由表派生,避免两处维护 */
export const navItems = routes[1].children!
  .filter((r) => r.meta?.title)
  .map((r) => ({ name: r.name, path: `/${r.path}`, title: r.meta!.title, icon: r.meta!.icon }));
  • hash 模式createWebHashHistory):静态托管无需 rewrite 规则即可直达子路由, dist/ 丢到任意 Nginx / OSS / GitHub Pages 都能跑。
  • 全部视图懒加载:首屏只下载 Login + AdminLayout + Dashboard 三个 chunk。
  • navItems 单一来源:侧边栏直接从路由表派生,加一个页面只需改路由表一处
  • 登录重定向:未登录访问受保护页时把原路径塞进 ?redirect=,登录后跳回。

布局 AdminLayout

侧栏
展开 248px / 折叠 76px(只留图标),折叠态存 localStorage
顶栏
h-16,含面包屑、全局搜索入口、健康徽章、用户菜单
健康徽章
8s 轮询 GET /healthz, 用 chip-ok / chip-err 显示 status 与上游连通数; 请求失败时徽章变红,是「后端挂了」最直观的视觉信号
内容区
<router-view>transition name="fade" mode="out-in", 页面切换有 150ms 淡入淡出
滚动
body { overflow: hidden },滚动只发生在 main 容器内, 保证侧栏与顶栏始终固定

API 客户端

frontend/src/api/client.ts(159 行)是一层薄封装,刻意不引 axios:

const TOKEN_KEY = 'mgw_admin_token';
const USER_KEY  = 'mgw_admin_user';

export class ApiError extends Error {
  constructor(public status: number, message: string, public body?: unknown) { super(message); }
}

export const tokenStore = {
  get token() { return localStorage.getItem(TOKEN_KEY) || ''; },
  get user()  { return localStorage.getItem(USER_KEY)  || ''; },
  set(token: string, user: string) { /* 写两个键 */ },
  clear() { /* 删两个键 */ },
};

let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: () => void) { onUnauthorized = fn; }

async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
  const res = await fetch(`/admin/api${path}`, {
    method,
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${tokenStore.token}`,
      'x-admin-user': tokenStore.user,
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (res.status === 401) { tokenStore.clear(); onUnauthorized?.(); }
  if (!res.ok) throw new ApiError(res.status, (await res.json().catch(() => ({}))).message || res.statusText);
  return res.status === 204 ? (undefined as T) : res.json();
}

export const http = {
  get:  <T>(p: string)          => request<T>('GET', p),
  post: <T>(p: string, b?: any) => request<T>('POST', p, b),
  put:  <T>(p: string, b?: any) => request<T>('PUT', p, b),
  del:  <T>(p: string)          => request<T>('DELETE', p),
};

/** CSV 导出:blob + a.download,避免浏览器直接打开乱码文本 */
export async function downloadCsv(path: string, filename: string) { … }

frontend/src/api/index.ts(390 行)集中定义全部 TypeScript 接口 (Upstream / ToolItem / RoleItem / RuleItem / PreviewResult / KeyItem / IssuedKey / AuditItem / StatsOverview / TimeseriesPoint / Health / RegistryStatus / Settings / BreakerStatus / BackupItem …), 并按业务域组织成一个 api 对象:

export const api = {
  upstreams:   { list, create, update, remove, probe, refresh },
  tools:       { list, detail, setDescription, setEnabled, setRatelimit },
  permissions: { roles, createRole, deleteRole, rules, saveRules, rollback, preview, matrix },
  keys:        { issue, list, revoke, rotate },
  audit:       { list, detail, exportCsv, changes },
  stats:       { overview, timeseries },
  system:      { health, metrics, registry, registryRefresh, settings, saveSettings },
  breaker:     { status, events, summary, streamUrl },
  backup:      { list, summary, run, downloadUrl },
};

页面只调 api.xxx.yyy()不直接拼 URL。这让接口变更时只需改一处, 也让 vue-tsc 能在构建期发现字段不匹配。

10 个管理页职责

路由视图标题 / 图标核心内容
/dashboardDashboard.vue仪表盘 · gauge 总调用/失败/敏感调用数字卡、按上游分布、Top 工具、Top 用户、失败 Top、 P95 延时、时序折线(纯 SVG 手绘,无图表库)、RBAC 模式徽章与 wouldForbid 提示
/upstreamsUpstreams.vue上游服务 · server 上游列表(健康状态徽章 / 工具数 / 延时)、新建与编辑弹窗(按 transport 切换字段)、 探活、强制刷新、启停、删除
/toolsTools.vue工具目录 · tool 全量工具表(全名 / 上游 / 描述 / 是否被覆写 / 是否启用)、搜索与上游过滤、 描述覆写编辑(并排显示原描述)、单独上下线、工具级限流设置
/permissionsPermissions.vue权限矩阵 · shield 角色 CRUD、有序规则编辑器(拖拽排序 / dryRun 预览 / 一键回滚)、 影响面预览面板(gained / lost / 受影响 Key / 近 7 日调用量)、角色 × 工具矩阵网格
/keysKeys.vueAPI Key · key Key 列表(前缀 / 用户 / 团队 / 角色 / 过期 / 最近使用)、签发弹窗、 明文一次性展示弹窗、吊销(带 reason 选择)、轮换(带宽限期)
/auditAudit.vue审计日志 · list 13 维过滤 + 分页表格、详情抽屉(参数摘要/全量、耗时、排队时长、错误码、原始堆栈)、CSV 导出
/changesChanges.vue变更记录 · history 按 targetType / actor / 时间过滤,展示 diffSummary 与可展开的 before/after JSON 对比
/reportsReports.vue数据报表 · chart 更长周期的趋势分析:按天/小时聚合、上游健康度排名、敏感调用占比、错误码分布
/breakerBreaker.vue熔断事件 · shield 实时状态卡片(三态徽章 + 距重试时间)、SSE 实时事件流(新事件自动置顶)、 历史事件表、按上游汇总
/backupsBackups.vue数据库备份 · box 备份列表(大小/耗时/触发方式/状态)、汇总卡、立即备份按钮、下载、cron 配置入口

自研组件(6 件)

Modal.vue
teleport to="body" + glass-card + .mask 过渡; role="dialog" aria-modal="true"Esc 关闭;内部 scrollbar-thin
ToastHost.vue
全局提示栈,配 useToast() composable;success / error / warn / info 四态,自动消失
Icon.vue
内联 SVG 图标集,按 name prop 取;不用图标字体,避免额外请求与 FOUT
Spinner.vue
纯 CSS 旋转环,用于加载态
Empty.vue
空状态占位(图标 + 文案 + 可选操作按钮)
PageHeader.vue
统一页头:标题 + 描述 + 右侧操作槽,保证 10 个页面的头部节奏一致

状态管理

只有一个 Pinia store —— stores/auth.ts

export const useAuthStore = defineStore('auth', () => {
  const token = ref(tokenStore.token);
  const user  = ref(tokenStore.user);
  const authenticated = computed(() => !!token.value);

  function login(t: string, u: string) { tokenStore.set(t, u); token.value = t; user.value = u; }
  function logout() { tokenStore.clear(); token.value = ''; user.value = ''; }

  return { token, user, authenticated, login, logout };
});

其余页面数据都是局部状态ref / reactive),不进全局 store。 理由是这些数据没有跨页共享需求,放全局只会增加失效同步的复杂度。 main.ts 里调 setUnauthorizedHandler(() => { auth.logout(); router.push('/login'); }) 把 401 处理接到 store 上,形成闭环。

Vite 配置要点

// frontend/vite.config.ts
export default defineConfig({
  plugins: [vue(), UnoCSS()],
  server: {
    port: 5173,
    proxy: {
      '/admin/api': { target: 'http://127.0.0.1:8080', changeOrigin: true },
      '/mcp':       { target: 'http://127.0.0.1:8080', changeOrigin: true },
      '/healthz':   { target: 'http://127.0.0.1:8080', changeOrigin: true },
    },
  },
  build: { outDir: 'dist', sourcemap: false },
});

开发时前端直接请求同源相对路径,由 Vite 代理转发,不存在 CORS 问题。 生产部署时把 dist/ 交给 Nginx,同样反代这三个前缀到后端即可—— 前后端的相对路径约定在开发与生产环境完全一致,这是选择代理而非配置 baseURL 的主要原因。

16

设计体系:黑白毛玻璃

整套视觉语言沉淀在 frontend/uno.config.ts 的 shortcuts 里,改主题只动这一处。

四条设计原则

底 · Background
近黑 #08080a + 两处极低透明度白色径向光晕 + 48px 网格, 营造纵深而非死黑。纯黑背景会让玻璃面失去可对比的参照物,毛玻璃效果就不成立了
面 · Surface
半透明白 white/4~8 + backdrop-blur-xl + 1px white/10 描边 + 顶部 1px 渐变高光 → 磨砂玻璃的厚度感
字 · Typography
白/90 主 · 白/70 次 · 白/45 弱 · 白/28 极弱,纯单色阶,不用彩色文字。 层级靠透明度而非色相区分,这是"黑白"约束的核心
强调 · Accent
纯白是唯一"主色"。主按钮 = 白底黑字,与半透明玻璃面形成最强对比。 语义色(emerald / red / amber)用于状态徽章

墨色色板

// frontend/uno.config.ts
theme: {
  colors: {
    ink: {
      900: '#08080a',   // 页面底
      800: '#0d0d10',   // 深面板
      700: '#141418',   // 卡片
      600: '#1c1c22',   // 悬浮/激活
    },
  },
},

实际使用中直接引用 ink-* 的地方很少——绝大多数表面都是 white/X 半透明叠加,这样背景光晕才能透过来,形成真正的"玻璃"。 ink-* 主要用于需要不透明底的场景(如输入框聚焦态 bg-black/35)。

shortcuts 全清单

类名展开
glass bg-white/[0.045] backdrop-blur-xl border border-white/10 rounded-2xl shadow-[0_8px_32px_rgba(0,0,0,0.45)]
glass-panel bg-white/[0.03] backdrop-blur-2xl border border-white/[0.08](更浅,用于大面积容器)
glass-inset bg-black/20 border border-white/[0.06] rounded-xl(内嵌区块,视觉上"凹下去")
glass-sheen before:content-empty before:absolute before:inset-x-0 before:top-0 before:h-px before:bg-gradient-to-r before:from-transparent before:via-white/25 before:to-transparent
glass-cardglass glass-sheen relative overflow-hidden(卡片的标准写法
glass-hoverhover:bg-white/[0.07] hover:border-white/[0.16] transition(叠加在 glass-card 上)
text-title / text-body
text-muted / text-faint
text-white/90 · text-white/70 · text-white/45 · text-white/28
btn inline-flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-lg text-[13px] font-medium transition select-none disabled:opacity-40 disabled:pointer-events-none
btn-primary btn bg-white text-black hover:bg-white/85 active:scale-[0.98] shadow-[0_2px_12px_rgba(255,255,255,0.15)]
btn-ghostbtn bg-white/[0.04] border border-white/10 text-white/80 hover:bg-white/[0.08]
btn-dangerbtn bg-red-500/12 border border-red-500/30 text-red-300 hover:bg-red-500/20
btn-sm / btn-icon尺寸变体(更小的 padding / 正方形)
seg-group / seg-item
seg-item-active
分段控制器(如时间范围切换 hour/day),激活态白底黑字
input / input-sm w-full px-3 py-2 rounded-lg bg-black/25 border border-white/10 text-white/85 placeholder-white/25 focus:border-white/35 focus:bg-black/35 outline-none transition
label / select表单标签与下拉,与 input 同一视觉族
th / td / tr-hover 表头(小号大写灰字)· 单元格(py-2.5 px-3 border-b border-white/[0.06])· 行悬停 hover:bg-white/[0.035]
chipinline-flex items-center px-2 py-0.5 rounded-full text-[11px] border(基础徽章)
chip-on / chip-off白底黑字(启用)/ 半透明灰(停用)
chip-ok / chip-err / chip-warn emerald / red / amber 三套语义色,各含 bg-*/12 + border-*/28 + text-*/70
nav-item / nav-item-active 侧栏项;激活态带 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] 内发光 + bg-white/[0.07]
page-title / stat-num 页标题(text-[19px] font-semibold)· 统计大数字(text-[26px] font-semibold tabular-nums)
scrollbar-thin细滚动条(8px,thumb white/12)
RULE

页面只写语义类名glass-cardbtn-primarychip-ok), 不写具体的透明度与阴影值。这样"改主题"就等于"改 shortcuts",一次生效全站。 本文档站的样式就是按同样思路重构的:所有观感收敛到 CSS 变量, 明暗切换只换一套变量值。

base.css 承担的部分

原子类表达不了的全局观感放在 frontend/src/styles/base.css(119 行):

:root {
  color-scheme: dark;
  --mgw-bg: #08080a;
  --mgw-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
              'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
  --mgw-mono: 'SF Mono', 'JetBrains Mono', 'Fira Code', Menlo, Consolas, 'Liberation Mono', monospace;
}

/* 三层径向光晕 */
.mgw-backdrop {
  position: fixed; inset: 0; z-index: -1; pointer-events: none;
  background:
    radial-gradient(60rem 40rem at 12%  -8%, rgba(255,255,255,.055), transparent 60%),
    radial-gradient(50rem 40rem at 108% 12%, rgba(255,255,255,.035), transparent 60%),
    radial-gradient(40rem 30rem at 50% 120%, rgba(255,255,255,.028), transparent 60%);
}
/* 48px 网格 + 径向渐隐遮罩 */
.mgw-backdrop::after {
  content: ''; position: absolute; inset: 0;
  background-image:
    linear-gradient(rgba(255,255,255,.028) 1px, transparent 1px),
    linear-gradient(90deg, rgba(255,255,255,.028) 1px, transparent 1px);
  background-size: 48px 48px;
  mask-image: radial-gradient(ellipse 90% 70% at 50% 30%, #000 20%, transparent 78%);
}

body { overflow: hidden; }               /* 滚动交给布局内部容器 */
.mono { font-family: var(--mgw-mono); }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,.12); border-radius: 8px; }
::selection { background: #fff; color: #000; }
:focus-visible { outline: 1px solid rgba(255,255,255,.45); outline-offset: 2px; }
.fade-enter-active, .fade-leave-active { transition: opacity .15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.mask-enter-active, .mask-leave-active { transition: opacity .2s ease; }
.mask-enter-from, .mask-leave-to { opacity: 0; }

三个光晕的位置是刻意错开的(左上 / 右中 / 下中), 配合 mask-image 的径向渐隐,让网格只在视觉中心可见、边缘自然消融—— 否则满屏网格会显得很"技术演示"而非"产品"。

本文档站的亮色主题

项目本身只实现了暗色color-scheme: dark + UnoCSS dark: 'class' 预设, 未提供亮色切换)。本文档站在保留同一套设计语言的前提下新增了亮色主题, 实现方式是把所有观感收敛到 CSS 自定义属性:

令牌暗色亮色
--bg#08080a#f2f2f4
--glass-bgrgba(255,255,255,.045)rgba(255,255,255,.62)
--glass-borderrgba(255,255,255,.10)rgba(0,0,0,.09)
--text-titlergba(255,255,255,.92)rgba(0,0,0,.90)
--text-bodyrgba(255,255,255,.72)rgba(0,0,0,.68)
--accent#ffffff(主按钮白底黑字)#0a0a0c(主按钮黑底白字)
--inset-bgrgba(0,0,0,.32)rgba(0,0,0,.035)
--grid-linergba(255,255,255,.028)rgba(0,0,0,.035)

切换逻辑(tech/docs/assets/app.js): 读写 localStorage['mgw_docs_theme'],无记录时跟随 prefers-color-scheme,并监听系统主题变化; 同时更新 <meta name="theme-color"> 以适配移动端浏览器地址栏。

NOTE

亮色下毛玻璃的关键调整:玻璃面的不透明度要显著提高.045.62)。原因是亮背景上低透明度的白色叠加几乎不可见, 必须靠更高的白度 + 更明显的阴影才能维持"面"的层次感。 backdrop-blur 保持不变,磨砂质感依然存在。

17

配置参考

三级优先级、20 个环境变量、13 项运行时设置。所有默认值均取自源码。

三级优先级

最高进程环境变量MGW_* / ADMIN_TOKEN
中间config/gateway.json路径由 MGW_CONFIG 指定
兜底代码内置默认值ConfigService 构造函数
// backend/src/config/config.service.ts
const str  = (env, jsonVal, def: string)  => env ?? (jsonVal != null ? String(jsonVal) : def);
const num  = (env, jsonVal, def: number)  => Number(env ?? jsonVal ?? def);
const list = (env, jsonVal, def: string[]) => {
  if (env != null && env.trim() !== '') return env.split(',').map(s => s.trim()).filter(Boolean);
  if (Array.isArray(jsonVal))           return jsonVal.map(s => String(s).trim()).filter(Boolean);
  return def;
};

// gateway.json 不存在或解析失败 → 返回 {},只打一条 warn,绝不阻断启动
function readJsonConfig(): Record<string, any> { … }

刻意不引第三方配置库(no config / no cosmiconfig / no dotenv): 合并逻辑只有 20 行,确定性远高于任何黑盒库,也不会引入额外的启动开销与安全面。 列表型配置统一用逗号分隔。

环境变量全表

监听

变量默认值说明
MGW_PORT8080HTTP 监听端口
MGW_HOST127.0.0.1 绑定地址。默认只监听回环,对外服务需显式改为 0.0.0.0, 此时必须同时配好防火墙与反代
MGW_PUBLIC_ORIGINhttp://127.0.0.1:8080 对外基址,用于拼接 WWW-Authenticate 里的 OAuth 保护资源元数据 URL
MGW_ALLOWED_ORIGINS空数组 允许的浏览器 Origin,逗号分隔,防 DNS rebinding。留空 = 不校验非浏览器客户端.env.example 里预置了 http://localhost:5173,http://127.0.0.1:5173

数据

变量默认值说明
MGW_DATA_DIR./data SQLite 与备份目录,相对 backend/ 或绝对路径。 启动时自动 mkdirSync(recursive) 创建,并额外创建 backup/ 子目录
MGW_CONFIG./config/gateway.json中间层配置文件路径
MGW_DB_SYNCHRONIZE1 实体自动建表。MVP 零迁移上手;生产建议置 0 并改用 migrations, 否则实体字段变更会直接 ALTER 生产库

安全(生产必填)

变量默认值说明
MGW_SECRET开发兜底 AES-256-GCM 主密钥,推荐 64 位 hex(openssl rand -hex 32)。 变更此值将导致历史加密的上游凭据无法解密,需全部重新录入
MGW_SECRET_FILE 从文件读取主密钥,优先级高于 MGW_SECRET。用于 Docker/K8s secret 挂载
ADMIN_TOKENmgw_admin_dev_token 管理后台令牌。未设置时回落到开发默认值,禁止用于生产
ADMIN_TOKEN_FILE同上,文件形式,优先级更高
MGW_OUTBOUND_ALLOWED_CIDRS 10.0.0.0/8,
127.0.0.1/32,
192.168.0.0/16
出站 SSRF 白名单,逗号分隔。169.254.0.0/16127.0.0.0/8 永久拒绝,无需也无法通过此变量放开
CRIT

MGW_SECRET 未设置时,代码使用固定兜底值 'a'.repeat(64) 并向 stderr 打一条 warn:
{"level":"warn","msg":"MGW_SECRET 未设置,使用开发兜底主密钥,禁止用于生产"}
这意味着不配密钥时"加密"形同虚设——任何人拿到 db 文件都能解密全部上游凭据。 生产部署检查清单的第一条就应该是它。

会话 / 上游 / 审计 / 告警

变量默认值说明
MGW_MAX_SESSIONS300并发会话上限,超出拒绝新 initialize 并 IM 告警
MGW_SESSION_IDLE_TIMEOUT_S1800会话空闲回收阈值(秒)
MGW_STDIO_MAX8stdio 上游子进程总量上限
MGW_REGISTRY_REFRESH_INTERVAL_S300注册表定时刷新间隔(秒)
MGW_AUDIT_FLUSH_MS1000审计攒批的时间触发阈值
MGW_AUDIT_FLUSH_SIZE100审计攒批的条数触发阈值
MGW_IM_WEBHOOK IM 告警 Webhook(企业微信 / 飞书 / Slack 通用 markdown 格式)。留空 = 不推送, 此时 ImService 只写结构化日志
NODE_ENVdevelopment development | production | test

gateway.json 示例

backend/config/gateway.json 适合放「不敏感、不常变、想随代码库版本管理」的配置:

{
  "port": 8080,
  "host": "127.0.0.1",
  "publicOrigin": "http://127.0.0.1:8080",
  "allowedOrigins": ["http://localhost:5173", "http://127.0.0.1:5173"],
  "outboundAllowedCidrs": ["10.0.0.0/8", "192.168.0.0/16"],
  "maxSessions": 300,
  "sessionIdleTimeoutS": 1800,
  "stdioMax": 8,
  "registryRefreshIntervalS": 300,
  "auditFlushMs": 1000,
  "auditFlushSize": 100,
  "dataDir": "./data"
}
RULE

绝不要把 secret / adminToken 写进 gateway.json—— 它在版本库里。这两个值只能走环境变量或 *_FILEConfigService 也确实没有从 json 读取这两项的分支, 只有 readFileSecret(process.env.X, 'X_FILE') 一条路径。

运行时设置(后台可改,热生效)

与「启动时确定、改了要重启」的环境变量不同,settings 表里的 13 项 可以在后台直接改并立即生效。划分标准是:运维日常需要调的用 settings, 部署时确定的用环境变量。

环境变量(重启生效)
端口 / 绑定地址 / 数据目录 / 主密钥 / 管理令牌 / SSRF 白名单 / Origin 白名单 / 会话与子进程上限 / 审计攒批参数 / IM Webhook
settings(热生效)
限流默认 QPM 与 burst / 上游默认超时与并发 / 审计开关与全量阈值 / 注册表刷新间隔 / 会话空闲阈值 / RBAC 模式 / 备份开关·cron·时区·保留份数

完整的 13 项默认值见 12 数据模型 · settings。 修改入口:PUT /admin/api/settings 或后台「系统设置」页, 受 SETTINGS_WHITELIST 约束,每次修改都写 change_logs

NOTE

session.idle_timeout_sregistry.refresh_interval_s 同时存在于 settings 与环境变量两处。读取顺序是 settings 优先(运行期可覆盖),环境变量作为 settings 缺省时的初值来源。 若发现改了环境变量没生效,先检查 settings 表里是否已有值。

三种 env 喂入方式

后端不内置 dotenv,直接读进程环境变量。因此需要外部机制把 .env 喂进去:

场景做法
仓库 start.sh ./start.sh 内部执行 set -a; source backend/.env; set +a, 在当前 shell 加载后自动 export 给子进程。本地开发最省事的方式
手动本地 cp .env.example .env && set -a && source .env && set +a && npm run start:dev
systemd 服务单元里写 EnvironmentFile=/etc/mcp-gateway.env—— 直接兼容 .env.example 的格式,无需转换
Docker / K8s docker run --env-file .env,或 compose 的 env_file:; 敏感项建议改用 secret 挂载 + MGW_SECRET_FILE / ADMIN_TOKEN_FILE
18

可观测性

三个探针端点、11 个自定义指标、结构化日志、事件循环监控与 IM 告警。

GET /healthz

不鉴权,供 K8s liveness / readiness 与负载均衡健康检查使用。返回 8 个字段:

{
  "status": "ok",                  // ok | degraded | down
  "uptime_s": 12843,               // 进程运行秒数
  "version": "2.0.0",
  "upstreams": { "total": 4, "up": 3, "down": 1 },
  "registry": { "tools": 87, "version": 23 },
  "sessions": 12,
  "audit_queue": 0,                // 持续 > 0 说明写入跟不上
  "el_p99_ms": 3.2                 // 事件循环延迟 p99
}

status 的判定逻辑:全部上游 up → ok;部分 down → degraded; 全部 down → down即使 degraded 也应返回 HTTP 200—— 网关自身是健康的,只是某些上游不可用,把它从负载均衡摘掉会扩大故障面。

GET /metrics.prom · 11 个自定义指标

使用独立的 prom-client Registry(非全局默认),避免与其它库的指标串味; 全部指标带 mgw_ 前缀。

指标名类型标签含义
mgw_tool_calls_totalCounterupstream, status 工具调用总数,status 为 ok 或具体错误码
mgw_tool_call_duration_msHistogramupstream 工具调用耗时。buckets [50,100,250,500,1000,2500,5000,10000,20000,30000]
mgw_upstream_breaker_stateGaugeupstream 熔断态:0=closed · 1=half-open · 2=open。 数值化设计让 PromQL 可以直接 max() 与告警比较
mgw_registry_tools_totalGauge注册表工具总数
mgw_registry_versionGauge 注册表版本戳。changes(mgw_registry_version[10m]) 可观察抖动频率
mgw_sessions_activeGauge活跃会话数
mgw_upstreams_connectedGauge已连接上游数
mgw_audit_queue_depthGauge审计待写队列深度,持续 > 0 是写入瓶颈的信号
mgw_ratelimit_bucketsGauge令牌桶数量,反映 Key × 工具的组合基数
mgw_http_requests_totalCountermethod, route, status P1-E HTTP 入口 QPS。status 归类为 2xx/4xx/5xx
mgw_http_request_duration_msHistogrammethod, route P1-E HTTP 入口延时。buckets [1,5,10,25,50,100,250,500,1000,2500,5000,10000,20000,30000]

此外还调用了 collectDefaultMetrics({ register, prefix: 'mgw_', eventLoopMonitoringPrecision: 10 }), 自动带上 Node 运行时指标(GC、堆内存、事件循环延迟等),同样加 mgw_ 前缀。

HTTP 埋点中间件

governance/metrics.middleware.ts(71 行)有三个关键处理:

/** 1) 排除探针端点,避免自我观测污染业务指标 */
function isExcluded(path: string): boolean {
  return path === '/metrics' || path === '/metrics.prom' || path === '/healthz'
      || path.startsWith('/metrics/');
}

/** 2) 路由归一化:用 baseUrl + route.path,而非原始 URL */
function normalizeRoute(req): string {
  // '/admin/api/keys/42/revoke' → '/admin/api/keys/:id/revoke'
  return req.baseUrl + (req.route?.path ?? '') || '__unmatched__';
}

/** 3) status 归类到百位,控制标签基数 */
const statusLabel = `${Math.floor(res.statusCode / 100) * 100}`;   // '200' / '404' / '500'

// 4) SSE 长连接跳过延时埋点
if (res.getHeader('content-type')?.includes('text/event-stream')) return;
CRIT

标签基数(cardinality)是 Prometheus 的头号杀手。这两个处理是必须的:

若直接用 req.originalUrl 作为 route 标签, /admin/api/keys/1/revoke/admin/api/keys/2/revoke … 会各自成为一个独立时间序列,几万把 Key 就是几万个序列,Prometheus 内存直接爆掉。 req.route.path 拿到的是 Express 匹配到的模式串/keys/:id/revoke), 基数恒定。

同理,status 归类到百位而非精确值,把 40+ 种可能压到 5 种。 未匹配任何路由的请求统一记为 __unmatched__, 避免扫描器探测 /wp-admin 之类的随机路径制造海量序列。

Gauge 的取值策略:不用定时器周期更新,而是在每次 Prometheus 抓取前调 refreshRuntime() 现算。这样指标永远是抓取瞬间的真实值, 也不会因为定时器与抓取周期不同步而出现锯齿。

GET /metrics(JSON)

/metrics.prom 同源数据,但组织成前端友好的 JSON 结构, 供仪表盘直接消费,不需要前端实现 Prometheus 文本解析。 这是一个刻意的双出口设计:机器读 text format,人(前端)读 JSON。

F7 · 事件循环延迟监控

common/event-loop-monitor.ts(97 行)用 Node 内置的 perf_hooks.monitorEventLoopDelay

const RESOLUTION_MS      = 20;         // 采样精度
const SAMPLE_INTERVAL_MS = 5_000;       // 每 5s 读一次
const ALERT_P99_MS       = 200;         // p99 超过 200ms 告警
const ALERT_COOLDOWN_MS  = 5 * 60_000;  // 5 分钟冷却,防告警风暴

const history = monitorEventLoopDelay({ resolution: RESOLUTION_MS });
history.enable();

setInterval(() => {
  const p99 = history.percentile(99) / 1e6;      // ns → ms
  const p50 = history.percentile(50) / 1e6;
  healthReporter.setEventLoop({ p50, p99 });     // 供 /healthz 的 el_p99_ms
  history.reset();                               // ★ 必须重置,否则被历史数据钝化

  if (p99 > ALERT_P99_MS && now - lastAlert > ALERT_COOLDOWN_MS) {
    lastAlert = now;
    im.eventLoopSlow(p99);
    logger.warn({ event: 'event_loop_slow', p99_ms: p99 });
  }
}, SAMPLE_INTERVAL_MS);

为什么这个指标重要:Node 是单线程的,事件循环被阻塞时 所有请求都会一起变慢,但每个请求各自的耗时看起来只是"稍微慢了一点", 很难从业务指标定位。事件循环 p99 是唯一能直接反映"进程整体卡住了"的指标

history.reset() 是容易被漏掉的关键一行:不重置的话, 直方图会累积从启动至今的全部样本,一次 10 分钟的卡顿会被之后几小时的正常数据稀释到看不见—— 监控钝化,告警永远不触发。

导出接口:startEventLoopMonitor() · stopEventLoopMonitor() · getEventLoopPercentiles() · registerEventLoopAlert() · __triggerAlertForTest()(供冒烟脚本验证告警链路)。

结构化日志

common/logger.ts(27 行)刻意极简:单行 JSON,写 stderr

export const logger = {
  info:  (o: object) => write('info', o),
  warn:  (o: object) => write('warn', o),
  error: (o: object) => write('error', o),
  debug: (o: object) => { if (process.env.NODE_ENV !== 'production') write('debug', o); },
};

function write(level: string, o: object): void {
  process.stderr.write(JSON.stringify({
    level, ts: Date.now(), ...o,
  }) + '\n');
}
为什么是 stderr
stdout 在很多部署形态下被用作数据通道(尤其 stdio MCP 场景); 日志走 stderr 是 12-factor 的推荐做法,也便于 2>&1 统一重定向
为什么是单行 JSON
可被 Loki / ELK / CloudWatch 直接结构化解析,无需 grok 规则; 一行一条保证并发写入不会交错
为什么不用 winston/pino
需求只有"打一行 JSON",27 行自研代码零依赖、零配置、零学习成本
格式化脚本
scripts/mgw-fmt.py 把 JSON 日志流转成人类可读的彩色对齐格式, 本地排查时用 ./start.sh 2>&1 | python3 scripts/mgw-fmt.py

错误消息净化

common/safe-message.ts(33 行)在任何错误文本返回给模型之前做净化:

剥离内容为什么必须剥
堆栈(at … 行)暴露文件路径、框架版本、内部函数名
IP:port暴露内网拓扑,为横向移动提供地图
SQL 片段暴露表结构与查询逻辑,辅助注入
绝对路径暴露部署结构与用户名
Bearer xxx直接泄漏凭据——上游 401 响应里常回显请求头
截断 200 字符限制信息量,也防止超长错误撑爆模型上下文

净化后的文本进 audit_logs.error_msg不。 净化文本用于返回给模型error_msg 字段存的是原始错误, 仅在后台审计详情页(管理员已鉴权)可见。两者职责不同,不能混用。

IM 告警

notify/im.service.ts(94 行)向 MGW_IM_WEBHOOK 推送 markdown 消息, 带 5 分钟同类抑制SUPPRESS_MS = 5 * 60_000): 同一告警类型在冷却期内只推一次,避免上游持续故障时把群聊刷爆。

告警方法触发条件
circuitOpened(upstream)熔断从 closed/half-open 跃迁到 open
circuitClosed(upstream)探针成功,从 half-open 恢复 closed
upstreamRecovered(upstream)健康检查发现从 down 恢复 up
sessionLimitReached()活跃会话数达到 MGW_MAX_SESSIONS
auditFallbackGrowing(size)审计写库失败,JSONL 兜底文件在增长
eventLoopSlow(p99)F7 事件循环 p99 > 200ms
stdioCrashLoop(upstream)F9 stdio 上游 5 分钟内崩溃 5 次,已自动禁用
backupFailed(trigger, err)P1-D 备份执行失败
backupPruneFailed(err)P1-D 滚动清理失败(磁盘可能已满)

Webhook 未配置时,全部方法降级为写结构化日志,不抛错—— 告警通道不可用不应该影响主业务流程。

P1-G · Grafana 看板

docs/grafana/ 提供开箱即用的监控栈配置:

prometheus.yml
抓取配置模板,指向 http://127.0.0.1:8080/metrics.prom, 含推荐的 scrape_interval 与 job 命名
gateway-overview.json
Grafana 仪表盘 JSON,可直接 Import。 面板覆盖:调用 QPS 与错误率、按上游的 P95/P99 延时、熔断状态时间线、 活跃会话数、审计队列深度、事件循环 p99、Node 堆内存与 GC
README.md
docker-compose 起 Prometheus + Grafana 的步骤与导入说明

几条实用的 PromQL:

# 工具调用错误率(5 分钟窗口)
sum(rate(mgw_tool_calls_total{status!="ok"}[5m]))
  / sum(rate(mgw_tool_calls_total[5m]))

# 按上游的 P95 延时
histogram_quantile(0.95,
  sum by (le, upstream) (rate(mgw_tool_call_duration_ms_bucket[5m])))

# 当前处于熔断态的上游
mgw_upstream_breaker_state == 2

# 审计写入是否跟不上(持续大于 0 即告警)
mgw_audit_queue_depth > 0

# HTTP 5xx 比例
sum(rate(mgw_http_requests_total{status="500"}[5m]))
  / sum(rate(mgw_http_requests_total[5m]))
19

错误码契约

治理链每一步的失败都对应一个稳定错误码,前端与模型据此行动。

11 个错误码

错误码返回给模型的文本kind产生位置与含义
UNAUTHORIZED未授权 transport 层:Bearer 缺失 / 格式错 / Key 不存在 / 已吊销 / 已过期。以 HTTP 401 返回,不进治理链
FORBIDDEN无权调用该工具client-error GATE 2:enforce 模式下 allowedSet 未命中。不计入熔断
WOULD_FORBID影子模式下放行,实际无权限(仅审计标记)不失败 F1observe 模式下未命中 allow 时照常放行, 仅写 rbac_would_forbid 审计(resultOk=1)。 这个错误码永远不会返回给模型,它只存在于审计与统计里
TOOL_NOT_FOUND工具不存在或已下线client-error GATE 1:注册表无此全名,或 entry.enabled = false(被覆写单独下线)
RATE_LIMITED调用过于频繁,请稍后重试client-error GATE 3:令牌桶空。附带 meta.retryAfterS 指导退避
UPSTREAM_DOWN上游服务暂不可用upstream-error 两处:GATE 1(上游 enabled=0 或记录不存在)与 GATE 4(熔断 open)。 连接建立失败也归此类
UPSTREAM_BUSY上游繁忙,请稍后重试upstream-error GATE 5:并发等待队列已满(> 50)。附带 meta.queueMs
UPSTREAM_TIMEOUT上游响应超时upstream-error GATE 6:AbortController 触发。计入熔断
UPSTREAM_ERROR工具执行失败upstream-error GATE 6:上游返回了错误 / 抛异常。计入熔断
BAD_ARGS参数不合法client-error GATE 6 前置:zod 或上游 schema 校验失败。不计入熔断——参数错是调用方的问题
INTERNAL网关内部错误upstream-error 兜底:任何未被上述分类捕获的异常。出现即意味着代码有 bug,应当告警排查

kind 的意义:错误归因

export class GatewayToolError extends Error {
  constructor(
    public readonly code: ErrorCodeType,
    public readonly safeMessage: string,
    public readonly rawError?: string,
    public readonly kind: 'client-error' | 'upstream-error' = 'upstream-error',
    public readonly meta: Record<string, unknown> = {},
  ) {
    super(`[${code}] ${safeMessage}`);      // ★ 固定格式
  }

  /** 客户端问题不计入熔断 */
  get isClientError(): boolean { return this.kind === 'client-error'; }
}

kind 决定这个失败该怪谁,进而决定是否计入熔断统计:

client-error(不熔断)
FORBIDDEN · RATE_LIMITED · BAD_ARGS · TOOL_NOT_FOUND

调用方的问题。上游完全健康, 如果把这些计入熔断,一个乱传参数的模型就能把整个上游搞挂。
upstream-error(计入熔断)
UPSTREAM_DOWN · UPSTREAM_BUSY · UPSTREAM_TIMEOUT · UPSTREAM_ERROR · INTERNAL

服务侧的问题。连续 5 次即触发熔断,保护上游也保护自己。

isError vs JSON-RPC error

MCP 规范对两类失败有明确区分,网关严格遵守:

失败类型返回形态适用场景
工具业务失败 { result: { isError: true, content: [{ type:'text', text:'[CODE] 描述' }] } } 治理链任何一道的拒绝、上游执行失败、参数不合法。 让模型看到失败原因并有机会调整策略(换工具、改参数、稍后重试)
协议级失败 { error: { code: -32601, message: 'Method not found' } } 方法不存在、JSON-RPC 结构非法、请求体解析失败。 这类错误模型无法通过调整参数解决,属于客户端实现问题
传输级失败 HTTP 状态码 401 / 403 / 404 / 406 + JSON 错误体 鉴权失败、Origin 被拒、会话不存在、Accept 头不合规。发生在 JSON-RPC 解析之前
WHY

把「工具执行失败」包成 JSON-RPC error 是一个常见错误实现。 后果是客户端 SDK 会把它当作协议故障处理,可能直接断开连接或抛出异常, 模型完全看不到失败原因,也就无法自我纠正。 用 isError: true 才能让失败信息进入模型的上下文。

HTTP 状态码对照

状态码场景通道
200正常响应(含 isError: true 的工具失败)MCP
202notification 类消息(无响应体)MCP
400JSON-RPC 结构非法 / zod 校验失败 / settings 未知 key两者
401Key 无效或已吊销 / Admin Token 错误(带 WWW-Authenticate两者
403Origin 不在白名单(DNS rebinding 防护)MCP
404会话不存在或已过期 / Admin 资源未找到两者
406Accept 头缺少 application/jsontext/event-streamMCP
409名称冲突 / 删除仍被引用的角色 / 上游名已存在Admin
429(预留)入口级限流
500未捕获异常两者
502创建上游时试连失败Admin
20

安全须知与生产加固

代码里已经做了的防护,以及部署时必须由你补上的部分。

代码内建的防护

威胁防护机制实现位置
凭据明文存储API Key 只存 SHA-256;上游凭据 AES-256-GCM 加密auth/crypto.service.ts
令牌时序侧信道crypto.timingSafeEqual 常量时间比较admin/guards/admin-token.guard.ts
SSRF / 云元数据泄漏CIDR 白名单 + ALWAYS_DENY 永久拒绝段 + redirect:'manual'gateway/ssrf.ts
子进程读取网关密钥stdio env 白名单,屏蔽 MGW_* / ADMIN_TOKEN / NODE_OPTIONSgateway/upstream-manager.service.ts
DNS rebinding浏览器请求强制 Origin 白名单校验transport/mcp.controller.ts
错误消息泄漏内网信息safeMessage() 剥离堆栈 / IP:port / SQL / 路径 / Bearer,截断 200 字符common/safe-message.ts
ReDoS(正则拒绝服务)pattern 长度上限 200 + 嵌套量词检测auth/rbac.service.ts
PII 落盘入库前递归正则脱敏,四类 PIIgovernance/redact.service.ts
Prompt 注入(供应链)上游工具描述过 SUSPICIOUS_DESC 检测并告警admin/upstream.controller.ts
越权调用(列表/调用不一致)tools/listtools/call 共用同一 buildAllowedSet()gateway/router.service.ts
资源耗尽会话上限 · stdio 子进程上限 · 限流桶 · 并发队列上限 · CSV 导出 10 万行上限多处
审计丢失 / 篡改攒批失败落 JSONL + 启动回放(重命名不删除);audit_logs 无 UPDATE/DELETE 路径governance/audit.service.ts
配置误改无法追溯所有写操作进 change_logs,含 before/after 与 actor/IPadmin/change-log.service.ts
Prometheus 基数爆炸路由归一化为 req.route.path + status 归类百位 + __unmatched__ 兜底governance/metrics.middleware.ts

生产部署必做清单

  1. 设置 MGW_SECRET openssl rand -hex 32,通过 MGW_SECRET_FILE 挂载。 不设的话会用固定兜底值 'a'.repeat(64),加密等于没加密。 一旦确定不要再改——改了历史凭据全部无法解密。
  2. 设置 ADMIN_TOKEN 至少 32 字节随机串。默认值 mgw_admin_dev_token 是公开的,等于没有鉴权。
  3. 关闭自动建表 MGW_DB_SYNCHRONIZE=0,改用受控的 migration 流程。
  4. 收紧 SSRF 白名单 默认含 10.0.0.0/8192.168.0.0/16 两个大段。 生产应精确到实际网段,如 10.20.30.0/24
  5. 显式配置 MGW_ALLOWED_ORIGINS 留空意味着不做 Origin 校验。若网关可能被浏览器访问,必须列出确切来源。
  6. 限制探针端点的网络访问 /healthz · /metrics · /metrics.prom 均不鉴权。 Nginx allow/deny 或 K8s NetworkPolicy 只放行监控系统网段。
  7. 绑定地址与反向代理 保持 MGW_HOST=127.0.0.1,由 Nginx / 网关做 TLS 终止与对外暴露。 不要让 NestJS 直接面对公网。
  8. 配置 IM 告警 MGW_IM_WEBHOOK。没有告警的熔断等于没熔断——问题发生了但没人知道。
  9. 验证备份可用 手动跑一次 POST /admin/api/backups/run,下载后实际恢复一次到测试环境。 没有验证过的备份不是备份。
  10. 切换 RBAC 模式前先看影子数据 上线新角色规则时先 rbac.mode=observe 跑几天, 确认 wouldForbidTop 里没有误伤再切 enforce
  11. data 目录权限 chmod 700 backend/data。db 文件里含 Key 哈希、加密凭据与全部审计记录。
  12. 吊销所有开发期 Key seed 签发的第一把 Key 是开发用途,上线前应吊销并为每个业务方单独签发, 做到一把 Key 一个责任人

已知残余风险

风险说明与缓解
Admin Token 是全局单一令牌 无法区分不同管理员的权限层级,泄漏即全线失守。x-admin-user 是自报的、不可信。 缓解:放在反代之后并加 IP 白名单 / mTLS / 内网 VPN;定期轮换; 依赖 change_logs 做事后追责
SSE 端点用 query 传 token 浏览器 EventSource 不支持自定义 Header, /admin/api/breaker/events/stream?token= 的 token 会进访问日志。 缓解:该端点只读且只含熔断事件;在 Nginx 侧对该路径关闭 access_log 或过滤 query
正则脱敏不完备 姓名、地址、自定义业务编号无固定格式,规则无法覆盖。 缓解:真正的敏感数据应在上游工具侧就不返回; 把敏感上游标记为 sensitivity='sensitive' 以启用更严格的审计与查询过滤
会话不可水平扩容 进程内 Map。多实例部署需要 sticky session,且工具变更广播不跨实例。 缓解:单实例 + 快速重启;或改造 SessionManager 为 Redis 后端
限流与熔断状态在内存 进程重启后令牌桶补满、熔断归零。 影响可接受:重启本身是低频事件,且重启后上游状态需要重新探测本就是合理的
SQLite 单写者 审计攒批缓解了这个瓶颈,但极高写入量(> 数千 QPS 持续)下仍可能触及上限, 表现为 mgw_audit_queue_depth 持续升高与 JSONL 兜底增长。 缓解:监控该指标;必要时迁移到 PostgreSQL(TypeORM 换驱动即可,实体无需改动)
21

迭代交付史

代码里大量注释以 F1 / P1-C 之类的代号引用需求,这份对照表让你读注释时不再一头雾水。

P0 · 六项

代号特性实现要点与涉及文件
F1RBAC 影子模式 settings.rbac.mode = enforce | observeobserve 下未命中 allow 照常放行, 但写 rbac_would_forbid 审计(errorCode=WOULD_FORBID, resultOk=1)。 仪表盘显示当前模式徽章与 wouldForbidCount / wouldForbidTop
auth/rbac.service.ts · gateway/router.service.ts · admin/stats.controller.ts
F2权限矩阵一键回滚 POST /admin/api/roles/:name/rules/rollback。从 change_logs.before_json 恢复上一版规则集,并再记一条 action='rollback' 的新变更(回滚本身也留痕)。
admin/permission.controller.ts · admin/change-log.service.ts
F3影响面预览量化 POST /admin/api/permissions/previewPreviewResult(12 字段): gained / lost 工具清单、受影响 Key 明细与数量、近 7 日调用量、 lostCalls7d(切 enforce 后会失败多少次)。 走 RbacService.computeWithRules() 独立路径,不写库、不污染线上缓存
admin/permission.controller.ts · auth/rbac.service.ts
F4Key 吊销 ≤2s 即时失效 吊销时主动写入短 TTL 黑名单缓存,而非依赖查库结果缓存的自然过期。 会话每次调用都重新 verify(),因此进行中的会话也立即失效
auth/apikey.service.ts · admin/key.controller.ts
F7事件循环延迟监控 perf_hooks.monitorEventLoopDelay,resolution 20ms、每 5s 采样、 p99 > 200ms 告警、5min 冷却、每次采样后 history.reset() 防钝化。 p99 进 /healthzel_p99_ms 字段。
common/event-loop-monitor.ts · notify/im.service.ts
F9stdio crash loop 自动熔断 5 分钟窗口内崩溃 5 次 → enabled=0 + lastError='crash_loop' + change_logs.action='auto-disable' + IM 告警。三个动作缺一不可:止血、留痕、通知人。
gateway/upstream-manager.service.ts

P1 首批 · 三项

代号特性实现要点
P1-C熔断事件面板 新增 circuit_breaker_events 表(11 张表里的第 10 张)记录每次状态跃迁: event / from_state / to_state / fails_in_window / open_ms / reason。配套 BreakerEventsService、 3 条索引、后台「熔断事件」页与 summary 聚合。
database/entities/breaker-event.entity.ts · governance/breaker-events.service.ts · admin/breaker.controller.ts
P1-A数据库自动备份 better-sqlite3db.backup() 在线热备份(不停机、不加全局锁), node-cronbackup.cron / backup.tz 调度且支持热更新, 滚动保留 backup.keep 份,新增 backups 表记录每次执行。
governance/backup.service.ts · database/entities/backup.entity.ts · admin/backup.controller.ts
P1-BPrometheus 标准指标 新增 GET /metrics.prom(text format 0.0.4), 使用独立 Registry + mgw_ 前缀 + collectDefaultMetrics。 原有 /metrics(JSON,供前端)保留,形成"机器读 text、前端读 JSON"的双出口。
governance/metrics.service.ts · admin/system.controller.ts

P1 收尾 · 四项

代号特性实现要点
P1-D备份失败 IM 告警 im.backupFailed(trigger, err)im.backupPruneFailed(err)。 备份失败但没人知道 = 没有备份,所以告警是备份能力的必要组成而非附加项。
notify/im.service.ts · governance/backup.service.ts
P1-EHTTP 入口 QPS / 延时埋点 新增 mgw_http_requests_total{method,route,status}mgw_http_request_duration_ms{method,route}。关键是路由归一化 (用 req.route.path 而非原始 URL)与 status 归类到百位, 防止 Prometheus 标签基数爆炸;排除 /metrics*/healthz 自我观测; SSE 长连接跳过延时埋点。
governance/metrics.middleware.ts
P1-F熔断面板 SSE 实时推流 @Sse('events/stream') + merge(breaker$, interval(25_000).pipe(map(() => ({ type: 'ping' }))))。 因 EventSource 无法带 Header,AdminTokenGuard 对以 /stream 结尾的路径开放 ?token= query 鉴权。
admin/breaker.controller.ts · admin/guards/admin-token.guard.ts · frontend/src/views/Breaker.vue
P1-GGrafana 看板与 Prometheus 模板 docs/grafana/ 下提供 prometheus.yml(抓取配置)、 gateway-overview.json(可直接 Import 的仪表盘)与 README.md(部署步骤)。
docs/grafana/

演进脉络

把这三批交付串起来看,能读出一条清晰的主线:

  1. 先让功能跑通(MVP):双通道、治理链、注册表、11 张表的主体、10 个管理页。
  2. P0 补"敢不敢改"的能力:F1 影子模式 + F2 回滚 + F3 影响面预览三件套, 本质是把高风险的权限变更变成低风险的可预演、可量化、可撤销的操作。 F4(吊销即时生效)与 F9(crash loop)是止血能力,F7 是自我感知能力。
  3. P1 补"能不能运维"的能力:C(熔断可追溯)+ A(数据可恢复)+ B(指标可采集) 是运维三要素;D/E/F/G 则是把这三要素闭环—— 备份要会告警、指标要能进 Prometheus、事件要能实时看到、看板要开箱即用。
TAKE

这条脉络对做内部平台的同学有普适参考价值: 功能可用只是起点,"敢改"和"能运维"才是内部平台能不能真正被采用的分水岭。 很多平台的失败不是因为功能不够,而是因为运维者不敢动它、动坏了救不回来。

22

运维与脚本

部署形态、日常操作手册与 scripts/ 工具箱。

scripts/ 工具箱

脚本类型用途
smoke-test.shbash基础连通冒烟:healthz → initialize → tools/list → tools/call
smoke-p0.shbash验证 P0 六项特性,含 __triggerAlertForTest 类的白盒钩子
smoke-p1.shbash验证 P1 首批:熔断事件、自动备份、Prometheus 指标
smoke-p1b.shbash验证 P1 收尾:备份告警、HTTP 埋点、SSE 推流、Grafana 模板
smoke-domestic.shbash内网 / 国产化环境适配验证(镜像源、系统库、字体等)
mock-mcp-server.mjsnode 本地假上游 MCP Server。没有真实上游时联调的必备工具, 可模拟慢响应、错误、工具列表变更
seed-calls.pypython3 直接向 gateway.db 灌入模拟审计记录,让仪表盘 / 报表 / 审计页有数据可看。 执行前先停网关,避免 WAL 写冲突
seed-dashboard.shbash组合调用,一键准备演示数据
mgw-fmt.pypython3 把单行 JSON 日志流转成彩色对齐的人类可读格式: ./start.sh 2>&1 | python3 scripts/mgw-fmt.py

Nginx 反向代理

upstream mgw_backend {
    server 127.0.0.1:8080;
    keepalive 32;
}

server {
    listen 443 ssl http2;
    server_name mcp.example.com;

    # ── 管理后台静态资源(frontend/dist)──
    root /opt/mcp-gateway/frontend/dist;
    index index.html;
    location / {
        try_files $uri $uri/ /index.html;   # hash 路由其实不需要,但留着更稳
    }

    # ── MCP 端点:必须支持 SSE ──
    location /mcp {
        proxy_pass http://mgw_backend;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # ★ SSE 三件套:关缓冲、长超时、保持连接
        proxy_buffering        off;
        proxy_cache            off;
        proxy_read_timeout     3600s;
        proxy_send_timeout     3600s;
        proxy_set_header       Connection '';
        chunked_transfer_encoding off;
    }

    # ── 管理 API ──
    location /admin/api/ {
        proxy_pass http://mgw_backend;
        proxy_set_header Host            $host;
        proxy_set_header X-Real-IP       $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        client_max_body_size 8m;
    }

    # ── 熔断事件 SSE(同样要关缓冲)──
    location ~ ^/admin/api/breaker/events/stream {
        proxy_pass http://mgw_backend;
        proxy_http_version 1.1;
        proxy_buffering off;
        proxy_read_timeout 3600s;
        proxy_set_header Connection '';
        access_log off;              # ★ token 在 query 里,不要记进访问日志
    }

    # ── 探针端点:仅限内网 ──
    location ~ ^/(healthz|metrics|metrics\.prom)$ {
        allow 10.0.0.0/8;
        allow 127.0.0.1;
        deny  all;
        proxy_pass http://mgw_backend;
    }
}
GOTCHA

proxy_buffering off 是 SSE 能否工作的决定因素。 后端已经发了 X-Accel-Buffering: no 响应头,但显式在 Nginx 配置里关掉更可靠 (某些版本或全局配置会覆盖该头)。症状是:客户端连上了 SSE 但永远收不到事件, 直到连接关闭时一次性收到全部内容。

systemd 服务单元

# /etc/systemd/system/mcp-gateway.service
[Unit]
Description=MCP Gateway
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=mcpgw
Group=mcpgw
WorkingDirectory=/opt/mcp-gateway/backend

# .env.example 格式可直接被 EnvironmentFile 消费
EnvironmentFile=/etc/mcp-gateway.env
Environment=NODE_ENV=production

ExecStart=/usr/bin/node dist/main
Restart=always
RestartSec=3

# ── 加固 ──
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/mcp-gateway/backend/data
LimitNOFILE=65536

# 优雅停机:给审计队列排空的时间
TimeoutStopSec=20
KillSignal=SIGTERM

[Install]
WantedBy=multi-user.target

/etc/mcp-gateway.env 权限设为 600 且属主为 mcpgwReadWritePaths 只放开 data/,配合 ProtectSystem=strict 即使进程被攻破也无法篡改代码或系统文件。

后端在 main.ts 里注册了 SIGTERM / SIGINT 处理: 停止接收新连接 → 排空进行中的调用 → SessionManager.drain() 通知所有会话 → AuditService.flush() 写完队列 → 关闭数据库连接。TimeoutStopSec=20 给这个流程留足时间。

容器化要点

  • 原生模块编译better-sqlite3 需要在目标架构上编译。 多阶段构建时,builder 阶段需要 python3 / make / g++, runtime 阶段只需 node:20-slim + 编译好的 node_modules
  • 密钥用文件而非 env:设置 MGW_SECRET_FILE=/run/secrets/mgw_secretADMIN_TOKEN_FILE=/run/secrets/admin_tokenenv 形式的密钥会出现在 docker inspect/proc/*/environ 与容器编排的审计日志里。
  • data 目录必须挂卷-v mgw-data:/app/backend/data。 否则容器重建即丢失全部配置与审计记录。
  • 时区backup.tz 默认 Asia/Shanghainode-cron 自带时区处理,不需要设置容器 TZ; 但日志时间戳是 UTC 毫秒,展示层转换。
  • 健康检查HEALTHCHECK CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1

日常操作手册

场景操作
接入新上游 后台「上游服务」→ 新建 → 填 transport 与凭据 → 提交时自动试连。 失败返回 502 且不落库,按提示修正后重试。成功后工具自动进注册表并广播 list_changed
新业务方接入 「API Key」→ 签发(填 name / userName / team / role / 过期时间)→ 复制明文交给对方(只显示一次)。一个业务方一把 Key,绝不共用
Key 泄漏应急 「API Key」→ 吊销 → reason 选 leak。≤2s 全链路失效。 随后到「审计日志」按 key_id 过滤,核查泄漏窗口内的全部调用
收紧某角色权限 rbac.mode=observe → 改规则(先 dryRun)→ 看「影响面预览」的 lostCalls7d → 观察几天 wouldForbidTop → 切回 enforce。出问题一键回滚
某工具需要单独限流 「工具目录」→ 找到工具 → 设置 rate_qpm不需要动上游,也不影响该上游的其它工具
某上游频繁熔断 「熔断事件」页看跃迁历史与 reason → 「审计日志」按 upstream 过滤看错误码分布 → 若是 UPSTREAM_TIMEOUT 集中,调大该上游的 timeout_ms; 若是 UPSTREAM_BUSY,调大 concurrency
数据库恢复 停服务 → 备份当前 data/ 整目录 → 用目标备份覆盖 gateway.db删除 gateway.db-walgateway.db-shm → 启动 → 验证
升级版本 先手动备份(POST /admin/api/backups/run)→ git pull → 两个包分别 npm cibackend: npm run build · frontend: npm run build → 重启 → 跑 scripts/smoke-test.sh
合规导出 「审计日志」→ 设好时间与维度过滤 → 导出 CSV(上限 10 万行,含 UTF-8 BOM,Excel 直开不乱码)。 超量请分批按时间窗导出

容量与清理

audit_logs 是唯一会无界增长的表。代码里刻意没有提供删除路径 (只增不改是审计的基本要求),因此清理必须由运维显式执行:

# 1) 先看当前占用
sqlite3 backend/data/gateway.db \
  "SELECT COUNT(*) AS rows, page_count*page_size/1024/1024 AS mb FROM audit_logs, pragma_page_count(), pragma_page_size();"

# 2) 导出超期数据留档(走 Admin API 更安全,带鉴权与脱敏)
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
  "http://127.0.0.1:8080/admin/api/audit/export?from=0&to=$(( $(date +%s%3N) - 180*86400000 ))" \
  -o audit-archive-$(date +%Y%m%d).csv

# 3) 停服务后归档并回收空间(DELETE 不会自动缩小文件)
sqlite3 backend/data/gateway.db "DELETE FROM audit_logs WHERE ts < $(( $(date +%s%3N) - 180*86400000 ));"
sqlite3 backend/data/gateway.db "VACUUM;"
WARN

VACUUM 需要约等于库大小的临时空间且会独占写锁, 必须在停服务时执行。另外请先确认这一步符合你所在组织的审计留存合规要求—— 某些行业要求调用记录留存 3 年甚至更久。

另外两类会自动清理的数据:backupsbackup.keep 滚动保留(默认 7 份); audit-fallback.jsonl 回放后被重命名为 .replayed.<ts>需要人工定期清理这些残留文件

23

常见问题与排查

按"症状 → 原因 → 处置"组织,覆盖接入、开发、运行三类场景。

接入类

症状原因处置
返回 401,带 WWW-Authenticate Bearer 缺失 / 拼错 / Key 已吊销或过期 确认 Header 是 Authorization: Bearer mgw_live_…(注意 Bearer 后有一个空格); 到「API Key」页核对 prefix 与 revoked 状态
返回 406 Not Acceptable Accept 头不合规 必须同时包含两种类型:Accept: application/json, text/event-stream。 这是 MCP Streamable HTTP 的强制要求
返回 403,无响应体 浏览器请求的 Origin 不在 MGW_ALLOWED_ORIGINS 把实际来源加进白名单;非浏览器客户端通常不发 Origin,不会触发此校验
返回 404,initialize 之后的请求全失败 没回传 Mcp-Session-Id,或会话已因空闲被回收(默认 30 分钟) initialize 响应头里的 mcp-session-id 存下来,后续每个请求都带上; 收到 404 应重新 initialize
tools/list 返回空数组 ① 没有 enabled 的上游 ② 注册表为空 ③ 该 Key 的角色规则把所有工具都 deny 了 依次查:GET /admin/api/system/registry 看工具总数与版本 → 「上游服务」看连接状态 → 「权限矩阵」用该角色跑一次 dryRun 看 allowed 集合
看得到工具但调用返回 FORBIDDEN 理论上不应发生(可见即可调契约)。若出现,说明两次判定之间规则或注册表变了 allowedSet 缓存有 60s TTL,等一分钟重试;持续出现则是 bug,请查 registryVersionrulesVersion 是否在抖动
调用返回 [TOOL_NOT_FOUND] 但后台能看到工具 用了原名而非全名 必须传 upstream__tool 形式的全名,如 wiki__search,不是 search
模型频繁收到 RATE_LIMITED 默认 30 QPM 对该场景偏低 三种粒度可选:改全局 ratelimit.default_qpm · 改该工具的 tool_overrides.rate_qpm · 给该业务方多发几把 Key 分摊桶

开发类

症状原因处置
npm install 卡在 better-sqlite3 原生模块编译失败,缺 python3 / make / 编译器 macOS 装 Xcode Command Line Tools(xcode-select --install); Debian/Ubuntu 装 build-essential python3; 切换 Node 大版本后需 npm rebuild better-sqlite3
启动报 ERR_REQUIRE_ESM 在 CommonJS 里静态 import 了 MCP SDK SDK 是 ESM-only,必须await import('…') 动态加载。 参考 upstream-manager.service.ts 的写法,类型用 typeof import(…) 声明
环境变量改了没生效 后端不内置 dotenv,变量必须存在于进程环境 ./start.sh(会自动 source),或手动 set -a && source .env && set +a 后再 npm run start:dev另外注意:session.idle_timeout_s 等 settings 项优先级高于环境变量
前端 401,但 Token 是对的 Vite 代理没配上,或后端不在 :8080 确认 vite.config.ts 的 proxy 三项(/admin/api/mcp/healthz) 指向实际后端地址;改了 MGW_PORT 要同步改代理
端口被占用,start.sh 直接退出 脚本刻意做端口预检,避免误跑到备用端口造成"改了没生效"的错觉 ./start.sh --stop 清理,或 ./start.sh --force 自动停旧起新
重跑 seed 后自定义规则丢了 seed 对 role_rules 采用整体替换策略 这是设计行为。定制请新建角色而非改内置角色; 生产环境把 seed 移出启动流程,只在首次部署时执行一次
仪表盘 / 报表页空白无数据 库里没有审计记录 python3 scripts/seed-calls.py 灌演示数据(先停网关), 或用 scripts/mock-mcp-server.mjs 起假上游后真实调几次

运行类

症状排查路径处置
上游刚配好就自动被禁用,lastError='crash_loop' F9 触发:stdio 子进程 5 分钟内崩溃 5 次。查 change_logsaction='auto-disable' 的记录看时间线 手工在服务器上执行同样的 command + args 看真实报错(常见:命令不在 PATH、 依赖未装、Node 版本不符)。注意子进程 env 是白名单隔离的,本地能跑不代表网关里能跑—— 缺的变量要在上游配置的自定义 env 里显式补上。修好后手动启用
所有调用都返回 UPSTREAM_DOWN 熔断处于 open,或上游 enabled=0 「熔断事件」页看跃迁时间与 reason → 「上游服务」手动 probe。 open 态默认 60s 后自动进入 half-open 放一个探针,不需要人工干预; 若探针持续失败会继续 open
mgw_audit_queue_depth 持续 > 0 审计写入跟不上产生速度。SQLite 单写者到达上限,或磁盘 IO 慢 先调大 MGW_AUDIT_FLUSH_SIZE(如 100 → 500)减少事务次数; 检查磁盘 IO 与是否触发了 JSONL 兜底;长期高写入量应规划迁移 PostgreSQL
data/audit-fallback.jsonl 在增长 写库持续失败:磁盘满、库文件权限、WAL 损坏 df -h 与目录权限(应为运行用户可写)。修复后重启进程触发回放, 回放成功会重命名为 .replayed.<ts>;确认无误后清理这些残留文件
/healthzel_p99_ms 长期偏高 F7:事件循环被阻塞。常见元凶是同步 IO、超大 JSON 序列化、正则回溯 查是否有人在审计页导出了超大 CSV、是否有 pattern 触发了回溯 (NESTED_QUANTIFIER 校验挡掉了大部分,但超长 * 串仍可能慢); 用 --cpu-prof 抓一次 profile 定位
耗时高但不知道慢在哪 看审计详情里的 duration_msqueue_ms 组合 duration 高 + queue 低 = 上游本身慢(调 timeout_ms 或找上游优化); duration 高 + queue 高 = 并发不够(调大该上游 concurrency; stdio 上游受 ≤2 硬限,只能加实例或改 http transport)
后台「熔断事件」页不实时更新 P1-F SSE 被反代缓冲,或 ?token= 没带上 Nginx 对应 location 加 proxy_buffering off + proxy_read_timeout 3600s; 浏览器控制台看 EventSource 是否收到 type:'ping' 心跳(25s 一次), 收到心跳说明连接正常只是没有事件
改了上游配置但模型看到的工具描述没变 客户端缓存了旧的 tools/list 结果,或未订阅通知 网关侧确认 mgw_registry_version 已递增、SSE 已广播 tools/list_changed;客户端需实现 capabilities.tools.listChanged 的处理,或强制重连会话
改了 MGW_SECRET 后所有上游连不上 历史加密凭据无法用新密钥解密 不可逆。只能逐个上游重新录入凭据。 因此密钥轮换前必须先导出全部上游配置,并安排停机窗口

排查三板斧

  1. 先定位是哪一层 GET /healthz 一眼看清全局:上游 up/down、注册表工具数与版本、活跃会话数、 审计队列深度、事件循环 p99。80% 的问题在这一步就能确定方向。
  2. 再拿 requestId 串起来 每次调用都有唯一 request_id,同时出现在返回体、结构化日志与 audit_logs 里。拿到它就能把「模型看到的错误」「网关打的日志」「库里的记录」三者对齐—— 这是 idx_audit_req 索引存在的意义。
  3. 最后看错误码的 kind client-error(FORBIDDEN / RATE_LIMITED / BAD_ARGS / TOOL_NOT_FOUND) → 调用方的问题,去查 Key、角色规则、参数; upstream-error(DOWN / BUSY / TIMEOUT / ERROR) → 服务侧的问题,去查上游健康、并发配置、熔断历史。 错误码分类本身就在告诉你该往哪个方向查。