Skip to content

Four */list adapters drop an empty-string cursor, silently re-requesting page one #2220

Description

@cliffhall

Raised in the v2.5.0 milestone-merge review (#2215) and confirmed against the source. Filed here rather than fixed in the merge PR, whose tree is byte-identical to origin/v2/main.

The asymmetry

core/mcp/inspectorClient.ts builds the five */list request params two different ways:

Adapter Guard cursor: ""
listTools cursor !== undefined sent
listPrompts cursor ? { cursor } : {} dropped
listResources cursor ? { cursor } : {} dropped
listResourceTemplates cursor ? { cursor } : {} dropped
listRequestorTasks cursor ? { cursor } : {} dropped

A truthiness check cannot distinguish "no cursor" from "the cursor is the empty string". MCP cursors are opaque strings — the spec places no constraint on their content, so "" is a valid nextCursor a server may legitimately return, and the client is required to send it back verbatim.

Consequence

When a server paginates with an empty-string cursor, the four truthiness adapters drop it and re-request page one. Depending on the caller that is either a list that silently stops at the first page, or a loop that fetches page one forever because nextCursor never advances. Nothing surfaces an error — the request is well-formed, it just asks the wrong question.

listTools is already correct, and its inline comment states the reasoning: dropping the cursor asks for page one again.

This is already suspected in-tree

clients/web/src/lib/protocolReplay.ts encodes the asymmetry as a known quirk rather than a rule, and says as much:

// 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.

That comment made the right call for protocolReplay — describing the dispatch faithfully is its job — but it also records an unfiled bug. This is the issue it should have pointed at.

Fix

Bring the four adapters to listTools's guard:

...(cursor !== undefined ? { cursor } : {}),

Then simplify getReplayableParams in protocolReplay.ts: the method === "tools/list" || params.cursor !== "" special case exists only to mirror the asymmetry, and once the adapters agree the condition collapses to typeof params.cursor === "string". Update the long comment there to match rather than leaving it describing a state that no longer exists.

Cover it with a test server fixture that paginates via an empty-string cursor, driven through at least one of the four — the request params are the assertion, so a unit test on the adapter is enough, but the fixture is what keeps it honest end to end.

Reported by Copilot on #2215; the finding appeared in the review summary without an accompanying inline comment, and was verified from the source before filing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv2Issues and PRs for v2

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions