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

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

View file

@ -0,0 +1,157 @@
import { describe, it, expect, vi } from "vitest";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
/**
* Unit coverage for the pagination cursor every list adapter — `tools/list`,
* `prompts/list`, `resources/list`, `resources/templates/list`, `tasks/list` —
* puts on the wire (#2220).
*
* An MCP cursor is an **opaque string**: the spec places no constraint on its
* content, so `""` is a `nextCursor` a server may legitimately hand back and
* the client is required to send verbatim. Four adapters used to build their
* params with a truthiness check, which cannot distinguish "no cursor" from
* "the cursor is the empty string" — so they dropped `""` and silently
* re-requested page one. Nothing surfaced an error, because the request was
* well-formed; it just asked the wrong question.
*
* The outbound params are therefore the whole assertion here: what each case
* pins is that `""` survives and that a genuinely absent cursor still sends no
* `cursor` key at all. The SDK client is stubbed rather than connected — the
* decision under test is made entirely in `InspectorClient`.
*/
describe("InspectorClient list cursor handling (#2220)", () => {
/**
* The one SDK call these tests care about. Named rather than inlined so the
* `vi.fn` stub can be typed by it — that is what puts the request shape on
* `request.mock.calls`, so each assertion reads `params` without a cast.
*/
type SdkRequest = (
req: { method: string; params: Record<string, unknown> },
schema: unknown,
) => Promise<unknown>;
interface ClientInternals {
client: { request: SdkRequest } | null;
}
/**
* A structural view onto the private `client` field so a test can stub the
* SDK client without connecting.
*
* The double cast is justified rather than incidental: `InspectorClient`
* declares `client` `private`, so no single `as` relates it to a type that
* exposes it, and there is no public setter — the public path is `connect()`,
* which needs a transport, a live server and a handshake. It is safe because
* the shape asserted is exactly the shape the class declares, so a rename or
* a type change breaks these tests at the first use rather than silently
* passing. The same seam is used by `inspectorClient-skills.test.ts`.
*/
function internals(client: InspectorClient): ClientInternals {
return client as unknown as ClientInternals;
}
function makeClient(): InspectorClient {
return new InspectorClient(
{ type: "stdio", command: "noop", args: [] },
// `environment.transport` is only used on connect(); these tests never
// connect, they stub the SDK client directly.
{ environment: { transport: () => ({}) as never } },
);
}
/**
* Stub the SDK client so `request` resolves with a fixed result.
*
* Typed by {@link SdkRequest} rather than inferred, so `request.mock.calls`
* carries the request shape.
*/
function stubRequest(client: InspectorClient, result: unknown) {
const request = vi.fn<SdkRequest>(async () => result);
internals(client).client = { request };
return request;
}
/**
* The five adapters, each with the method it emits and an empty result of the
* right shape. `listTools` was always correct and is included as the control:
* if the guard it has always used ever regressed, these cases go red too.
*/
const ADAPTERS: {
name: string;
method: string;
result: Record<string, unknown>;
call: (client: InspectorClient, cursor?: string) => Promise<unknown>;
}[] = [
{
name: "listTools",
method: "tools/list",
result: { tools: [] },
call: (client, cursor) => client.listTools(cursor),
},
{
name: "listPrompts",
method: "prompts/list",
result: { prompts: [] },
call: (client, cursor) => client.listPrompts(cursor),
},
{
name: "listResources",
method: "resources/list",
result: { resources: [] },
call: (client, cursor) => client.listResources(cursor),
},
{
name: "listResourceTemplates",
method: "resources/templates/list",
result: { resourceTemplates: [] },
call: (client, cursor) => client.listResourceTemplates(cursor),
},
{
name: "listRequestorTasks",
method: "tasks/list",
result: { tasks: [] },
call: (client, cursor) => client.listRequestorTasks(cursor),
},
];
it.each(ADAPTERS)(
"$name sends an empty-string cursor verbatim",
async ({ method, result, call }) => {
const client = makeClient();
const request = stubRequest(client, result);
await call(client, "");
const sent = request.mock.calls[0][0];
expect(sent.method).toBe(method);
expect(sent.params.cursor).toBe("");
},
);
it.each(ADAPTERS)(
"$name sends no cursor key when there is no cursor",
async ({ method, result, call }) => {
const client = makeClient();
const request = stubRequest(client, result);
await call(client);
const sent = request.mock.calls[0][0];
expect(sent.method).toBe(method);
expect(sent.params).not.toHaveProperty("cursor");
},
);
it.each(ADAPTERS)(
"$name forwards a non-empty cursor unchanged",
async ({ result, call }) => {
const client = makeClient();
const request = stubRequest(client, result);
await call(client, "page-2");
const sent = request.mock.calls[0][0];
expect(sent.params.cursor).toBe("page-2");
},
);
});

