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.
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.tsbuilds the five*/listrequest params two different ways:cursor: ""listToolscursor !== undefinedlistPromptscursor ? { cursor } : {}listResourcescursor ? { cursor } : {}listResourceTemplatescursor ? { cursor } : {}listRequestorTaskscursor ? { cursor } : {}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 validnextCursora 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
nextCursornever advances. Nothing surfaces an error — the request is well-formed, it just asks the wrong question.listToolsis 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.tsencodes the asymmetry as a known quirk rather than a rule, and says as much: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:Then simplify
getReplayableParamsinprotocolReplay.ts: themethod === "tools/list" || params.cursor !== ""special case exists only to mirror the asymmetry, and once the adapters agree the condition collapses totypeof 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.