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:
parent
23698afbe5
commit
9b35e530fc
4 changed files with 447 additions and 39 deletions
|
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
168
clients/web/src/test/integration/mcp/empty-cursor.test.ts
Normal file
168
clients/web/src/test/integration/mcp/empty-cursor.test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
Loading…
Reference in a new issue