View file

@ -0,0 +1,168 @@
import { describe, it, expect, afterEach } from "vitest";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import {
createTestServerHttp,
type TestServerHttp,
createTestServerInfo,
loadConfig,
resolveConfig,
} from "@modelcontextprotocol/inspector-test-server";
/**
* Live coverage of `test-servers/configs/empty-cursor-http.json` — the
* documented manual reproduction for #2220.
*
* The unit tests on the adapters assert the outbound params directly, which is
* where the decision is made. What they cannot see is the round trip: that a
* server may hand back `""` as a `nextCursor` at all, that the SDK carries it
* through both directions without normalizing it away, and that a page walk
* driven by it actually advances. Those are the premise of the fix, and a
* change anywhere in that chain would leave the unit tests green while the
* showcase server quietly stopped reproducing the bug.
*
* The server is built by **resolving the checked-in config** rather than by
* hand, so a config that names a dead preset — or an `emptyStringCursor` flag
* that stops being threaded through `resolveConfig` — fails here rather than
* only when someone runs the repro by hand.
*/
describe("empty-string pagination cursor over the wire (#2220)", () => {
let client: InspectorClient | null = null;
let server: TestServerHttp | null = null;
const configPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../../test-servers/configs/empty-cursor-http.json",
);
afterEach(async () => {
if (client) {
try {
await client.disconnect();
} catch {
// ignore
}
client = null;
}
if (server) {
try {
await server.stop();
} catch {
// ignore
}
server = null;
}
});
/**
* Boot the showcase config. The harness picks the port rather than using the
* config's fixed one, so this cannot collide with a showcase server someone
* is running by hand.
*/
async function connectToShowcase(): Promise<InspectorClient> {
const resolved = resolveConfig(loadConfig(configPath));
const started = createTestServerHttp({
...resolved,
serverInfo: createTestServerInfo("empty-cursor-test", "1.0.0"),
port: undefined,
});
await started.start();
server = started;
const connected = new InspectorClient(
{ type: "streamable-http", url: started.url },
{ environment: { transport: createTransportNode } },
);
await connected.connect();
client = connected;
return connected;
}
it("resolves the config with the empty-cursor flag intact", () => {
const resolved = resolveConfig(loadConfig(configPath));
expect(resolved.emptyStringCursor).toBe(true);
expect(resolved.maxPageSize).toEqual({
tools: 4,
resources: 4,
prompts: 4,
});
expect(resolved.tools).toHaveLength(12);
});
/**
* One walk per list, driven by the same three assertions: page one hands back
* `""` rather than a numeric index, sending it back yields the *second* four
* items rather than the first four again, and the walk still terminates.
*
* The names are the assertion for "advanced" — a length check alone would
* pass on a server that re-served page one, which is exactly the failure.
*/
const WALKS: {
label: string;
walk: (
client: InspectorClient,
cursor?: string,
) => Promise<{ names: string[]; nextCursor?: string }>;
}[] = [
{
label: "tools/list",
walk: async (connected, cursor) => {
const page = await connected.listTools(cursor);
return {
names: page.tools.map((tool) => tool.name),
nextCursor: page.nextCursor,
};
},
},
{
label: "prompts/list",
walk: async (connected, cursor) => {
const page = await connected.listPrompts(cursor);
return {
names: page.prompts.map((prompt) => prompt.name),
nextCursor: page.nextCursor,
};
},
},
{
label: "resources/list",
walk: async (connected, cursor) => {
const page = await connected.listResources(cursor);
return {
names: page.resources.map((resource) => resource.uri),
nextCursor: page.nextCursor,
};
},
},
];
it.each(WALKS)(
"$label advances past an empty-string cursor instead of re-serving page one",
async ({ walk }) => {
const connected = await connectToShowcase();
const first = await walk(connected);
expect(first.names).toHaveLength(4);
// The premise: the server really does hand back `""`, and nothing between
// here and the wire turned it into `undefined`.
expect(first.nextCursor).toBe("");
const second = await walk(connected, first.nextCursor);
expect(second.names).toHaveLength(4);
// On the pre-fix adapters this was `first.names` again.
expect(second.names).not.toEqual(first.names);
expect(second.nextCursor).toBe("8");
const third = await walk(connected, second.nextCursor);
expect(third.names).toHaveLength(4);
expect(third.nextCursor).toBeUndefined();
// All twelve, each seen exactly once — the walk covered the list rather
// than looping over one page.
const seen = [...first.names, ...second.names, ...third.names];
expect(new Set(seen).size).toBe(12);
},
);
});