AI 技术
#MCP#工具调用#缓存一致性#会话生命周期#可观测性

MCP 工具列表变更通知:服务端热更新后客户端为何仍在调用已删除的工具

远程 MCP Server 热更新工具后,客户端仍调用已删除工具,问题往往出在 tools/list_changed 通知链路而非服务端。本文拆解通知、重新拉取与缓存刷新的完整流程,分析通知丢失、未订阅、会话重连三类失效路径,对比轮询与版本校验方案,并给出可观测指标与部署边界。

一个远程 MCP Server 上线新版本,删掉了旧的 search_legacy 工具,运维确认进程已重启、tools/list 返回的列表里也没有这个工具了。但客户端侧的模型仍然在对话里生成 search_legacy 的调用参数,tools/call 发到服务端,收到一个 JSON-RPC 错误:未知工具。用户看到的是“工具调用失败”,而服务端日志显示这个工具根本不存在。

直觉上会怀疑服务端没删干净,或者客户端缓存写死了。实际排查中更常见的情况是:服务端确实删了,也发了通知,但客户端本地那份工具列表从来没有被刷新过。MCP 的工具列表不是一次拉取后永久有效的静态清单,它依赖一条通知链路来维持客户端缓存与服务端真实状态的一致。这条链路任何一环断开,客户端就会拿着过期列表继续工作。

下面用一个贯穿场景说明:一个企业知识库助手,通过远程 MCP Server 暴露检索、工单查询、文档导出等工具。运维在不停机的情况下增删工具,客户端是常驻的对话服务进程,多个用户会话共享同一个 MCP 连接。

工具列表为什么不能只拉一次

MCP 把工具定义为模型可调用的外部能力,每个工具由 name 唯一标识,并带有 description、inputSchema 等元数据。客户端要调用工具,先得知道有哪些工具,这个动作由 tools/list 请求完成。

关键点在于,tools/list 的结果是会话期内的一个快照。客户端拿到列表后,通常会把它转成模型可用的工具定义,注入到提示或工具选择逻辑里,同时在本地维护一份 name 到工具定义的映射,用于校验模型给出的调用参数。这份映射就是客户端缓存。

服务端可以在运行时增删工具。规范为此定义了一条通知:notifications/tools/list_changed。当可用工具列表变化时,声明了 listChanged 能力的服务端应该发送这条通知。客户端收到后,重新发一次 tools/list,用新结果替换本地缓存。

这里有个容易被忽略的约束:通知本身不携带工具列表。它只是一个“变了,来重新拉”的信号。真正的列表内容仍然要通过 tools/list 获取。这意味着通知链路和拉取链路是两段独立的过程,任何一段出问题都会导致缓存陈旧。

在企业知识库场景里,运维删掉 search_legacy 是因为它调用的后端接口已下线。如果客户端缓存没刷新,模型仍会选中它,调用必然失败。更麻烦的是,模型看到的是一个“看起来合法”的工具,所以它会反复尝试,而不是立刻换用 search_v2。

一次工具变更的完整消息流

把服务端删工具到客户端刷新缓存的过程拆开,可以看到状态在几个组件之间传递。

flowchart TD
    A[运维删除 search_legacy] --> B[服务端更新工具注册表]
    B --> C{服务端是否声明 listChanged}
    C -->|否| D[客户端无感知 继续用旧列表]
    C -->|是| E[发送 notifications/tools/list_changed]
    E --> F{客户端是否已订阅该通知}
    F -->|否| D
    F -->|是| G[客户端发起 tools/list]
    G --> H[服务端返回新工具列表]
    H --> I[客户端替换本地缓存]
    I --> J[模型只看到 search_v2]
    D --> K[模型仍生成 search_legacy 调用]
    K --> L[tools/call 返回未知工具错误]

这条链路里有三个转折点值得单独看。

