一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

MCP Server 如何通过 notifications/tools/list_changed 通知客户端刷新工具列表?

时间:2026-09-16 08:26:01 编辑:袖梨 来源:一聚教程网

MCP Server 在工具集合发生变化时,应发送单向通知 `notifications/tools/list_changed`,客户端收到后重新调用 `tools/list` 获取完整的新快照。通知本身不携带新增、删除或修改的工具详情,它表达的只是“你缓存的工具列表已经过期”。在 TypeScript SDK 中,高层 `McpServer` 通常通过注册句柄自动发通知;只有工具变化发生在注册 API 无法感知的外部系统中时,才需要显式调用 `sendToolListChanged()`。

完整时序是什么

服务端先在能力协商中声明 `tools: { listChanged: true }`。客户端确认该能力后,打开通知流并选择接收工具列表变化。初次发现阶段,客户端调用 `tools/list` 并缓存返回的工具定义。此后当服务端增加、更新、禁用、启用或移除工具时,向已订阅客户端推送通知。

Server -- notifications/tools/list_changed --> Client
Client -- tools/list -----------------------> Server
Client <-- complete tool snapshot ---------- Server

客户端必须以新的 list 响应作为权威状态,不能根据通知次数推断发生了几次变更,也不能假设通知按每个工具精确对应。服务端可以对高频变化做合并,网络层也可能让客户端在一次刷新期间又收到通知,因此刷新操作必须幂等。

高层 McpServer 的推荐写法

TypeScript SDK 的 `McpServer` 在注册工具时会自动声明工具及列表变化能力。`registerTool` 返回一个注册句柄,后续通过该句柄更新描述、禁用、启用或删除工具,SDK 会自动发送匹配的 list-changed 通知。多数服务端不需要直接调用通知方法。

import { McpServer } from '@modelcontextprotocol/server'

const server = new McpServer({ name: 'jobs', version: '1.0.0' })

const report = server.registerTool(
  'run-report',
  { description: 'Run the weekly report' },
  async () => ({
    content: [{ type: 'text', text: 'report queued' }]
  })
)

report.update({ description: 'Run and email the weekly report' })
report.disable()
report.enable()
report.remove()

注册、更新、禁用、启用和删除都会改变客户端可观察到的工具集合或定义,因此句柄会自动通知。使用句柄还有一个好处:工具注册状态与通知绑定在同一抽象中,开发者不容易忘记在修改后补发事件。

什么时候显式调用 sendToolListChanged

如果工具清单由外部插件目录、数据库配置、远程功能开关或租户策略驱动,变化可能绕过注册句柄。此时服务端在确认新状态已生效后调用 `server.sendToolListChanged()`。调用顺序很重要:应先更新权威工具源,再发通知;否则客户端立即重新 list 时仍会取得旧状态。

await pluginRegistry.reload()
server.sendToolListChanged()

不要把显式通知放在每次工具调用之后。只有 `tools/list` 的可观察结果改变时才需要通知。如果工具执行结果中的业务数据改变,而工具名称、描述或 schema 未变,那不是工具列表变化。

低层 Server 必须显式声明能力

高层 `McpServer` 会随着注册工具自动广告相关能力;直接使用低层 `Server` 时,则必须在构造阶段声明 `tools.listChanged`。SDK 会拒绝发送未被 capability 覆盖的通知,调用 `sendToolListChanged()` 可能直接抛错。

const lowLevel = new Server(
  { name: 'jobs', version: '1.0.0' },
  { capabilities: { tools: { listChanged: true } } }
)

能力声明是一项协议承诺。声明为 true 后,服务端应对所有会改变工具列表的路径发出通知;没有实现可靠通知时,不应虚假声明。客户端也只应订阅服务端明确支持的通知类型。

客户端收到通知后怎样刷新

客户端处理器应将本地快照标记为陈旧,并重新获取所有分页。刷新完成前继续展示旧快照还是暂停工具调用,需要按风险决定。只读低风险工具可以短暂沿用旧列表,高风险工具更适合在刷新期间停止新调用。

let refreshing = false
let dirty = false

async function onToolsChanged() {
  dirty = true
  if (refreshing) return
  refreshing = true

  try {
    do {
      dirty = false
      const next = await fetchAllToolPages()
      validateToolDefinitions(next)
      replaceToolSnapshot(next)
    } while (dirty)
  } finally {
    refreshing = false
  }
}

这段逻辑会合并突发通知,同时保证刷新过程中若又发生变化,至少再拉取一轮。`replaceToolSnapshot` 应是原子操作,避免模型上下文或界面看到新旧数据混合。还应验证工具名唯一、输入 schema 合法,并为聚合多个 Server 的重名工具加稳定前缀。

HTTP handler 与实例通知的区别

