fix: send an empty-string pagination cursor verbatim on every list adapter (#2220)

An MCP cursor is an opaque string: the spec constrains neither its content
nor its length, so `""` is a `nextCursor` a server may legitimately return
and the client is required to send back verbatim.

`listPrompts`, `listResources`, `listResourceTemplates` and
`listRequestorTasks` built their request params with `cursor ? { cursor } : {}`,
a truthiness check that cannot distinguish "no cursor" from "the cursor is
the empty string". Against a server that paginates with `""` they dropped it
and re-requested page one — a list that silently stops at the first page, or
a walk that loops on it forever. Nothing surfaced an error, because the
request was well-formed; it just asked the wrong question. `listTools` (and
the later `listSkills`) already used `cursor !== undefined`; this brings the
other four into line.

`getReplayableParams` in `protocolReplay.ts` existed partly to mirror the
asymmetry — its `method === "tools/list" || params.cursor !== ""` special
case collapses to `typeof params.cursor === "string"` now that the adapters
agree, and its long comment is updated rather than left describing a state
that no longer exists.

Coverage:

- `inspectorClient-list-cursor.test.ts` asserts the outbound params of all
  five adapters — `""` carried, an absent cursor sending no `cursor` key,
  a non-empty cursor forwarded unchanged. `listTools` is the control.
- `test-servers/configs/empty-cursor-http.json` (`emptyStringCursor`) hands
  out `""` as the cursor for page two, the shape no other fixture produces,
  and `empty-cursor.test.ts` walks all three of its lists end to end.
- Removing the fix from any one adapter fails exactly its two cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2UfrkQZvtrviMtqURRm4
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-06 13:24:27 -04:00
commit 9b35e530fc

View file

@ -3045,7 +3045,13 @@ export class InspectorClient extends InspectorClientEventTarget {
throw new Error("Client is not connected");
}
const result = await this.client.request(
{ method: "tasks/list", params: cursor ? { cursor } : {} },
{
method: "tasks/list",
// `!== undefined`, not truthiness: a cursor is opaque and `""` is a
// legal value a server may hand back. Dropping it asks for page one
// again, so a caller walking pages would loop on the first page.
params: cursor !== undefined ? { cursor } : {},
},
ListTasksResultSchema,
this.getRequestOptions(),
);
@ -5242,7 +5248,10 @@ export class InspectorClient extends InspectorClientEventTarget {
const effectiveMeta = this.mergeMeta(metadata);
const params: ListResourcesRequest["params"] = {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
...(cursor ? { cursor } : {}),
// `!== undefined`, not truthiness: a cursor is opaque and `""` is a
// legal value a server may hand back. Dropping it asks for page one
// again, so a caller walking pages would loop on the first page.
...(cursor !== undefined ? { cursor } : {}),
};
const response = await this.invokeMcpClient(() =>
this.client!.request(
@ -5416,7 +5425,10 @@ export class InspectorClient extends InspectorClientEventTarget {
const effectiveMeta = this.mergeMeta(metadata);
const params: ListResourceTemplatesRequest["params"] = {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
...(cursor ? { cursor } : {}),
// `!== undefined`, not truthiness: a cursor is opaque and `""` is a
// legal value a server may hand back. Dropping it asks for page one
// again, so a caller walking pages would loop on the first page.
...(cursor !== undefined ? { cursor } : {}),
};
const response = await this.invokeMcpClient(
() =>
@ -5480,7 +5492,10 @@ export class InspectorClient extends InspectorClientEventTarget {
const effectiveMeta = this.mergeMeta(metadata);
const params: ListPromptsRequest["params"] = {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
...(cursor ? { cursor } : {}),
// `!== undefined`, not truthiness: a cursor is opaque and `""` is a
// legal value a server may hand back. Dropping it asks for page one
// again, so a caller walking pages would loop on the first page.
...(cursor !== undefined ? { cursor } : {}),
};
const response = await this.invokeMcpClient(() =>
this.client!.request(