Skip to main content

摘要

本 SEP 提议添加字段,以支持对 tools/listprompts/listresources/listresources/readresources/templates/list 返回的结果对象进行缓存。将添加两个字段 ttlMscacheScope。TTL 告诉客户端在重新获取之前,响应可以被视为新鲜多长时间。这允许客户端缓存特性列表,减少对服务器推送通知的依赖,同时保持完全向后兼容。cacheScope 字段控制谁可以缓存某个响应。TTL 是对既有通知机制的补充而非替代——两者可以共存。

动机

如今,MCP 客户端通过调用服务器上的方法来发现服务器特性。这些调用返回当前的特性集。为了解变化,客户端依赖来自服务器的推送通知。下表将服务器方法映射到通知类型。 这种方式有若干局限:
  1. 基于 HTTP 的传输需要 SSE 流:许多客户端和服务器在支持长期存在的 SSE 流(通知所必需)方面存在挑战。目标是让 SSE 流成为一种可选的优化,而在没有它们的情况下也能支持协议功能。TTL 允许客户端按可预测的计划轮询,而不依赖服务器推送通知。
  2. 实现复杂性:客户端和服务器都必须实现通知订阅和投递基础设施。许多简单的服务器其特性列表很少(或从不)变化,但如果想让客户端保持最新,仍必须支持通知机制。
  3. 无新鲜度信号:即使能够接收通知的客户端,也没有关于列表”稳定性”的指示。一个工具列表每天变化一次的服务器和一个每秒变化的服务器,在客户端看来完全相同——两者都只是在变化发生时发送通知。TTL 提供了一个显式的新鲜度提示。
  4. 与 Web 标准对齐:HTTP 缓存(Cache-Control: max-age)和 DNS TTL 长期以来已证明,基于时间的新鲜度提示是一种简单、被充分理解的机制,用于减少不必要的重新获取。MCP 可以受益于同样的模式。
向列表响应添加 TTL 字段,以最小的、向后兼容的协议变更解决了所有这些问题。

规范

新接口:CacheableResult

引入一个新的 CacheableResult 接口,作为扩展 Result 的独立类型。它拥有 ttlMscacheScope 字段。

Schema 变更(TypeScript)

语义

TTL 是一个新鲜度估计,而非保证。服务器**可以(MAY)在 TTL 过期前更改底层列表;这样做且已公告 listChanged 的服务器应当(SHOULD)**发送相应的通知。 服务器**必须(MUST)**在 tools/listprompts/listresources/listresources/readresources/templates/list 返回的 Result 上提供 ttlMs ttlMs 必须(MUST) >= 0。如果服务器返回负值,客户端**应当(SHOULD)**忽略它并将其视为 0(立即过期)。

新鲜度计算

客户端记录收到响应的本地时间(t_received)。当 now < t_received + ttlMs 时,响应被视为新鲜。一旦 TTL 过期,响应即为过期,客户端**应当(SHOULD)**在下次访问时重新获取。 客户端不应当(SHOULD NOT)将 TTL 视为触发自动后台重新获取的轮询间隔。TTL 是一个新鲜度提示:客户端在需要列表时检查新鲜度,仅在过期时重新获取。确实选择轮询的实现**应当(SHOULD)**应用抖动(jitter)和退避(backoff)。 即使 TTL 尚未过期,客户端在有理由相信数据已变化时也**可以(MAY)**重新获取。例如在工具调用上收到意外错误,表明该方法未找到或参数无效。 如果重新获取结果时发生错误(例如网络问题、服务器停机),客户端**可以(MAY)**提供过期的响应。TTL 是关于客户端能安全依赖数据多久的提示,但现实条件可能需要灵活性。

缓存作用域

cacheScope 字段控制谁可以缓存某个响应:
  • "public":响应不包含用户特定的数据。任何客户端、共享网关或缓存代理**可以(MAY)**存储该缓存响应并向任何用户提供。这适合对所有用户都相同的工具、提示和资源模板列表。
  • "private":响应包含用户特定的数据。只有发起请求的用户的客户端**可以(MAY)**缓存它。共享缓存(例如多租户 API 网关)**不得(MUST NOT)**向不同用户提供 "private" 缓存响应。这适合依赖已认证用户的 resources/read 结果,或逐用户变化的过滤后列表结果。
