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 响应作为权威状态,不能根据通知次数推断发生了几次变更,也不能假设通知按每个工具精确对应。服务端可以对高频变化做合并,网络层也可能让客户端在一次刷新期间又收到通知,因此刷新操作必须幂等。
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()
注册、更新、禁用、启用和删除都会改变客户端可观察到的工具集合或定义,因此句柄会自动通知。使用句柄还有一个好处:工具注册状态与通知绑定在同一抽象中,开发者不容易忘记在修改后补发事件。
如果工具清单由外部插件目录、数据库配置、远程功能开关或租户策略驱动,变化可能绕过注册句柄。此时服务端在确认新状态已生效后调用 `server.sendToolListChanged()`。调用顺序很重要:应先更新权威工具源,再发通知;否则客户端立即重新 list 时仍会取得旧状态。
await pluginRegistry.reload()
server.sendToolListChanged()
不要把显式通知放在每次工具调用之后。只有 `tools/list` 的可观察结果改变时才需要通知。如果工具执行结果中的业务数据改变,而工具名称、描述或 schema 未变,那不是工具列表变化。
高层 `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 的重名工具加稳定前缀。
在 `createMcpHandler` 模式下,工厂创建的 `McpServer` 可能是每请求实例。外部配置变化发生时,随手保留某个实例再调用 `sendToolListChanged()`,不一定能触达当前所有客户端。SDK 提供 handler 级通知门面,应使用 `handler.notify.toolsChanged()` 向 handler 管理的开放订阅流发布。
const handler = createMcpHandler(() => buildServer())
await reloadToolConfiguration()
handler.notify.toolsChanged()
在较新的协议连接中,变更通知通过客户端打开的 `subscriptions/listen` 流传递,并且客户端需明确选择 `toolsListChanged`。如果没有开放且匹配的订阅流,服务端即使发布事件,也没有可投递目标。
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 或让不同节点看到不一致的能力列表。