第一个转折在服务端:它是否在 initialize 响应里声明了 tools.listChanged 为 true。规范要求支持工具的服务器声明 tools 能力,listChanged 表示服务器是否会在列表变化时发通知。如果服务端没声明,客户端在能力协商阶段就知道不该期待通知,但很多客户端不会因此主动轮询,于是缓存就一直停在初始化时的那份快照。

第二个转折在客户端:它是否真的订阅并处理了这条通知。规范里的通知是服务端单向发出的,客户端需要在消息处理逻辑里注册对应的处理器。如果客户端框架默认只处理请求响应,而把通知丢进一个没人消费的队列,通知就等同于没发。

第三个转折在拉取:客户端收到通知后重新发 tools/list,这次请求可能因为网络抖动、超时或会话已断而失败。失败后如果客户端没有重试,缓存仍然是旧的。

通知丢失、未订阅与重连不同步

生产环境里导致陈旧工具调用的原因,基本可以归到三类。

(1)通知丢失

notifications/tools/list_changed 走的是和普通消息相同的传输通道。在 Streamable HTTP 这类传输下,如果服务端在发送通知的瞬间连接正好处于半开状态,或者中间有代理提前关闭了长连接,这条通知就可能没有到达客户端。通知是单向的,服务端发完不会等确认,所以它无法知道客户端是否收到。

一个典型表现是:服务端日志里明明有发送记录,客户端日志里却找不到对应的接收记录。这类问题在跨机房、经过多层网关的部署里更常见。

(2)客户端未订阅

有些客户端实现只在初始化时拉一次工具列表,之后不再监听任何通知。这不一定是因为开发者不知道这个机制,而是因为客户端把 MCP 连接当成“启动时配置一次”的资源。在这类实现里,服务端发多少次通知都没用,因为客户端根本没有注册处理器。

还有一种更隐蔽的情况:客户端注册了处理器,但处理器内部抛了异常,比如在解析新列表时遇到一个不符合预期的 schema,异常被吞掉,缓存替换这一步没执行。

(3)会话重连后状态不同步

这是最容易被误判的一类。客户端和服务端的连接断开后重连,会重新走一遍 initialize。按规范,初始化阶段会重新协商协议版本和能力,但客户端本地那份工具缓存不一定会被清空。如果重连逻辑复用了旧的缓存对象,而重连后没有主动发一次 tools/list,客户端就会带着断线前的列表继续工作。

在常驻对话服务里,这个问题会被放大:多个用户会话共享同一个 MCP 连接,连接重连时正在进行的会话不会中断,它们继续用旧缓存里的工具定义。服务端此时可能已经删掉了某个工具,于是这些会话的调用全部失败。

轮询、版本校验与通知的取舍

既然通知可能丢,一个自然的想法是让客户端定期轮询 tools/list。这确实能兜底,但代价不小。

方案一致性保证额外开销实现复杂度适用场景
仅依赖 list_changed 通知依赖通知必达,可能长期陈旧变更时一次拉取低连接稳定、客户端可控
定期轮询 tools/list有上限的陈旧窗口周期性请求,与工具数量相关低无法改造服务端时的兜底
通知 + 列表版本校验变更后尽快一致,重连可检测变更时拉取,调用前轻量校验中多会话共享连接的生产服务
每次调用前全量拉取强一致每次调用一次列表请求低但开销高工具极少、调用频率低

轮询的核心问题是开销和工具数量相关。企业知识库场景里工具可能有几十个,每个工具的 inputSchema 都不小,周期性全量拉取会持续占用带宽和序列化开销。轮询周期设长了,陈旧窗口就大;设短了,开销又上去了。

版本校验是折中方案。服务端在工具列表里附带一个版本标识,客户端在每次 tools/call 前带上自己缓存的版本。服务端发现版本不匹配,就返回一个明确的错误码或提示,客户端据此触发重新拉取。这样正常情况下没有额外往返,只在真正不一致时才付出代价。

需要说明的是,MCP 规范本身没有定义工具列表的版本字段,版本校验属于实现层约定。它要求服务端和客户端都做改造,所以更适合双方都在自己控制范围内的部署。

