MCP Server 动态增删工具时如何让客户端更新可用能力?

作者:袖梨 2026-09-16

MCP Server 动态增删工具后,要让客户端更新可用能力,需要同时完成三件事:服务端的权威工具集合已经改变;连接模式允许服务端发送未经请求的通知;服务端发送 Tool List Changed 通知,客户端收到后重新调用 `ListToolsAsync`。在 C# SDK 中,通知只适用于 stateful HTTP 或 stdio,stateless Server 无法主动向客户端推送,因此不能只修改依赖注入中的工具集合就期待客户端界面自动变化。

动态工具更新不是把新定义塞进通知

工具列表变化通知只表示客户端缓存已过期,不携带新增或删除的完整工具定义。服务端应先修改真正用于响应 tools/list 的集合,然后调用通知方法。客户端收到事件后主动重新发现,最终以新的 list 响应替换本地快照。

修改服务端工具集合
  → 发送 ToolListChangedNotification
  → 客户端处理通知
  → 调用 ListToolsAsync
  → 替换可用工具快照

如果先通知再更新集合,客户端可能立刻拉到旧数据;如果只更新集合而不通知,已经连接的客户端会继续使用旧缓存;如果客户端只记录通知却不重新 list,界面和模型上下文同样不会改变。四个环节缺一不可。

先选择支持主动通知的传输模式

C# SDK 文档明确指出,工具列表变化属于 unsolicited notification,也就是并非某个请求的直接响应。它要求服务端与客户端之间存在可持续投递通道,因此适用于 stateful 模式或 stdio。stateless HTTP 为每个请求独立处理,服务端没有可以主动推送事件的持久会话。

如果业务必须使用 stateless HTTP,应改用其他刷新机制,例如客户端按 TTL 重新发现、用户手动刷新、在下一次普通请求前检查版本,或者由应用自己的消息通道发出失效信号。不要在 stateless 配置中调用通知 API 后假设所有客户端都已更新。

对于 ASP.NET Core 部署,配置 Server 时要明确会话模式,并验证代理没有缓冲或截断事件流。stdio 则通常由长期进程和单一传输承载通知,但进程重启后客户端仍需重新初始化和发现。

服务端如何发送工具列表变化通知

工具可以通过特性扫描、`McpServerTool.Create` 工厂、自定义派生类或底层处理器定义。静态特性扫描适合启动时固定的工具;真正的运行时增删通常需要维护线程安全的工具注册表,并让 list 与 call 都读取同一份权威状态。

完成注册表更新后,注入 `McpServer` 并发送标准通知:

await server.SendNotificationAsync(
    NotificationMethods.ToolListChangedNotification,
    new ToolListChangedNotificationParams(),
    cancellationToken);

通知应发生在事务提交之后。若工具来自数据库配置,可以先写入并刷新内存快照,再发布事件;若刷新失败则不要通知。删除工具时,在途调用如何结束需要单独定义,通常允许已开始调用完成,而新的 list 不再返回该工具。

客户端必须注册通知处理器

客户端不能依赖 SDK 自动替换自己保存的 IList。建立连接后,应为标准方法注册处理器,在回调中重新获取工具列表:

mcpClient.RegisterNotificationHandler(
    NotificationMethods.ToolListChangedNotification,
    async (notification, cancellationToken) =>
    {
        var updatedTools = await mcpClient.ListToolsAsync(
            cancellationToken: cancellationToken);

        toolCatalog.Replace(updatedTools);
    });

回调中的 Replace 应是原子操作。先在局部变量中拉取并验证所有工具,成功后再替换共享集合;若请求失败,保留旧快照并标记为陈旧。不要先 Clear 再等待网络,否则刷新过程中调用方会看到短暂的空列表。

动态注册表应如何设计

工具注册表至少保存名称、描述、输入 schema、调用委托、启用状态和版本。名称在单个 Server 内必须唯一。更新操作应在锁或不可变快照下完成,保证同一时刻 tools/list 与 tools/call 对工具存在性的判断一致。

一种稳妥方式是复制当前字典、应用一批变更、校验无重名且 schema 合法,然后用原子引用替换旧字典。通知只发送一次,避免批量增加十个工具时让客户端连续刷新十次。调用处理器获取快照后再查找工具,防止枚举期间集合被修改。

public async Task ReplaceToolsAsync(
    IReadOnlyDictionary<string, McpServerTool> next,
    CancellationToken cancellationToken)
{
    Validate(next);
    Interlocked.Exchange(ref _snapshot, next);

    await _server.SendNotificationAsync(
        NotificationMethods.ToolListChangedNotification,
        new ToolListChangedNotificationParams(),
        cancellationToken);
}

代码只表达核心顺序,实际实现还要处理不可变集合类型、服务生命周期和异常回滚。若多实例共享配置,每个有连接的实例都必须收到变化事件并向自己的客户端转发。

