AI 技术
#MCP#资源订阅#缓存一致性#变更通知#会话重连

MCP 资源订阅与变更通知:客户端订阅后为何仍读到过期资源内容

远程 MCP Server 暴露文件与数据库资源时,客户端调用 resources/subscribe 后仍可能读到旧内容。文章拆解订阅、通知与缓存刷新的完整链路,分析订阅粒度粗、通知丢失、重连后订阅状态不同步等失效原因,并对比轮询与版本校验的取舍,给出可观测指标与部署边界。

一次典型的过期读取

假设你维护一个企业知识库客户端,它通过远程 MCP Server 读取两类资源:file:///docs/policy.md 这样的文档文件,以及 postgres://kb/articles/schema 这样的数据库结构描述。用户在界面上打开某篇文档,客户端调用 resources/read 拿到正文,缓存起来用于后续对话。文档在服务端被编辑后,客户端理应收到变更通知并刷新缓存。

实际运行中却出现这样的现象:服务端日志显示文件已经更新,客户端界面上的正文仍是旧版本,直到用户手动重新打开或重启客户端才恢复。排查时往往发现订阅请求确实发出过,服务端也声明支持 subscribe 能力。问题出在订阅之后的链路上,而不是订阅这个动作本身。

要定位这类问题,需要把“订阅—通知—刷新”当成一条有状态、有生命周期、可能被中断的链路来看,而不是一个布尔开关。下面以这个知识库场景贯穿全文,逐段拆开。

订阅不是推送内容,而是推送“该去读了”

MCP 的资源订阅机制在规范里定义得很克制。客户端发送 resources/subscribe,参数只有资源 URI:

{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "resources/subscribe",
  "params": { "uri": "file:///project/src/main.rs" }
}

服务端在资源变化时发送通知:

{
  "jsonrpc": "2.0",
  "method": "notifications/resources/updated",
  "params": { "uri": "file:///project/src/main.rs" }
}

注意通知里只有 URI,没有新内容,也没有版本号或时间戳。规范把 notifications/resources/updated 定位为“提示客户端该资源已变化”的信号。客户端收到后必须自己再发一次 resources/read 才能拿到最新内容。

这个设计决定了两个后果。第一,通知只是线索,真正的数据仍走读取请求,所以“读到旧内容”可能发生在通知没到、通知到了但没触发读取、或读取了但写缓存顺序不对等多个环节。第二,服务端不需要为每个订阅者维护内容副本,只需要维护“谁订阅了哪些 URI”这份状态。这份状态一旦丢失或错位,通知就不会发出,而客户端毫无察觉。

MCP Python SDK 的客户端订阅文档把这一点讲得更直白:事件“说明什么变了,从不说明怎么变”,因此收到事件后要调用 read_resource 重新拉取。文档还提到一个细节,等待消费的重复事件会合并成一个,重新拉取仍能拿到当前状态。这说明协议层面容忍通知重复,但不保证通知不丢。

订阅粒度决定了你能收到什么

订阅的粒度是单个资源 URI。客户端要监控 500 篇文档,就得发 500 次 resources/subscribe,或者依赖服务端把某个目录 URI 当作可订阅对象。规范里资源用 URI 唯一标识,资源模板(resources/templates/list)用 URI 模板暴露参数化资源,但订阅针对的是具体 URI,不是模板。

这带来一个容易踩的坑:如果服务端把 file:///docs/ 当作一个资源,其内容是一份目录清单,那么订阅它只能感知目录清单变化,感知不到目录内某篇文档正文的修改。客户端以为订阅了“整个文档库”,实际只订阅了一个清单条目。

另一种情况是服务端把数据库表结构暴露为 postgres://kb/articles/schema,而实际数据行变化不会触发该 URI 的更新通知。订阅了 schema 的客户端在文章内容被改写后依然读到旧行。资源 URI 的语义边界由服务端实现决定,客户端不能想当然地认为“我订阅了知识库,就能收到知识库的一切变化”。

