tools/list 网络语义
Day 53 把第一个 Zod 工具注册进了 server。但「注册了」不等于「客户端能正确发现」——发现走的是 tools/list,而在 stateless 规范下,tools/list 有它特定的网络语义:任意节点都能回完整清单、客户端可按 TTL 缓存、工具顺序必须稳定。今天用 MCP Inspector 真连上 server,调 tools/list,核对返回的工具数/名称/顺序与进程内
阶段: B6 · 真 MCP server (2026-07-28 spec)(Day 51-60) 标签: #mcp #tools-list #caching #mcp-inspector
今日导引(由浅入深)
Day 53 把第一个 Zod 工具注册进了 server。但「注册了」不等于「客户端能正确发现」——发现走的是 tools/list,而在 stateless 规范下,tools/list 有它特定的网络语义:任意节点都能回完整清单、客户端可按 TTL 缓存、工具顺序必须稳定。今天用 MCP Inspector 真连上 server,调 tools/list,核对返回的工具数/名称/顺序与进程内 list() 一致。这是 B6 从「能起 server」走向「契约可对拍」的关键一步:顺序稳定是缓存与 mock↔真 server 对拍的前提。今日最小可判定产出:Inspector 截图,tools/list 返回 ≥1 工具(Day 53 注册的工具)。
0. 协议背景速记
- 承上:Day 53 注册了第一个 Zod 工具;但「注册了」不等于「客户端能正确发现」。
- 本日:用 MCP Inspector 真连 server 调
tools/list,核对数量/名称/顺序与进程内list()一致。 - 判定点:Inspector 截图,
tools/list返回 ≥1 工具。
1. 机理精读
stateless 下 tools/list 是无状态发现:因为 server 不维护会话,tools/list 对所有客户端、从任意节点返回的结果都应当相同——它是一个纯发现操作。两个直接性质:
- (1) 可缓存:客户端可以按 TTL(time-to-live)把工具清单缓存起来,不必每次请求前都重拉,省一次网络往返。
- (2) 可水平扩容:LB 后面任意一台 server 都能回完整清单,不需要粘性路由。
这正是 Day 51 讲 stateless 好处时承诺的「tools/list 结果可被客户端缓存」的落地。
契约关键是排序稳定。tools/list 返回的工具顺序必须稳定且确定。为什么这条这么重要?两个下游都依赖它:
- 缓存一致性:客户端缓存的是一份有序清单;如果同一份工具集每次
tools/list顺序不同,客户端没法判断「清单变了还是只是顺序抖动」,缓存失效逻辑会误判。 - mock ↔ 真 server 对拍:我们有进程内 mock(
toolRegistry.ts的list())和网络版两套实现。要确认网络版没改变契约,得拿两边的tools/list输出逐条比对——如果顺序不稳定,对拍会因「顺序不同」而误报差异,掩盖真正的问题。
所以「网络 tools/list 的工具顺序须与进程内 list() 一致」是今天的硬契约。实现上靠确定性排序保证(见第 2 节 list() 的 localeCompare),而不是依赖 Map 的插入顺序——后者会随注册时序变化。
stateless 的发现刷新策略:
- stateless 客户端不能假设 server 会主动推送
listChanged通知来告诉它「工具变了」。 - 那需要 server 维护会话 + SSE 推送,与 stateless 的无会话前提相悖。
- 正确做法是客户端靠 TTL 主动重拉:缓存到期就重新
tools/list。 - 代价:工具集变化的感知有 TTL 级延迟,换来的是无状态可扩容——这是 stateless 在「实时性」上的明确取舍。
与相邻概念的边界:
tools/list只负责「有哪些工具、各自的契约」,不执行任何工具——执行是明天 Day 55 的tools/call。- 今天确认的是发现层契约:数量、名称、
inputSchema(Day 53 的 Zod 派生)、以及顺序稳定。
2. 推导 / 手算 / 代码走读
进程内 list() 的排序保证(Read src/agent/mcp/toolRegistry.ts,真实已存在):
list()(line 169):
关键在list(): McpToolSpec[] { return [...this.tools.values()].map((t) => t.spec) .sort((a, b) => a.name.localeCompare(b.name)) }.sort((a, b) => a.name.localeCompare(b.name))——按 name 字典序稳定排序。这就是「排序稳定」契约的进程内实现:不管工具注册进Map的先后,list()永远按 name 升序输出。- 注释(line 166-167)写明 "tools/list — 工具发现。返回稳定排序(按 name)的声明列表。stateless:不依赖任何会话,结果可被客户端按 ttl 缓存。"——进程内 mock 已经把「稳定排序 + 可缓存 + 无会话」三性质都标注到位。
tools/list 返回里每条工具长什么样:除了 name,还携带 description 与 inputSchema(即 Day 53 由 Zod 派生的 JSON Schema)。也就是说今天 Inspector 上看到的不只是工具名,而是 LLM 客户端将来用来「决定调哪个工具、传什么参数」的完整契约——description 是自然语言提示、inputSchema 是参数约束。校验 tools/list 时除了核对 name 与顺序,也应顺带确认 inputSchema 与 Zod 声明一致。
对拍逻辑(手推):假设 Day 53 注册了工具 assessTypology,进程内 list() 返回 ["assessTypology"](只有 1 个时排序无歧义)。网络版 tools/list 经 Inspector 调出,应当返回同样的 name 集合 + 同样的顺序。判定步骤:
- 进程内:
registry.list().map(s => s.name)→ 得有序 name 数组 A。 - 网络版:Inspector 调
tools/list,取result.tools.map(t => t.name)→ 得有序 name 数组 B。 - 断言
A 深度等于 B(同元素 + 同顺序)。若注册了多个工具,这一步才真正检验排序稳定性。
handle(req)里tools/list分支(line 201-203):return { ...base, result: { tools: this.list() } }——网络信封里result.tools直接来自list(),所以排序保证是从list()一路继承到网络响应的。
排序为何用 localeCompare 而非默认 sort():
- 默认
Array.prototype.sort()不传比较函数时按字符串 UTF-16 code unit 排,跨工具名可能给出反直觉次序。 localeCompare给确定且符合语言习惯的字典序,工具名稳定可预测——这对「客户端缓存 + 对拍」更友好。- 关键不是「用哪种序」,而是「同一份工具集每次都给同一个序」——确定性才是契约本身。
多工具时的对拍才有意义:单个工具 ["assessTypology"] 排序无歧义;当 Day 56 注册 assessCase/draftSar/listTypologies 三个工具后,list() 会稳定输出 ["assessCase","draftSar","listTypologies"](按字典序),此时网络版若给出别的顺序,对拍立刻报错——这才是排序契约真正发挥作用的场景。
3. 今日实战
- 启动 Day 52/53 起的本地 server(确保 Day 53 的 Zod 工具已注册)。
- 用 MCP Inspector 连上:
npx @modelcontextprotocol/inspector,在 UI 里填本地 server 的 transport(Streamable HTTP)与 endpoint。 - 在 Inspector 里点
List Tools(即调tools/list),截图返回的工具数与名称。 - 核对两点:(a) 工具数量与你注册的一致;(b) 顺序与
toolRegistry.ts的list()输出一致(按 name 字典序)。 - 可选对拍脚本:导出 Inspector 返回的
name数组,与registry.list().map(s=>s.name)做深度相等断言。 - 用最新版 Inspector,确保支持 2026-07-28 stateless 语义。
4. 今日实测 / 产出
- 待建——Inspector 截图:
tools/list返回 ≥1 工具(Day 53 注册的assessTypology)。 - 本地可验,无需 key。
- 可附一条对拍记录:进程内
registry.list().map(s=>s.name)与 Inspector 返回name数组深度相等(顺序敏感)。
对拍的工程意义:今天确立的「网络 tools/list == 进程内 list()」不是形式主义。它让我们在后面 Day 56 把 AML 工具集批量上网时,能用进程内 mock 作为「金标准」对照——网络版多/少/错序了任何一个工具,对拍立刻暴露。没有这条契约,工具集越大越难保证「客户端看到的」与「我们以为暴露的」一致。
TTL 怎么定:
- TTL 太长 → 工具集更新后客户端长时间用旧清单(漏掉新工具、调到已下线工具)。
- TTL 太短 → 退化成「每次 call 前都重拉 list」,缓存形同虚设、白白增加往返。
- 实践上按工具集变更频率定:稳定的 AML 工具集可给较长 TTL(分钟级到小时级),快速迭代期给短 TTL;关键是客户端必须有「缓存过期重拉」逻辑,而非永久缓存。
5. 常见误区 / 陷阱
- 假设 server 会主动推
listChanged:stateless 客户端不能依赖推送,应靠 TTL 主动重拉。 - 忽视排序稳定:工具多了之后,若网络版顺序与
list()不一致,缓存/对拍会误判——务必核对顺序,不只核对集合。 - 依赖
Map插入顺序当排序:插入顺序随注册时序变化,不是确定性的;必须显式sort(list()用localeCompare)。 - 用旧版 Inspector:旧 Inspector 可能不懂 2026-07-28 stateless 语义,会误报握手/会话问题;用最新版。
- 把
tools/list当成执行:它只发现不执行,看到工具不代表tools/call一定通——那是明天 Day 55 的事。
6. 学习资源(每条带 YYYY-MM)
- MCP Specification — tools 章节(
tools/list网络语义 / 排序 / 缓存,草案 2026-07-28 预定定稿) - MCP Inspector —
npx @modelcontextprotocol/inspector官方调试工具文档(随 spec 升级,用最新版) src/agent/mcp/toolRegistry.ts的list()(2026-06)——稳定排序(按 name)实现,对拍基线- MCP TypeScript SDK — server 文档(2026-03)
- MDN —
String.prototype.localeCompare()(确定性字典序参考,长期有效)
7. 一句话回顾
- 发现层契约:stateless
tools/list= 无状态发现,对所有客户端/任意节点返回相同清单,可按 TTL 缓存。 - 硬约束:排序稳定(
list()用localeCompare按 name 排)——这是缓存一致性与 mock↔真 server 对拍的前提。 - 刷新策略:客户端靠 TTL 主动重拉,不依赖 server 推
listChanged。 - 诚实状态:Inspector 截图为「待建」,本地可验、无需 key。
8. 在 B1→B18 能力曲线上的位置
- B6 三件套之一:MCP 协议核心是 list(发现)/ call(执行)/ error(失败)三件套。今天攻下发现层,明天 Day 55 攻执行+错误层。
- 承接 Day 53:Zod 派生的
inputSchema正是tools/list返回里每条工具携带的契约——今天验证它能被客户端正确发现。 - 通向 Day 56:今天确立的「网络 list == 进程内 list()」对拍契约,是 Day 56 批量上网 AML 工具时的金标准对照手段。
- 一句话:发现层是 LLM 客户端「知道有哪些工具可用」的入口,排序稳定则是这个入口可缓存、可对拍的工程前提。
9. 给 hiring manager 的一句话价值
- 「我能把同一套工具集在进程内 mock 与远端 MCP server 两处暴露,并用排序稳定契约自动对拍——保证客户端看到的工具清单与我声明的完全一致,这是工具集规模化后避免静默契约漂移的关键。」
SOTA检查 (2026-06 更新)
- 当前主流:stateless
tools/list+ 客户端 TTL 缓存 + 稳定排序 + Inspector 对拍,仍是 SOTA 调试/验证路径。 - 是否仍 SOTA:MCP Inspector 随 spec 升级;用最新版以支持 2026-07-28 stateless 语义。
- 过时黑名单(AVOID):假设 server 会主动推送
listChanged通知——stateless 客户端应靠 TTL 主动重拉。 - 下次复查点:07-28 规范定稿后复验
tools/list是否新增字段;Inspector 当周用最新版。
衔接
- 昨天:Day 53 — Zod 工具 schema vs 现有 JsonSchema 子集(用 Zod 注册第一个工具)
- 今天:Inspector 调
tools/list,验证网络发现层契约(数量/名称/顺序)与进程内list()一致 - 明天:Day 55 — tools/call 网络往返 + content 封装(真正执行工具,验证
-32602失败路径与result.content成功路径)