每次调用前全量拉取一致性最强,但把列表请求放到了热路径上,工具调用频率一高就会成为瓶颈,一般只在工具数量极少时考虑。

客户端缓存刷新要处理的状态

实现缓存刷新时,有几个状态边界需要明确。

客户端本地至少维护三样东西:工具定义映射、当前列表的版本或时间戳、以及一个“正在刷新”的标志。收到 list_changed 后,如果已经有刷新在进行,不应该并发再发一次 tools/list,否则多个响应可能乱序到达,后到的旧响应覆盖新结果。

刷新过程中,正在进行的工具调用怎么处理?一种做法是让它们继续用旧映射完成,新映射只对新调用生效。另一种是等刷新完成再放行。前者延迟低,但会有一小段新旧混用的窗口;后者一致性好,但刷新期间调用会被阻塞。企业知识库这种读多写少的场景,通常选前者,因为一次刷新很快,混用窗口极短。

重连后的处理要单独写。连接重建、initialize 成功后,客户端应该把本地缓存标记为失效,并主动发一次 tools/list,而不是等通知。因为断线期间发生的变更,通知已经发过了,客户端收不到。

还有一个容易漏的点:tools/list 支持分页。客户端收到 nextCursor 时必须继续拉取,直到没有下一页。如果只取了第一页就替换缓存,工具多的服务端会丢工具,表现和缓存陈旧类似,但原因不同。

需要观察哪些信号

这类问题在用户侧表现为零散的调用失败,很难靠单个错误定位。需要在几个位置埋点。

服务端侧,记录每次发送 list_changed 的时间、当前工具数量、以及发送时的连接标识。同时记录 tools/call 中未知工具的请求,把请求里的工具名和当前注册表做对比。如果未知工具名集中出现在某几个客户端连接上,基本可以判断这些客户端的缓存没刷新。

客户端侧,记录每次收到 list_changed 的时间、随后 tools/list 的发起时间和结果、以及缓存替换的时间。这三个时间点能区分“通知没收到”和“收到了但拉取失败”。

还可以加一个一致性探针:客户端定期用自己缓存的工具名集合和服务端返回的集合做差集,把差集大小作为指标上报。差集长期不为零,说明刷新链路有问题。

对于多会话共享连接的部署,要按连接维度而不是按进程维度统计。同一个进程里不同连接的缓存状态可能不同,混在一起统计会掩盖问题。

部署边界与仍未解决的问题

通知机制的可靠性上限由传输层决定。在连接稳定、客户端和服务端都在自己控制范围内的部署里,仅靠 list_changed 通知通常够用。一旦中间有不可控的代理、网关,或者客户端是第三方实现,就必须假设通知可能丢,用轮询或版本校验兜底。

重连后的状态同步目前没有协议层的统一约定。规范要求重连时重新走 initialize,但没有规定客户端必须清空工具缓存。不同客户端实现的行为可能不一致,这在混合客户端的环境里会带来排查困难。

工具定义变更(同名工具改了 inputSchema)比增删更难处理。增删会让旧工具名直接失效,错误明显;改了参数结构但保留工具名,模型可能生成符合旧 schema 的参数,服务端校验失败,错误信息看起来像参数问题而不是缓存问题。规范提到更新工具定义应该谨慎执行,但没有给出客户端如何区分“同名但定义已变”的机制。

一个尚未形成共识的方向是:能否在 tools/list 的响应里带一个服务端生成的列表摘要,让客户端在调用时附带,服务端据此判断客户端是否过期。这需要规范层面的支持,目前只能靠实现层自行约定。在那之前,把通知、重连刷新和一致性探针三件事都做上,是让陈旧工具调用从“偶发难查”变成“可观测可定位”的现实做法。

资料来源

  1. Model Context Protocol Specification - Tools
  2. Model Context Protocol Specification - Lifecycle
  3. 工具 – MCP 中文站(Model Context Protocol 中文)
  4. MCP的动态发现 - 蝈蝈俊 - 博客园