ADR-004: MCP 能力状态 —— 偏好持久化于 DB,列表实时查询上游
English: ADR-004-capability-state-model.md
Status: 已采纳 (Accepted) Date: 2026-05-20 Deciders: Yuxing Wu Related: spec 001-mcp-gateway (User Story 2)
背景
一个 MCP 服务器会暴露 tools、resources 与 prompts。Gateway 需要知道每个上游 提供了什么、用户选择暴露哪些,以及在上游变化时(升级、插件重载、重启)如何 让两者保持一致。
MCP 协议本身通过 notifications/tools/list_changed、 notifications/resources/list_changed 与 notifications/prompts/list_changed 来表达动态能力。因此能力发现属于运行时关注点,而非配置层关注点。
两种极端设计框定选择:
- 将一切缓存在数据库。工具名、schema、描述以及启用标志全部进入
mcp_capabilities表。UI 直接从表中渲染。 - 完全不缓存。每次 list / call 都打到上游。启用/停用状态仅以列表形式 存在于
mcp_servers.configJSON 中。
两者都有失败模式。前者与协议的动态模型对抗,并积累过时 schema; 后者牺牲 UI 响应速度,且在上游离线时丢失有意义的用户状态。
决定
数据库只存用户偏好。能力列表实时查询。
mcp_capability_preferences (resource_id, capability_type, capability_key, enabled, first_seen_at, last_seen_at)—— 唯一持久化的能力状态。- Tool / resource / prompt 的名称、schema、描述永不持久化。每次
list_*请求时实时拉取,按 session 在内存中缓存,TTL 60 秒。 - 缓存失效触发条件:
- TTL 到期。
- 上游
notifications/*/list_changed(同时会转发给下游客户端)。 - 用户主动刷新 (
coffer mcp refresh <name>)。 - 上游 session 重启。
- 每次成功 list 后,将偏好与实时列表对账:新出现的 capability key 插入一行 (依据该服务器的
auto_enable_new_capabilities策略决定是否启用); 消失的 capability key 不会被删除(这样用户的选择能在上游波动中保留下来)。
后果
正面
- 不存在缓存陈旧的 bug 类。在 60 秒缓存窗口的边界上,UI、gateway 列表和上游 始终是一致的。
- 不存在 schema 漂移的 bug 类。上游修改某个工具的
inputSchema无需任何迁移; 下一次tools/list就能反映出来。 - 用户意图(启用/停用)天然在上游升级时保留 —— 以 capability 名作为键, 这是当前唯一稳定的身份。
- 与 MCP 协议的动态能力设计保持一致,而非与之对抗。
负面
- 当上游离线时,UI 无法为用户已知的 capability 展示 schema 或描述; 只能给出名字(来自
last_seen_at)与启用标志。 - 每次经 gateway 的
list_*在缓存未命中时至少要对每个上游产生一次往返。 上游多了之后可能给单次客户端请求增加几十毫秒 —— 判断为可接受。 - 那些已永久消失的 capability 的偏好仍会在 DB 中累积。清理留给人工或未来的 自动化;其总量受限于用户曾经见过的 capability 数量,在单用户场景下规模很小。
备选方案
将完整能力列表缓存到 DB,并定期同步。被否决。
- 与
list_changed语义相悖 —— 协议本意是动态的。 - 引入可被用户察觉的同步滞后(schema 缓存说一套,上游实际另一套)。
- 上游一旦新增字段就要强制 schema 迁移。
- 相对于内存缓存(已足够快)并无明显收益。
完全实时(不做任何缓存)。被否决。当 gateway 注册有 5+ 上游、AI 在 多轮对话中频繁执行 list_tools 时,延迟代价会被放大。60 秒的内存缓存能 覆盖常见情形。
基于 LLM 的语义去重,剔除冗余能力。被否决。脆弱(名称 vs 描述 vs schema)、 昂贵(每次 list 都要调一次 LLM)、不透明(用户无法预测两个 read_file 工具 最终留下哪一个)。用户显式的偏好开关才是正确的控制面。
持久化 schema + 描述,但靠 notifications/*/list_changed 维持新鲜度。 被否决。许多 MCP 服务器在重启时并不可靠地发出 list_changed;这样仍然会 积累过时 schema。我们决定彻底消除这种歧义。
更新 —— 2026-06-04:复用连接失效时自愈
「实时拉取」的列表跑在 supervisor 缓存并复用的连接上。进程级的管理 supervisor(capabilities / refresh / 详情页)会一直把一条 MCP session 已经 失效的连接(idle 超时、上游重启、socket 被断开)当作健康的继续复用,毫无察觉。 gateway 请求路径在首次失败时就会 evict 掉连接;而 discovery 路径没有,于是一条 死连接会以裸 500 / 空能力列表的形式表现,一直持续到 daemon 重启——与此同时 测试连接(每次新建临时连接)显示正常,刷新能力(只清数据缓存、不碰连接) 也毫无作用。
现在 discovery 的 list_* 路径对齐 gateway:请求失败时——除 METHOD_NOT_FOUND (合法的「无此能力」)和超时以外——会 evict 掉坏连接并在新连接上重试一次;重试 仍失败则归一为 UPSTREAM_UNAVAILABLE(503),不再泄漏成 500。能力页签现在会区分 「加载失败」(「无法加载——请测试连接 / 刷新」)与「上游确实为空」(「尚未发现任何 提示词」),这样在连接陈旧时,活动日志(「发现了提示词 …」)与页签不再相互矛盾。