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

View file

@ -0,0 +1,19 @@
{
"serverInfo": {
"name": "empty-cursor-showcase",
"version": "1.0.0"
},
"tools": [{ "preset": "numbered_tools", "params": { "count": 12 } }],
"resources": [{ "preset": "numbered_resources", "params": { "count": 12 } }],
"prompts": [{ "preset": "numbered_prompts", "params": { "count": 12 } }],
"maxPageSize": {
"tools": 4,
"resources": 4,
"prompts": 4
},
"emptyStringCursor": true,
"transport": {
"type": "streamable-http",
"port": 6601
}
}

View file

@ -480,6 +480,24 @@ export interface ServerConfig {
resourceTemplates?: number;
prompts?: number;
};
/**
* Hand out the **empty string** as the cursor for page two of every
* paginated list, instead of the usual numeric index (#2220).
*
* An MCP cursor is opaque: the spec constrains neither its content nor its
* length, so `""` is a legal `nextCursor` and a conforming client has to send
* it back verbatim. A client that builds its request params with a
* truthiness check cannot tell `""` from "no cursor", so it drops it and
* silently re-requests page one — a list that stops after one page, or a
* walk that loops on it forever, with no error anywhere because the request
* is perfectly well-formed.
*
* Nothing else in this repo produces that shape: every other fixture's
* cursor is a non-empty index string, which the buggy guard happens to carry
* correctly. Off by default so the existing pagination fixtures keep their
* numeric cursors.
*/
emptyStringCursor?: boolean; // default: false
/**
* Emit the named registered tools **twice** in `tools/list`, with the same
* `name` on both entries and " (duplicate)" appended to the second's title.
@ -1265,6 +1283,33 @@ export function createMcpServer(config: ServerConfig): McpServer {
// Set up pagination handlers if maxPageSize is configured
const maxPageSize = config.maxPageSize || {};
/**
* The cursor codec every paginated list below shares.
*
* Ordinarily a cursor is the next page's start index rendered as a string.
* Under {@link ServerConfig.emptyStringCursor} the *first* boundary — and
* only that one — is handed out as `""` instead, which is what exercises a
* client's ability to tell an empty cursor from an absent one (#2220). Later
* boundaries stay numeric, so a fixture with more than two pages still walks
* to the end.
*
* `decode` maps `""` back to that boundary only when the mode is on; with it
* off an empty cursor means page one, exactly as the previous
* `cursor ? parseInt(cursor, 10) : 0` did.
*/
const emptyStringCursor = config.emptyStringCursor === true;
const cursorCodec = (pageSize: number) => ({
encode: (index: number): string =>
emptyStringCursor && index === pageSize ? "" : index.toString(),
decode: (cursor: string | undefined): number => {
if (cursor === undefined || cursor === "") {
return emptyStringCursor && cursor === "" ? pageSize : 0;
}
const parsed = parseInt(cursor, 10);
return Number.isNaN(parsed) ? 0 : parsed;
},
});
// Emit each named tool a second time, same `name`, title marked so the two
// rows are told apart on screen. See ServerConfig.duplicateToolNames (#1957).
//
@ -1322,6 +1367,7 @@ export function createMcpServer(config: ServerConfig): McpServer {
// No pagination configured: one page holding everything, so the duplicate
// override can share this handler without inventing a page size.
const pageSize = maxPageSize.tools ?? Number.MAX_SAFE_INTEGER;
const codec = cursorCodec(pageSize);
// Convert registered tools to Tool format, mirroring the SDK's tools/list.
// The input-schema JSON comes from the SDK's memoised converter; the
@ -1351,11 +1397,11 @@ export function createMcpServer(config: ServerConfig): McpServer {
// boundary exactly as a real server's would.
const allTools = withDuplicates(withRawSchemas(registeredTools));
const startIndex = cursor ? parseInt(cursor, 10) : 0;
const startIndex = codec.decode(cursor);
const endIndex = startIndex + pageSize;
const page = allTools.slice(startIndex, endIndex);
const nextCursor =
endIndex < allTools.length ? endIndex.toString() : undefined;
endIndex < allTools.length ? codec.encode(endIndex) : undefined;
return {
tools: page,
@ -1371,6 +1417,7 @@ export function createMcpServer(config: ServerConfig): McpServer {
async (request, ctx) => {
const cursor = request.params?.cursor;
const pageSize = maxPageSize.resources!;
const codec = cursorCodec(pageSize);
// Collect all resources (static + from templates)
const allResources: Resource[] = [];
@ -1411,11 +1458,11 @@ export function createMcpServer(config: ServerConfig): McpServer {
}
}
const startIndex = cursor ? parseInt(cursor, 10) : 0;
const startIndex = codec.decode(cursor);
const endIndex = startIndex + pageSize;
const page = allResources.slice(startIndex, endIndex);
const nextCursor =
endIndex < allResources.length ? endIndex.toString() : undefined;
endIndex < allResources.length ? codec.encode(endIndex) : undefined;
return {
resources: page,
@ -1432,6 +1479,7 @@ export function createMcpServer(config: ServerConfig): McpServer {
async (request) => {
const cursor = request.params?.cursor;
const pageSize = maxPageSize.resourceTemplates!;
const codec = cursorCodec(pageSize);
// Convert registered resource templates to ResourceTemplate format
const allTemplates: Array<{
@ -1468,11 +1516,11 @@ export function createMcpServer(config: ServerConfig): McpServer {
}
}
const startIndex = cursor ? parseInt(cursor, 10) : 0;
const startIndex = codec.decode(cursor);
const endIndex = startIndex + pageSize;
const page = allTemplates.slice(startIndex, endIndex);
const nextCursor =
endIndex < allTemplates.length ? endIndex.toString() : undefined;
endIndex < allTemplates.length ? codec.encode(endIndex) : undefined;
return {
resourceTemplates: page as ResourceTemplate[],
@ -1487,6 +1535,7 @@ export function createMcpServer(config: ServerConfig): McpServer {
mcpServer.server.setRequestHandler("prompts/list", async (request) => {
const cursor = request.params?.cursor;
const pageSize = maxPageSize.prompts!;
const codec = cursorCodec(pageSize);
// Convert registered prompts to Prompt format. The argument descriptors
// are derived from the config's raw arg shape (the SDK no longer exposes
@ -1506,11 +1555,11 @@ export function createMcpServer(config: ServerConfig): McpServer {
}
}
const startIndex = cursor ? parseInt(cursor, 10) : 0;
const startIndex = codec.decode(cursor);
const endIndex = startIndex + pageSize;
const page = allPrompts.slice(startIndex, endIndex);
const nextCursor =
endIndex < allPrompts.length ? endIndex.toString() : undefined;
endIndex < allPrompts.length ? codec.encode(endIndex) : undefined;
return {
prompts: page,

View file

@ -80,6 +80,12 @@ export interface ConfigFile {
resourceTemplates?: number;
prompts?: number;
};
/**
* Hand out `""` as the cursor for page two of every paginated list, instead
* of the usual numeric index. See {@link ServerConfig.emptyStringCursor}
* (#2220).
*/
emptyStringCursor?: boolean;
/**
* Names of registered tools to emit **twice** in `tools/list` (same `name`,
* the second's title marked "(duplicate)") — the nonconforming-but-real shape

View file

@ -93,6 +93,7 @@ export function resolveConfig(config: ConfigFile): ServerConfig {
skills: config.skills,
appElicitation: config.appElicitation,
maxPageSize: config.maxPageSize,
emptyStringCursor: config.emptyStringCursor,
duplicateToolNames: config.duplicateToolNames,
rawToolSchemas: config.rawToolSchemas,
extensionGatedTools: config.extensionGatedTools,