登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  科技周边 >  人工智能

MCP 客户端怎样处理工具列表变化而不缓存旧 schema

来源:17golang原创

时间:2026-11-14 04:25:25 320浏览 收藏

MCP 客户端如果把首次 tools/list 的结果永久放在内存里,服务端新增、删除或修改工具后,模型仍可能看到旧的 inputSchema,最终把已经不接受的参数交给 tools/call。正确的做法是把工具列表视为可失效目录:首次连接完整拉取并保存快照,收到 notifications/tools/list_changed 后重新拉取,再用新的完整快照替换旧目录。

官方资料入口:https://modelcontextprotocol.io/specification/2026-07-28/server/tools

通知本身不携带新的 schema;它只是“缓存可能过期”的信号。客户端还要处理分页、缓存提示、刷新并发和调用前校验,才能避免刷新过程中的旧数据穿透到模型上下文或工具调用链。

先分清三个对象:目录、快照和模型上下文

排查“工具已经改了但客户端还在用旧参数”时,先不要只看工具名。一个可靠的客户端至少要区分三层状态:

对象保存内容失效依据
工具目录一次或多页 tools/list 的完整结果list_changed、TTL 到期、连接或权限上下文改变
schema 快照工具名、描述、inputSchema、可选 outputSchema 与目录代次完整刷新成功后整体替换
模型上下文传给模型选择工具的当前定义不能直接沿用旧快照,必须跟随目录代次重建

这里的关键不是给每个工具挂一个永久缓存,而是让“当前快照”拥有明确的代次。只更新某个工具而保留旧的分页结果,容易把删除的工具、旧参数和新参数混在一起。

MCP 客户端从 tools/list 分页结果建立工具目录和 schema 快照的静态关系说明图
图1:工具目录、分页响应与 schema 快照的静态关系说明图,不是运行截图或执行证据。

完整拉取 tools/list,最后一页才替换快照

tools/list 支持用 cursor 分页。客户端应先把所有页面收集到临时数组,只有确认 nextCursor 为空时,才将临时结果发布成新快照。否则刷新到一半就替换,会让模型看到不完整的工具集合。

2026-07-28 规范还允许列表结果携带 ttlMs 和 cacheScope。它们可以帮助客户端决定缓存多久、是否跨授权上下文共享,但不能替代变更通知:收到失效通知后应立即把条目标记为 stale,下一次刷新绕过旧快照。

type Tool = {
  name: string;
  description?: string;
  inputSchema: Record;
  outputSchema?: Record;
};

type ToolPage = { tools: Tool[]; nextCursor?: string; ttlMs?: number; cacheScope?: string };

async function loadToolSnapshot(requestPage: (cursor?: string) => Promise) {
  const collected: Tool[] = [];
  let cursor: string | undefined;
  let cacheHint: Pick = {};

  do {
    // 每页只加入临时集合,避免半套目录提前暴露给模型。
    const page = await requestPage(cursor);
    collected.push(...page.tools);
    cacheHint = { ttlMs: page.ttlMs, cacheScope: page.cacheScope };
    cursor = page.nextCursor;
  } while (cursor);

  // 只有完整分页结束,调用方才应发布新的不可变快照。
  return { tools: collected, cacheHint, loadedAt: Date.now() };
}

这段代码是客户端适配层的最小骨架。生产实现还应限制最大页数、记录最后一个 cursor,并在分页请求失败时保留上一份可用快照,而不是把半成品当成新目录。

把 list_changed 当成失效信号,而不是新 schema

服务端声明 tools.listChanged 能力后,工具增加、删除或元数据变化可以通过 notifications/tools/list_changed 通知客户端。通知没有新的工具数组,也没有新的 inputSchema,所以处理器只应做两件事:标记当前目录过期,提交一次可合并的刷新任务。

连续通知很常见,例如服务端先注册工具、再更新描述、最后启用工具。每个通知都启动一个刷新请求,会造成旧请求晚返回并覆盖新结果。可以用单飞锁或刷新代次解决:刷新开始时记下代次,发布结果前确认它仍是当前代次;旧请求即使成功,也只能丢弃。

let catalogGeneration = 0;
let refreshTask: Promise | undefined;
let currentTools: Tool[] = [];

function onToolListChanged() {
  // 通知只说明旧目录可能失效,不把通知内容当成工具定义。
  catalogGeneration += 1;
  refreshTask ??= refreshCatalog(catalogGeneration).finally(() => {
    refreshTask = undefined;
  });
  return refreshTask;
}

