AI 技术
#MCP#分页游标#tools/list#客户端实现#协议契约

MCP 工具结果分页游标:服务端返回 nextCursor 后客户端仍拿不到完整结果

从远程 MCP Server 返回超长列表型工具结果的场景出发,解释 tools/list 与 resources/list 的游标分页契约,说明客户端如何携带 cursor 续拉并合并,分析忽略 nextCursor、跨会话复用游标、游标非单调导致的重复与缺失,并给出校验点与可观测指标。

一个只有 8 个工具的服务器

假设你维护一个远程 MCP Server,它把代码仓库索引成知识图谱,对外暴露 14 个工具。你在客户端里连接它,打开工具列表,只看到 8 个。重启客户端、重新连接、切换网络,结果不变。你直接对服务器发一次 tools/list,返回的确实是 8 个工具,但响应里多了一个字段:nextCursor。再带上这个游标请求一次,剩下 6 个工具才出现。

这不是服务器少返回了数据,而是分页契约没有被走完。MCP 的列表类操作采用游标分页,服务器一次只给一页,并在还有后续内容时返回 nextCursor。客户端必须拿着这个游标再请求,直到响应里不再出现 nextCursor,然后把各页结果合并。只发一次请求的客户端,看到的就是第一页。

这个失败模式有一个公开案例。Cursor 社区论坛中有一份缺陷报告记录:某 MCP Server 以每页 8 个的粒度返回 14 个工具,客户端只注册了第一页的 8 个,第二页的 6 个在工具设置界面和代理中都不可见;报告者用 stdio 直接探测服务器,两次 tools/list 分别返回 8 个和 6 个,合计 14 个。项目维护者回复确认,客户端只发送了一次 tools/list,没有跟进 nextCursor,并把它作为客户端缺陷记录。

分页契约规定了什么

MCP 规范把分页定义在列表类操作上,涉及 resources/list、resources/templates/list、prompts/list 和 tools/list。规范给出的模型是游标分页,不是页码分页:服务器返回当前页结果,如果还有更多内容,就附带一个 nextCursor 字段;客户端把这个值原样放进下一次请求的 params.cursor 里。

请求和响应的形状很直接。第一次请求不带游标:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {}
}

服务器返回一页工具和一个游标:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [ { "name": "get_weather" } ],
    "nextCursor": "eyJwYWdlIjogMn0="
  }
}

客户端续拉时把游标放回去:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/list",
  "params": { "cursor": "eyJwYWdlIjogMn0=" }
}

规范对游标的性质有明确约束。游标是不透明字符串,代表结果集中的一个位置。客户端不得假设游标格式,不得解析或修改游标,也不得跨会话持久化游标。页大小由服务器决定,客户端不得假设固定页大小。服务器方面,规范建议提供稳定游标,并妥善处理无效游标;无效游标应返回 -32602(Invalid params)错误。客户端方面,规范要求把缺失的 nextCursor 视为结果结束,并同时支持分页与非分页两种流程。

这些约束合在一起,构成一个循环:请求、取页、检查游标、有游标就续拉、无游标就停止并合并。任何一步缺失,都会让结果不完整或重复。

客户端为什么容易漏掉续拉

列表操作在 MCP 里是低频调用。工具列表通常在连接建立后拉一次,缓存起来,之后只在收到 notifications/tools/list_changed 时刷新。这种使用方式让开发者容易把 tools/list 当成一次请求就结束的调用,忽略响应里可能存在的 nextCursor。

从实现角度看,漏掉续拉有两种典型形态。一种是代码里根本没有循环,只解析第一页的 tools 数组,把 nextCursor 丢掉。另一种是循环写对了,但退出条件写错,例如只在 nextCursor 为 null 时退出,而服务器在最后一页返回的是字段缺失而不是 null,循环就多跑一轮,或者干脆不退出。规范说的是“缺失 nextCursor 视为结束”,判断条件应当是字段不存在。

还有一种更隐蔽的情况:客户端把分页结果合并后,用工具名做去重键。如果服务器游标实现有缺陷,两页之间出现重叠工具,去重能掩盖重复,但掩盖不了缺失。缺失的工具不会出现在任何一页里,客户端也无从发现。

一个贯穿全文的续拉流程

把上面的场景固定下来:远程 MCP Server 暴露 14 个工具,页大小 8。客户端连接后拉取工具列表,用于构建模型可见的工具集合。下面这张图描述客户端应当执行的流程。

flowchart TD
    A[客户端发起 tools/list 不带游标] --> B[服务器返回一页工具]
    B --> C{响应中存在 nextCursor}
    C -- 是 --> D[把 nextCursor 写入 params.cursor]
    D --> E[再次发起 tools/list]
    E --> B
    C -- 否 --> F[合并各页工具并按名称去重]
    F --> G[校验工具总数与服务器声明是否一致]
    G --> H[注册工具集合供模型调用]

图里的关键转折点在判断节点。它检查的是字段是否存在,而不是字段值是否为空。第二页返回的 6 个工具没有 nextCursor,循环在这里退出,合并后的集合是 14 个。

合并阶段需要处理去重。工具用 name 唯一标识,按名称去重是合理选择。但去重只能消除重复,不能补齐缺失,所以合并之后还需要一个校验步骤。

游标失效与结果错乱的三种成因

分页失败不只发生在客户端漏掉续拉。即使客户端正确循环,服务器端的游标实现也可能让结果重复或缺失。

