MCP 的分页用的是不透明游标而不是页码:服务端在响应里返回 nextCursor,客户端原样回传,页大小完全由服务端决定,客户端绝不能假设一个固定值。落地结论是:服务端把游标做成”自包含、可校验、可失效”的字符串,在四个列表接口上统一实现;客户端把游标当黑盒,只以 nextCursor 缺失作为结束信号,并且必须同时兼容不分页的服务端。
协议规定:只有四个接口支持分页
按 MCP 规范(2026-07-28 修订版),支持分页的操作只有这四个:
resources/list—— 列出可用资源resources/templates/list—— 列出资源模板prompts/list—— 列出可用提示模板tools/list—— 列出可用工具
tools/call不在分页覆盖范围内。如果你的工具一次返回一万行数据,协议不会替你分页,必须在工具自己的入参里设计 limit / offset(或游标)参数,由你自己在工具内部裁剪。这是最常见的误解。请求与响应的最小形态
服务端响应带 nextCursor 即表示还有下一页:
{
"jsonrpc": "2.0",
"id": "123",
"result": {
"resultType": "complete",
"resources": [...],
"nextCursor": "eyJwYWdlIjogM30=",
"ttlMs": 300000,
"cacheScope": "public"
}
}
客户端把游标原样放进下一次请求:
{
"jsonrpc": "2.0",
"id": "124",
"method": "resources/list",
"params": { "cursor": "eyJwYWdlIjogMn0=" }
}
三个必须记住的细节:
- 没有
nextCursor就是最后一页,不要再发一次请求确认。 - 空字符串是合法游标,
""不能被当成”结束”。只能用”字段是否存在”判断,不能用”值是否为真”。 - 不要解析、不要修改、不要跨会话持久化游标。它的格式是服务端的实现细节,随时可能变。
服务端:三种游标设计怎么选
| 方案 | 做法 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 偏移游标 | base64 编码 {"offset":30} | 实现最简单,可跳页 | 数据变动时会重复或漏项 | 静态列表,如工具清单 |
| 键集游标 | 编码最后一项的排序键 {"last_id":"…"} | 数据插入不影响,稳定性好 | 需要稳定唯一排序键 | 数据库、文件列表 |
| 签名游标 | 上述任一 + HMAC 签名 + 过期时间 | 防篡改、可主动失效 | 多一层密钥管理 | 多租户、带权限过滤的列表 |
推荐起点是键集游标加签名。因为列表结果通常与权限相关,服务端需要能确认”这个游标是我签发的、没被改过、还没过期”。
import base64, hmac, hashlib, json, time, os
SECRET = os.environ["CURSOR_SECRET"].encode()
def make_cursor(last_id: str, ttl: int = 600) -> str:
payload = {"k": last_id, "e": int(time.time()) + ttl}
raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
sig = hmac.new(SECRET, raw.encode(), hashlib.sha256).hexdigest()[:16]
return f"{raw}.{sig}"
def read_cursor(cursor: str) -> str:
raw, _, sig = cursor.partition(".")
if not hmac.compare_digest(
hmac.new(SECRET, raw.encode(), hashlib.sha256).hexdigest()[:16], sig
):
raise InvalidParams("bad cursor") # → -32602
payload = json.loads(base64.urlsafe_b64decode(raw))
if payload["e"] < time.time():
raise InvalidParams("cursor expired") # → -32602
return payload["k"]
-32602(Invalid params)。不要静默返回空页——那会让客户端误以为已经取完,造成数据缺失。客户端:一个正确的翻页循环长什么样
async function listAllTools(client) {
const tools = [];
let cursor; // undefined = 首页
const seen = new Set();
while (true) {
const page = await client.request(
{ method: "tools/list", params: cursor ? { cursor } : {} },
ListToolsResultSchema
);
tools.push(...page.tools);
if (!page.nextCursor) break; // 关键:只在缺失时结束
if (seen.has(page.nextCursor)) break; // 防御重复游标导致的死循环
seen.add(page.nextCursor);
cursor = page.nextCursor;
}
return tools;
}
三个易错点:用 if (cursor) 判断结束(错,空串是合法游标)、假设每页 50 条(错,页大小由服务端定)、不设重复游标保护(服务端出 bug 时会无限循环)。
顺带可以带上的缓存提示
列表结果可以携带缓存提示:ttlMs 表示结果保鲜时长(0 = 不要缓存),cacheScope 取 public 或 private。对工具清单这类很少变动的列表,给一个 5–10 分钟的 ttlMs 能显著减少 tools/list 的重复请求;而含用户权限过滤的资源列表必须标 private,避免被共享缓存误发给其他用户。
分页、进度通知、工具内裁剪的分工
这三件事经常被混为一谈,实际职责完全不同:
三者职责对照(点击展开)
- 分页(nextCursor):解决”列表太长,一次返回不完”。只作用于四个 list 接口。
- 进度与取消(progressToken / notifications/cancelled):解决”单次调用耗时长,需要进度反馈和中断”。属于调用过程,与列表无关,可参考MCP 长任务的进度与取消。
- 工具内裁剪(limit / offset 入参):解决”工具返回的结果体太大”。由工具自己实现,协议不管。
如果你的 MCP 服务器是连数据库的,那么分页游标最好直接映射成数据库的键集查询条件,避免把全量结果读进内存再切片。这方面的连接池与只读约束,可以参考MCP 服务器连数据库的做法。另外,2026-07-28 之后协议已不再使用旧的握手流程,服务端能力通过 _meta 声明,细节见MCP 能力协商与版本兼容。
客户端可以自己指定每页返回多少条吗?
limit,而不是去改 tools/list。nextCursor 是空字符串时还有下一页吗?
nextCursor 字段是否缺失。服务端一定要实现分页吗?
nextCursor 即为不分页,一次返回全部结果。对工具数量少、资源固定的服务器完全可以不做,但客户端必须同时兼容两种流程。游标过期了应该怎么处理?
-32602,客户端捕获后应从头重新拉取,而不是重试同一个游标。同时客户端要限制最大页数,防止异常服务端导致的无限翻页。tools/call 返回的数据太大怎么办?
limit / offset 或游标参数,并在工具描述里写清楚默认上限。返回体过大还可以考虑改用嵌入式资源(resource)让客户端按需再取。一句话收尾:分页在 MCP 里是一件”服务端决定、客户端跟随”的事。服务端把游标做成可校验可失效的 token,客户端把它当黑盒并以字段缺失为终止条件,这条链路就不会出错。










评论 (0)