async function refreshCatalog(startGeneration: number) {
  const snapshot = await loadToolSnapshot(requestPage);
  // 老刷新请求不能覆盖已经更新过的目录。
  if (startGeneration !== catalogGeneration) return;
  currentTools = snapshot.tools;
  rebuildModelToolContext(currentTools, catalogGeneration);
}

如果通知发生在刷新进行中,不要简单地忽略它。可以让刷新任务结束后再次检查 dirty 标记,或者让生成代次与当前代次不一致时自动补刷一次。目标是最终收敛到最后一次完整目录,而不是追求每个通知都对应一个独立请求。

MCP 服务端 list_changed 通知与客户端刷新队列、目录代次和 tools call 校验的静态关系说明图
图2:list_changed、刷新队列和调用前版本校验的静态关系说明图,不是运行截图或执行证据。

tools/call 前再做一次 schema 代次校验

只刷新模型上下文还不够。真实系统里可能存在并发请求、消息队列延迟或多个 MCP server,某个调用任务拿到的工具定义可能已经不是当前版本。调用前至少检查工具是否存在、引用的目录代次是否仍然有效,以及参数是否通过当前 inputSchema 的本地校验。

async function callWithFreshSchema(
  toolName: string,
  args: unknown,
  expectedGeneration: number,
) {
  const tool = currentTools.find(item => item.name === toolName);
  if (!tool) throw new Error(`工具不存在或已被移除:${toolName}`);

  // 代次变化后先刷新,禁止旧模型上下文直接发出调用。
  if (expectedGeneration !== catalogGeneration) {
    await onToolListChanged();
    throw new Error("工具目录已更新,请用新 schema 重新生成参数");
  }

  // 生产代码应接入 JSON Schema 校验器;这里保留失败边界。
  validateJsonSchema(tool.inputSchema, args);
  return mcpCall("tools/call", { name: tool.name, arguments: args });
}

服务端仍然必须做最终校验,客户端校验只是减少明显的旧 schema 调用和更容易理解的错误。不要把服务端返回的“未知参数”当成刷新成功的证据;它可能来自权限差异、工具实现自身的版本或参数业务规则。

调试时按证据区分四类问题

  • 通知没到:检查服务端是否声明 tools.listChanged,以及当前协议/传输是否需要客户端主动订阅变化流。
  • 刷新没完成:查看分页 cursor 是否收敛、最大页数是否触发、请求超时后是否错误地清空了旧快照。
  • 快照被覆盖:给刷新任务记录 generation、开始时间和发布结果,确认较早请求没有晚到覆盖较新目录。
  • 权限视图不同:工具列表可能随请求携带的授权而不同,缓存键不能只用 server 地址,还要包含授权上下文或安全的分区标识。

建议日志至少包含 serverIdentity、授权分区、目录 generation、工具数量、工具名集合摘要、刷新原因、nextCursor 次数和 schema 指纹。日志里不要记录完整密钥、用户隐私或工具参数中的敏感值。

一份可以落地的检查清单

  1. 首次连接是否完成了 tools/list 的全部分页?
  2. 是否把通知理解为“重新拉取”的信号,而不是直接当成新目录?
  3. 刷新是否先写临时快照,分页结束后再一次性发布?
  4. 连续通知是否会合并,旧刷新结果是否不能覆盖新代次?
  5. 模型上下文、调用任务和本地 schema 校验是否共享同一目录代次?
  6. 缓存是否按 server 身份与授权上下文隔离,并尊重 ttlMs 和 cacheScope?
  7. 服务端参数错误、权限差异、刷新失败和通知缺失是否有不同日志?

只要把“工具列表”当成带代次的可失效目录,MCP 客户端就能把动态工具变化变成可控的刷新问题:通知负责让缓存失效,tools/list 负责提供新事实,调用前校验负责挡住并发期间残留的旧 schema。

相关问题

list_changed 通知里会直接带新工具列表吗?

不会。它只表示工具列表发生变化,客户端需要再次请求 tools/list 才能获得当前内容。

收到通知后能不能只刷新变化的一个工具?

除非你的服务端另有可靠的增量协议,否则不建议。标准的安全基线是重新获取完整目录并原子替换快照。

客户端校验通过就可以不校验服务端吗?

不可以。客户端缓存可能过期,授权上下文也可能不同,服务端必须把客户端输入视为不可信并执行最终校验。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>