你刚把一个 MCP Server 接进 Agent,测试时一切正常。第二天,Server 新增了 Tool,模型却看不到;另一个 Server 也提供 search,结果调用错了对象;再过一会儿,远程 Server 卡住,整个对话跟着停住。

这些问题其实指向同一个误区:把 MCP Tool 当成"启动时读取一次、之后永远不变的本地函数"。更稳妥的做法,是把它视为动态远程依赖:会变化、会冲突、会变慢,也会暂时消失。客户端要管理的,是 Tool 的生命周期。
Tool 列表变了,别重启整个 Agent
MCP Server 可以声明支持 Tool 列表变化通知;当列表变化时,可发送 notifications/tools/list_changed。客户端收到后,应重新调用 tools/list 获取当前 Tool 集合;如果结果分页,还要按照 nextCursor 拉完。
因此 Tool Registry 不该写死在启动逻辑里。初始化时加载一次,收到通知后局部刷新。
如果 Server 没声明 listChanged,也别假设 Tool 永远不变。可以在重连、会话重建、Server 版本变化时主动刷新,再用低频轮询兜底。也没必要每次模型推理前都 tools/list,否则发现机制本身就会制造延迟。
Schema 可以缓存,但缓存的是"版本"
Tool Schema 往往会进入模型上下文,频繁重新获取没有必要。但缓存不能只用 Tool 名作为键。
更可靠的方式是记录 Server 身份、Tool 原始名称和 Schema 指纹。把 inputSchema、描述等稳定序列化后计算 hash。重新拉取 Tool 列表时,hash 没变就复用;发生变化,再更新模型侧的 Tool 定义。
TTL 可以做保险,却不该成为唯一失效机制。收到 list changed、连接重建、Server 配置或版本改变时,都应重新校验。这样能避免长期使用脏 Schema。
多个 Server 都叫 search,冲突留给客户端解决
MCP 的 Tool 名称属于各自 Server 的上下文。冲突通常发生在客户端把多个 Server 的 Tool 合并后,一起暴露给模型。
假设 GitHub Server 有 search,知识库也有 search。直接把两个同名函数交给模型,路由就失去了确定性。
更好的办法是建立客户端命名空间,例如 github.search、kb.search,或生成稳定内部 ID:serverId/toolName。模型看到唯一名称,真正发送 tools/call 时,再由路由层映射回目标 Server 和原始 Tool 名。
不要用 search_2 这种随机编号。它没帮助模型理解两个 Tool 的区别。名称和描述都应该承担路由信息。
Server 不可用时,失败也要成为一种状态
远程 MCP Server 可能因为进程退出、网络问题、认证过期或限流而失联。只把异常抛给 Agent,往往会让模型反复调用同一个坏 Tool。
更稳的做法是给每个 Server 维护健康状态,例如 healthy、degraded、unavailable。连续失败达到阈值后进入熔断,暂时停止暴露该 Server 的 Tool;之后再探测,恢复时刷新 Tool 列表。
如果存在替代 Server,可以配置 fallback。没有替代路径,就明确告诉模型"该能力暂时不可用",而不是把重试当成恢复机制。
请求超时,不只是返回一个错误
MCP 规范建议为所有发出的请求设置超时,防止连接长期挂起和资源耗尽;超时后,发送方应停止等待,并可发送 notifications/cancelled。规范也建议支持按请求配置超时,即使收到 progress,也应保留最大超时上限。
实践中可以设置两层限制:软超时判断"还值不值得等",硬超时确保资源释放。不同 Tool 也应有不同预算,本地查询和复杂分析不必使用同一个阈值。
超时后更不能无脑重试。查询类 Tool 通常可以有限重试;付款、发邮件、创建工单这类有副作用的 Tool,如果第一次其实已经成功,自动重试可能制造重复操作。
把 MCP 做稳定,靠的是一套运行时规则
这五个问题可以收束成五条规则:Tool 动态发现,Schema 可失效缓存,名称建立命名空间,Server 维护健康状态,请求拥有明确时间预算。
如果你正在写 MCP Client,可以检查五件事:是否处理 Tool 列表变化;Schema 缓存能否主动失效;每个 Tool 是否有全局唯一内部名称;Server 失联后是否会熔断和恢复;每次调用是否定义了超时、取消与重试边界。
做到这些,MCP 才不只是"能接上工具",而会变成一个可以长期运行、能面对变化和故障的 Tool Runtime。