在 `createMcpHandler` 模式下,工厂创建的 `McpServer` 可能是每请求实例。外部配置变化发生时,随手保留某个实例再调用 `sendToolListChanged()`,不一定能触达当前所有客户端。SDK 提供 handler 级通知门面,应使用 `handler.notify.toolsChanged()` 向 handler 管理的开放订阅流发布。

const handler = createMcpHandler(() => buildServer())

await reloadToolConfiguration()
handler.notify.toolsChanged()

在较新的协议连接中,变更通知通过客户端打开的 `subscriptions/listen` 流传递,并且客户端需明确选择 `toolsListChanged`。如果没有开放且匹配的订阅流,服务端即使发布事件,也没有可投递目标。

stdio 传输如何处理

stdio 服务通常保持一个长期实例,`serveStdio` 会把实例的 `sendToolListChanged()` 路由到开放的订阅流,不需要 handler 通知门面。区别来自服务生命周期,而不是通知语义变化:两种传输最终发送的仍是同一个方法名,客户端仍然在收到通知后调用 `tools/list`。

实现跨传输服务时,可以把业务层的“工具目录已变化”事件抽象出来,再由 HTTP 适配器调用 handler notify,由 stdio 适配器调用 server send 方法。这样不会把传输对象渗透到插件管理逻辑中。

多进程部署必须有共享事件总线

单进程 handler 默认的内存事件总线足够使用。部署多个进程或多个实例后,一个节点观察到插件变化,只更新本节点的内存总线,会导致连接到其他节点的客户端收不到通知。此时需要实现 `ServerEventBus` 的 `publish` 与 `subscribe`,用 Redis、NATS 或其他共享发布订阅系统承载事件。

事件至少应携带服务标识、能力类别和变化版本。消费者收到事件后通过本节点的开放订阅流转发通知。总线通常按至少一次交付设计,因此重复事件必须无害;客户端本来就会重新 list,服务端无需追求脆弱的恰好一次语义。

防抖、版本和竞争条件

批量加载十个插件时,若每个注册操作都立即通知,会让客户端连续刷新十次。可以在批处理边界合并通知,或者对自动通知做短时间防抖。无论如何,最终工具状态落定后必须至少发送一次事件。

若更新发生在客户端分页拉取中,客户端可能拿到跨版本页面。服务端可以为列表响应提供版本或一致性游标,客户端发现版本不一致后重新开始;没有版本机制时,收到刷新期间的新通知就再执行一轮完整拉取。不要只更新通知后第一页而保留旧的后续页。

工具被移除时,已经发出的调用如何完成是另一个问题。通知不应取消在途请求;服务端应正常完成或返回明确错误。客户端更新快照后不再发起新调用。如果工具 schema 是破坏性更新,最好采用新增版本化工具、迁移客户端、再删除旧工具的渐进流程。

安全边界不能随刷新放松

新工具出现在列表里,不代表用户已经批准它。客户端应把工具定义视为不可信服务端元数据,重新验证 schema、名称和注解;涉及写入、支付、删除或外部消息的工具仍需展示清楚并请求确认。旧工具的授权决定不能仅按显示标题继承给新工具。

工具列表可按每个请求携带的授权范围变化,因此缓存键要包含服务端与授权上下文。服务端在实际 `tools/call` 时仍需重新鉴权,不能因为工具曾经出现在列表中就允许调用。权限被收回时,既要触发列表更新,也要立即让调用鉴权生效。

测试通知实现

测试应建立真实内存客户端与服务端连接,先断言能力中存在 `listChanged`,再获取初始列表。随后通过注册句柄新增、更新、禁用和删除工具,逐一断言客户端收到通知,并在每次通知后重新 list 检查快照。外部变化路径还要单独测试显式 send 方法。

HTTP 测试需确认没有 listen 流时不会伪造成功交付,有订阅流时 handler notify 能触达所有匹配客户端。多进程测试可用两个 handler 共享测试事件总线,验证从一个节点发布、另一个节点连接的客户端也能刷新。最后加入突发通知与刷新失败场景,确认去重、退避和陈旧快照策略生效。

结论

`notifications/tools/list_changed` 是工具发现缓存的失效通知,不是工具差异包。高层 `McpServer` 优先通过注册句柄自动通知,外部配置变化才显式调用 `sendToolListChanged()`;低层 Server 需提前声明能力;HTTP handler 使用 `handler.notify.toolsChanged()`,stdio 使用实例方法,多进程则接入共享 `ServerEventBus`。

客户端的核心职责是订阅、合并通知、完整重新分页拉取、验证并原子替换快照。把这些边界处理正确,MCP Server 才能在运行期安全地热更新工具,而不会让模型使用过期 schema 或让不同节点看到不一致的能力列表。

热门栏目