工程上通常的做法是:客户端订阅它当前真正展示或注入上下文的那几个 URI,而不是订阅一个宽泛的父 URI 然后期待子资源变化也上报。订阅集合要和缓存键集合对齐,否则会出现“缓存里有这个键,但没人订阅它”的盲区。

通知丢失与会话重连后的状态错位

MCP 的生命周期规范把连接分为初始化、操作、关闭三个阶段。初始化阶段协商协议版本和能力,其中 resources.subscribe 是服务端声明的可选能力。只有协商成功,订阅才可用。

问题在于订阅状态挂在会话上。规范没有定义“重连后自动恢复订阅”的机制。当传输断开(HTTP 连接关闭、stdio 子进程退出、网络抖动),会话结束,服务端为这个会话维护的订阅集合随之失效。客户端重新建立连接、重新初始化后,如果只恢复了工具列表和资源列表,却没有重发 resources/subscribe,那么它认为自己还在订阅,服务端却已经不再为它推送通知。

这种错位不会报错。客户端继续用旧缓存响应请求,服务端也不会主动告知“你之前订阅过但现在没有了”。表现就是开头描述的过期读取,而且只在重连之后出现,很难复现。

还有一类丢失发生在传输层。规范建议实现为所有请求设置超时,超时后发送取消通知并停止等待。通知是单向消息,没有确认机制。如果连接在通知发出后、客户端处理前断开,这条通知就永久丢失。MCP Python SDK 文档提到重复事件会合并,但没有任何机制补发已丢失的事件。

flowchart TD
    A[客户端初始化会话] --> B[协商 resources.subscribe 能力]
    B --> C[发送 resources/subscribe 订阅目标 URI]
    C --> D[服务端记录会话订阅集合]
    D --> E[资源在服务端被修改]
    E --> F{服务端能否找到订阅该 URI 的活跃会话}
    F -- 能 --> G[发送 notifications/resources/updated]
    F -- 不能 --> H[通知不发出 客户端无感知]
    G --> I{通知是否到达客户端}
    I -- 到达 --> J[客户端调用 resources/read]
    I -- 连接中断丢失 --> K[缓存保持旧值]
    J --> L[用新内容覆盖缓存]
    K --> M[后续请求读到过期内容]
    L --> N[连接断开 会话结束]
    N --> O[客户端重连并重新初始化]
    O --> P{是否重发 subscribe}
    P -- 是 --> C
    P -- 否 --> Q[客户端以为仍在订阅 服务端已无记录]
    Q --> M

图中的关键转折有两处。一是服务端找不到活跃订阅会话时,通知直接不发出,客户端没有任何信号。二是重连后是否重发订阅,决定了后续变化能否被感知。这两处正是“订阅了却读到旧内容”的主要来源。

客户端缓存刷新顺序也会制造陈旧读取

即使通知正常到达,客户端仍可能读到旧内容,原因在缓存写入的时序。

考虑这样一个实现:收到 notifications/resources/updated 后,客户端异步发起 resources/read,同时界面继续用缓存渲染。读取返回后写入缓存。如果两次更新通知在短时间内接连到达,客户端可能并发发起两次读取。假设第一次读取针对的是版本 A,第二次针对版本 B,但网络返回顺序是 B 先到、A 后到,缓存最终被版本 A 覆盖。用户看到的是比当前更旧的内容。

这类问题在轮询方案里同样存在,但订阅把更新频率提高了,反而更容易触发。缓解手段是给每次读取带上单调递增的本地序号,或让服务端在读取响应里返回可比较的版本标识,客户端只接受比当前缓存更新的结果。规范里的资源注解包含 lastModified 字段,是 ISO 8601 时间戳,客户端可以用它做粗粒度判断,但要注意它由服务端填写,时钟不一致时不能作为唯一依据。

另一个常见错误是把“收到通知”等同于“缓存已失效”。如果客户端在通知处理函数里只标记缓存脏,却依赖下一次读取路径去刷新,而读取路径又优先返回未过期缓存,就会形成死循环:标记脏,但没人真正去读。

轮询、订阅与版本校验的取舍

面对陈旧读取,客户端有三种基本策略。它们不是互斥的,实际系统常组合使用。