多实例部署不能只更新本机内存

负载均衡后的多个 Server 进程各自维护连接。管理员在节点 A 修改工具并只发送本机通知,连接在节点 B 的客户端不会刷新。应将工具目录存储在共享数据库或配置服务,并通过 Redis、消息队列或发布订阅系统广播版本变化。

每个实例订阅内部事件,刷新本地权威快照,再向本实例的 MCP 客户端发送 list-changed。内部事件可以重复投递,因此按目录版本去重;但即使重复通知,客户端重新 list 也应是幂等的。发布内部事件前要确保所有节点能够读取新版本,避免通知后拉到旧数据。

授权变化也可能改变可见工具

同一个 Server 可以依据请求中的授权范围返回不同工具集合。例如普通用户只能看到查询工具,管理员还能看到写入和删除工具。动态权限调整后,旧客户端缓存可能包含已失去权限的工具,因此服务端既要推动刷新,也必须在每次 tools/call 时重新鉴权。

客户端缓存键应包含服务端身份和授权上下文。不能把管理员连接取得的列表共享给普通用户。工具曾出现在列表中不是授权凭证,调用时服务端仍以当前 ClaimsPrincipal 和 scope 做最终判断。

工具定义修改也需要通知

不仅新增和删除需要刷新。描述、输入 schema、输出 schema、注解或可见状态改变,都会影响模型选择或参数生成,也应发送工具列表变化通知。尤其是 schema 变更后,客户端若继续使用旧定义,可能构造出服务端拒绝的参数。

破坏性 schema 变更最好采用版本化迁移:先注册新名称或新版本工具,保留旧工具一段时间,通知客户端刷新,观察调用迁移完成后再移除旧工具。这样可减少通知与在途模型推理之间的竞态。

处理通知风暴和刷新竞争

客户端可能在刷新进行时收到第二个通知。简单并发执行两个 `ListToolsAsync` 会出现后返回的旧请求覆盖新请求。可以使用 SemaphoreSlim 保证单飞,并记录 dirty 标志;当前刷新结束后若期间又收到通知,再执行一轮。

服务端也应对短时间内的批量操作做合并。通知表示“至少变化一次”,不要求一项变更对应一条事件。对外发送一次最终失效信号,比连续发送大量通知更有效率。

预加载 Known Tools 时的额外注意

C# 客户端可以通过 `AddKnownTools` 预加载工具定义,例如为了提前支持自定义 HTTP 参数头。这类 known tools 在普通列表缓存清理后仍会保留;如果服务端返回同名工具,服务端定义覆盖缓存值,但 known 状态继续存在。

因此动态删除服务端工具后,预加载的同名 known tool 可能仍存在于客户端缓存策略中。需要按应用意图调用 `RemoveKnownTools` 或 `ClearKnownTools`。不能假设一次 list-changed 会自动清除所有带外注入的工具。

如何验证整个更新闭环

集成测试应建立真实 stateful 或 stdio 连接,先调用 `ListToolsAsync` 确认只有工具 A。服务端原子加入工具 B 并发送通知,客户端处理器应被调用,随后第二次 list 返回 A、B。再依次测试更新 B 的描述、禁用 A 和删除 B。

测试日志至少记录能力协商结果、通知方法、通知时间、重新 list 的请求和响应版本。还要验证 stateless 模式不会被错误宣称支持主动通知,以及刷新失败时旧快照保留并可重试。多实例测试则从节点 A 修改,确认连接节点 B 的客户端也收到更新。

常见错误

第一种错误是在 stateless Server 中发送通知,忽略了没有持久投递通道。第二种错误是客户端仅打印通知,没有重新调用 ListToolsAsync。第三种错误是服务端在更新注册表之前通知,造成客户端刷新仍看到旧列表。第四种错误是只更新 UI 列表,却没有同步模型实际使用的工具快照。

第五种错误是动态删除工具后仍允许 call 路由调用旧委托。列表发现与调用分派必须读取同一权威快照,并且调用阶段继续鉴权。把这些一致性要求纳入注册表,而不是散落在控制器和 UI 中,能显著降低竞态。

结论

C# MCP Server 的动态工具更新依赖一个明确闭环:在 stateful HTTP 或 stdio 下更新权威注册表,发送 `ToolListChangedNotification`,客户端注册处理器并重新 `ListToolsAsync`,最后原子替换实际供 UI 和模型使用的工具集合。stateless HTTP 需要采用轮询、TTL 或外部事件等替代方案。

生产实现还要处理批量变更、多实例广播、授权缓存、known tools、在途调用和 schema 兼容。通知只是缓存失效信号,真正决定能力状态的始终是刷新后的 tools/list 响应与调用时的服务端鉴权。

相关文章

精彩推荐