此设计参照 HTTP 的 Cache-Control: public vs Cache-Control: private,在 MCP 协议层面应用同样被充分理解的语义。

与通知的交互

TTL 和服务器推送通知是互补的:
  • 服务器**可以(MAY)**提供 ttlMs 而不在其能力中公告 listChanged: true。在此情况下,客户端完全依赖 TTL。
  • 服务器**可以(MAY)**公告 listChanged: true 提供 ttlMs。在此情况下,客户端可以使用 TTL 避免通知之间不必要的重新获取,而通知充当立即失效的信号。

与分页的交互

当列表结果被分页(包含 nextCursor)时,每一页都是一个可独立缓存的响应——这与 HTTP Cache-Control 处理分页资源的方式一致。具体而言:
  • 每个分页响应携带其自己的 ttlMs 值。每页的新鲜度时钟从该页被接收时开始。
  • 服务器**可以(MAY)**在不同页返回不同的 ttlMs 值(例如稳定列表的前几页 TTL 更长,最后一页 TTL 更短)。
  • 不存在跨页一致性保证。如果底层数据在分页获取之间发生变化,客户端可能观察到重复或缺口——这与适用于 HTTP 分页 API 的取舍相同。
  • 需要完整列表一致快照的客户端**应当(SHOULD)**从头重新获取(不带游标)。
  • 如果某个游标失效(例如服务器对先前有效的游标返回错误),客户端**应当(SHOULD)**丢弃所有缓存的页并从头重新获取。
对于给定的列表请求,服务器**必须(MUST)对所有响应页应用相同的 cacheScope。例如,如果 tools/list 响应的第一页为 cacheScope: "private",则该请求的所有后续页也必须(MUST)**被视为 "private"

错误处理

  • 为向后兼容,如果 ttlMs 缺失,客户端**应当(SHOULD)**假定默认 ttlMs0(立即过期),并依赖其自身的缓存启发式或通知。
  • 如果 ttlMs 存在但为负整数,客户端**应当(SHOULD)**忽略它,并表现得如同它为 0(立即过期)。

理由

为何不替换 list_changed 通知?

通知提供立即失效,这对长期连接很有价值。TTL 提供一种互补机制,为无状态传输和减少不必要轮询而优化。两种机制服务于不同的用例,自然共存。

为何 TTL 用整数毫秒?

我们选择整数毫秒而非秒,因为我们希望在整个 MCP 协议中 TTL 使用统一单位。Tasks 有亚秒级 TTL 的用例,使用毫秒允许 MCP 中所有 TTL 有一致的表示。 许多既有系统对 TTL 使用整数秒,但有些(例如 gRPC retry pushback)使用毫秒。关键是为 MCP 中所有 TTL 选择单一、一致的单位。整数毫秒提供了必要的精度,同时保持易于实现和理解。

为何不直接使用 HTTP 缓存?

MCP 与传输无关。虽然基于 HTTP 的传输理论上可以使用 Cache-Control 头部,但 MCP 也运行在 stdio 之上,并支持可能没有 HTTP 头部的可插拔传输。将 TTL 嵌入 JSON 响应体确保它在所有传输上统一工作。

向后兼容性

  • 不提供它的既有服务器继续保持不变地工作。如果 ttlMs 字段缺失,客户端**应当(SHOULD)**假定默认 ttlMs 为 0(立即过期),并依赖其自身的缓存启发式或通知,这是当前的行为。
  • 不理解该字段的既有客户端会忽略它,因为 MCP 结果对象在 Result 基类型上通过 [key: string]: unknown 允许额外属性。
  • cacheScope 是必需的,因为对较旧的服务器没有安全的默认值。服务器必须显式声明预期的缓存作用域,以防止意外缓存用户特定的数据。
  • 不修改或移除任何既有字段或行为。
  • 无需能力协商。
  • SDK 维护者可以选择在其 SDK 中为 ttl 和 cacheScope 添加默认值以简化采用,但这对合规而言不是必需的。

参考实现

尚无参考实现。

安全影响

配置错误或恶意的服务器可能设置过长的 TTL,导致客户端缓存过期数据的时间比期望的更长。然而,由于 TTL 是一个提示,客户端可以选择忽略它或在怀疑变化时重新获取,安全风险很小。客户端应被设计为优雅地处理意外的 TTL 值。