策略触发方式延迟服务端开销能否感知丢失适用场景
纯轮询客户端定时 resources/read取决于轮询间隔每次轮询都产生读取请求不依赖通知,天然容忍丢失变化频率低、资源数量少
纯订阅服务端推送 updated 通知接近实时需维护每会话订阅集合通知丢失后无感知变化频繁、资源数量可控
订阅加版本校验通知触发读取,读取比对版本接近实时订阅开销加读取开销可通过版本比对发现落后对一致性要求较高的场景

纯轮询的优点是简单、无状态,客户端重启后不需要恢复任何订阅。代价是延迟和请求量。对 500 篇文档按 10 秒间隔轮询,就是每秒 50 次读取请求,其中绝大多数没有变化。

纯订阅在资源数量少、连接稳定时表现最好。它的弱点是前面分析的丢失与重连错位,且客户端无法区分“资源没变”和“通知没到”。

订阅加版本校验把两者结合:订阅负责及时性,版本校验负责正确性。客户端在读取响应里携带或获取一个版本标识(如 lastModified 或服务端自定义的 etag),缓存记录该版本。收到通知后读取,若返回版本与缓存相同,说明是重复通知或误报,可忽略;若版本更新,则覆盖。定期做一次低频全量校验,可以发现通知丢失导致的长期落后。

需要说明的是,MCP 规范目前没有定义标准的资源版本字段,lastModified 是注解而非强制字段,服务端可以不填。因此版本校验的可行性取决于服务端实现。如果服务端不提供版本信息,客户端只能退回到“收到通知就无条件覆盖缓存”的策略,并接受重复读取的开销。

可观测指标与部署边界

要判断线上是否出现订阅失效,光看客户端界面不够,需要在链路上埋点。

服务端侧可以观察:当前活跃会话数、每个会话的订阅 URI 数量、单位时间内 notifications/resources/updated 的发送次数、因找不到订阅会话而被丢弃的更新次数。最后一项尤其关键,它直接反映“资源变了但没人被通知”的情况。如果这个计数持续增长,说明存在客户端订阅状态与服务端不一致。

客户端侧可以观察:订阅请求的成功率、重连次数、每次重连后重发订阅的比例、通知到达后读取请求的发起率、缓存命中时资源的平均年龄。缓存年龄是发现陈旧读取最直接的指标:如果某个 URI 的缓存年龄长期超过预期更新周期,要么它真的没变,要么通知链路断了。

部署边界方面,有几点需要明确。第一,订阅能力是服务端可选声明的,客户端在初始化阶段必须检查 resources.subscribe 是否为真,不能假设所有服务端都支持。第二,HTTP 传输下客户端需要在后续请求中带上协商的协议版本头,版本不一致可能导致行为差异。第三,服务端如果重启,所有会话订阅状态丢失,客户端需要能检测到连接断开并重建订阅。

一个务实的默认策略是:客户端在每次会话初始化完成后,根据当前缓存中的资源键集合,重新发送全部订阅请求,而不是假设上一次会话的订阅仍然有效。这个动作成本低,能消除大部分重连错位。同时保留一个低频的兜底校验,比如对关键资源每几分钟主动读取一次,用于发现通知长期丢失。

仍未解决的问题

MCP 规范没有定义订阅的确认与恢复语义。客户端发出 resources/subscribe 后,服务端是否真的记录了这条订阅,客户端只能通过后续是否收到通知来间接判断,而“没收到通知”和“资源没变化”无法区分。规范也没有定义通知的投递保证,没有序列号,没有补发机制。

这意味着在可靠性要求高的场景里,纯订阅不足以支撑正确性,必须叠加版本校验或周期性全量读取。至于版本标识用什么形式、由谁生成、时钟不一致时如何比较,目前留给实现者决定。在这些问题有统一约定之前,客户端对资源内容的新鲜度只能做概率性保证,而不是协议层面的确定性保证。

资料来源

  1. Model Context Protocol Specification - Resources
  2. Model Context Protocol Specification - Lifecycle
  3. Subscriptions - MCP Python SDK
  4. docs/concepts/resources.mdx