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
2 changed files with 447 additions and 39 deletions

View file

@ -103,27 +103,18 @@ describe("replayableParams", () => {
).toEqual({ params: { cursor: "abc" }, dropped: ["_meta"] });
});
// `listTools` builds its params with `cursor !== undefined`, carrying `""`
// deliberately — its own comment says dropping it asks for page one again.
it("keeps an empty cursor on tools/list, which preserves it", () => {
expect(replayableParams("tools/list", { cursor: "" })).toEqual({
params: { cursor: "" },
dropped: [],
});
});
// The other four adapters build theirs with a truthiness check, so `""` never
// reaches the wire. Reporting it as kept would show `{"cursor":""}` in the
// editor while `{}` was sent.
// Every list adapter builds its params with `cursor !== undefined` (#2220),
// so `""` reaches the wire on all five and the editor must show it.
it.each([
"tools/list",
"prompts/list",
"resources/list",
"resources/templates/list",
"tasks/list",
])("drops an empty cursor on %s, which does not preserve it", (method) => {
])("keeps an empty cursor on %s, which preserves it", (method) => {
expect(replayableParams(method, { cursor: "" })).toEqual({
params: undefined,
dropped: ["cursor"],
params: { cursor: "" },
dropped: [],
});
});

View file

@ -118,18 +118,11 @@ export function replayableParams(
// Only a *string* cursor survives: the dispatcher ignores any other type,
// so keeping it would put a value in the editor that changes nothing.
//
// And an **empty** one survives on `tools/list` alone. `listTools` builds
// its params with `cursor !== undefined`, carrying `""` deliberately —
// its own comment explains that dropping it asks for page one again. The
// other four adapters use a truthiness check and drop it. That asymmetry
// looks like a latent bug in those four rather than an intention, but
// this function's job is to describe what the dispatch *does*, so it
// reports the empty cursor as dropped where it would be dropped.
kept =
typeof params.cursor === "string" &&
(method === "tools/list" || params.cursor !== "")
? ["cursor"]
: [];
// An **empty** string is a cursor like any other, and every one of these
// five adapters now builds its params with `cursor !== undefined` (#2220
// brought the other four into line with `listTools`), so `""` reaches the
// wire on all of them and is reported as kept rather than dropped.
kept = typeof params.cursor === "string" ? ["cursor"] : [];
break;
default:
// `ping` takes nothing, and an unreplayable method never reaches here.