跨会话复用游标。 规范明确要求客户端不得跨会话持久化游标。如果客户端把游标存进本地缓存,下次启动时直接用它续拉,服务器可能已经重建了结果集,旧游标指向的位置失去意义。服务器应当对无效游标返回 -32602,客户端收到这个错误时应当重新从第一页开始,而不是把错误当成空结果。

游标非单调。 游标代表结果集中的一个位置。如果服务器用可变排序键生成游标,例如按修改时间排序,而排序键在分页过程中发生变化,同一个游标可能指向不同的记录。结果是某些工具在两页中重复出现,另一些工具被跳过。规范建议提供稳定游标,正是为了约束这种行为。

结果集在分页期间变化。 工具列表本身可能因为服务器加载了新的工具模块而变化。如果服务器在分页过程中重新计算列表,页与页之间的一致性就没有保证。声明了 listChanged 能力的服务器应当在列表变化时发送 notifications/tools/list_changed,客户端收到通知后重新拉取整个列表,而不是继续用旧游标续拉。

这三种成因的共同点是:客户端无法从单页响应里看出问题。它必须依赖服务器返回的错误码、通知,以及自己合并后的校验。

一次性全量返回与资源引用

面对分页问题,有两种替代方案值得比较。

第一种是让服务器一次性返回全部结果,不返回 nextCursor。社区案例里,维护者给出的临时方案就是这个:把页大小调到大于工具总数,或者在服务器前面加一个代理,循环调用 tools/list 并把各页合并成单个响应。这个方案对客户端要求最低,任何客户端都能拿到完整列表。代价是响应体积随工具数量线性增长,远程连接下首字节时间和传输量都会上升。工具数量很大时,单个响应可能超出客户端的消息大小限制。

第二种是用资源引用替代列表。工具结果可以返回 resource_link 类型的内容,指向一个可被客户端读取的 URI。对于超长列表,服务器可以只返回一个资源链接,客户端按需读取。规范里有一条需要注意的约束:工具返回的资源链接不保证出现在 resources/list 的结果中。也就是说,客户端不能假设“工具返回过的资源”一定能在资源列表里找到,资源列表本身也可能分页。

方案客户端复杂度单次响应体积结果完整性适用场景
游标分页需要循环续拉与合并受页大小约束取决于客户端是否走完循环工具或资源数量大、远程连接
一次性全量返回单次请求即可随总数线性增长单次响应内完整总数可控、本地或低延迟连接
资源引用需要额外的读取步骤单次响应很小取决于资源读取是否成功内容体积大、需要按需加载

这张表里没有绝对更优的选项。游标分页把复杂度放在客户端,换来可控的单次响应体积。一次性返回把复杂度从客户端移走,代价是响应体积。资源引用适合内容本身很大的情况,但它引入了额外的读取失败路径。

校验点与可观测指标

要判断分页是否被正确走完,需要在客户端和服务端两侧设置校验点。

客户端侧,合并完成后可以检查三件事。第一,合并后的工具名称集合是否有重复;有重复说明游标实现可能非单调。第二,合并后的工具数量是否与服务器在初始化或其他渠道声明的数量一致;不一致说明有页被跳过。第三,续拉次数是否超过合理上限;如果服务器游标实现有缺陷导致循环不退出,这个上限能防止客户端卡死。

服务端侧,可以记录每次 tools/list 请求是否携带游标、返回的条目数和是否附带 nextCursor。当同一客户端在短时间内反复从第一页开始请求,或者大量请求携带无效游标并返回 -32602,说明客户端的分页逻辑有问题,或者游标在会话之间被错误复用。

可观测指标可以围绕几个量展开:每次列表拉取的页数、每页条目数、合并后去重前后的条目数差、-32602 错误率、以及从连接建立到工具集合就绪的耗时。去重前后的差值持续大于零,是游标非单调的强信号。页数分布如果长期只有一页而服务器实际有多页数据,说明客户端没有续拉。

边界与尚未解决的问题

游标分页的有效性依赖几个前提。服务器必须提供稳定游标,客户端必须走完循环,双方对“缺失 nextCursor 表示结束”的理解必须一致。任何一个前提不成立,结果就可能不完整。

规范本身留下了一些实现空间。页大小由服务器决定,客户端无法控制单页返回多少条目,也无法在请求里指定期望页大小。游标的生成方式没有规定,服务器可以用偏移量、主键或编码后的状态,只要保证稳定即可。这意味着不同服务器的分页行为差异可能很大,客户端只能依赖规范给出的不透明游标约定,不能做任何格式假设。

当客户端确实无法修改时,代理是一个可行路径:在服务器前面放一个中间层,循环调用 tools/list 并合并成单个响应,对下游客户端隐藏分页。这个方案能解决客户端漏拉的问题,但它把分页状态从客户端移到了代理,代理本身也需要处理游标失效和结果集变化。

对于使用托管网关的场景,服务器分页行为不可控,客户端又暂时不支持续拉,代理往往是当下唯一能落地的选择。这属于工程折中,不是协议层面的解法。

资料来源

  1. MCP Specification - Tools
  2. MCP Specification - Resources
  3. MCP Specification - Pagination
  4. MCP: Cursor only registers the first page of a paginated tools/list (doesn't follow nextCursor) — 14-tool server shows 8 - Bug Reports - Cursor - Community Forum