From c7ba26d62a85054ac010b109a797200064266b72 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sat, 29 Aug 2026 09:33:29 +0800 Subject: [PATCH 1/3] fix(client): follow repeated opaque cursors --- .changeset/follow-opaque-cursors.md | 5 +++++ packages/client/src/client/client.ts | 9 ++++----- .../client/test/client/responseCache.test.ts | 18 +++++++++++++++++- 3 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 .changeset/follow-opaque-cursors.md diff --git a/.changeset/follow-opaque-cursors.md b/.changeset/follow-opaque-cursors.md new file mode 100644 index 0000000000..a67a6278a5 --- /dev/null +++ b/.changeset/follow-opaque-cursors.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Follow repeated opaque cursors during automatic list pagination until the configured `listMaxPages` limit or server termination, instead of silently truncating results based on cursor values. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 0b386a63e8..d6e1079e9e 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1698,8 +1698,9 @@ export class Client extends Protocol { * methods' no-`cursor` auto-aggregate path. Page 1's result object is * mutated in place (its items array is extended; `nextCursor` is * cleared); page-1 metadata (`ttlMs`, `cacheScope`, `_meta`) is preserved. - * A `nextCursor` that repeats stops the walk (defence against a - * non-converging server, mcp.d's `drainList` guard); + * The walk is bounded by `listMaxPages`, which prevents a non-converging + * server from keeping the aggregate request open without interpreting + * opaque cursor values. * {@linkcode ClientOptions.listMaxPages} is a hard cap — hitting it * throws, so a partial aggregate is never cached. The * captured-generation guard skips the write when a `list_changed` landed @@ -1732,9 +1733,8 @@ export class Client extends Protocol { const generation = this._cache.captureGeneration(method); const acc = (await this.request({ method, ...(baseParams && { params: { ...baseParams } }) }, options)) as R; let cursor = acc.nextCursor; - const seen = new Set(); let pages = 1; - while (cursor !== undefined && !seen.has(cursor)) { + while (cursor !== undefined) { if (this._listMaxPages !== 0 && pages >= this._listMaxPages) { throw new SdkError( SdkErrorCode.ListPaginationExceeded, @@ -1742,7 +1742,6 @@ export class Client extends Protocol { { method, listMaxPages: this._listMaxPages } ); } - seen.add(cursor); const page = (await this.request({ method, params: { ...baseParams, cursor } }, options)) as R; append(acc, page); cursor = page.nextCursor; diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index cec28ac6d8..600d7e6e62 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -326,6 +326,7 @@ interface ScriptOptions { listHint?: { ttlMs?: number; cacheScope?: 'public' | 'private' }; readHint?: { ttlMs?: number; cacheScope?: 'public' | 'private' }; serverInfo?: { name: string; version: string }; + nextCursors?: (string | undefined)[]; } async function scriptedModernServer(pages: Tool[][], opts: ScriptOptions = {}): Promise { @@ -353,7 +354,9 @@ async function scriptedModernServer(pages: Tool[][], opts: ScriptOptions = {}): params.push(r.params as { cursor?: string; _meta?: unknown } | undefined); const cursor = (r.params as { cursor?: string } | undefined)?.cursor; const idx = cursor === undefined ? 0 : Number(cursor); - const next = idx + 1 < pages.length ? String(idx + 1) : undefined; + const next = opts.nextCursors && lists - 1 < opts.nextCursors.length + ? opts.nextCursors[lists - 1] + : idx + 1 < pages.length ? String(idx + 1) : undefined; void serverTx.send({ jsonrpc: '2.0', id: r.id, @@ -436,6 +439,19 @@ describe('Client response-cache substrate', () => { expect((JSON.parse(entry!.value) as { tools: Tool[] }).tools.map(t => t.name)).toEqual(['a', 'b']); }); + it('listTools() follows repeated opaque cursors until the page limit or server termination', async () => { + const { clientTx, listParams } = await scriptedModernServer( + [[TOOL_A], [TOOL_B], [TOOL_A]], + { nextCursors: ['same', 'same', undefined] } + ); + const client = modernClient(); + await client.connect(clientTx); + + const { tools } = await client.listTools(); + expect(tools.map(t => t.name)).toEqual(['a', 'b', 'a']); + expect(listParams().map(p => p?.cursor)).toEqual([undefined, 'same', 'same']); + }); + it('the auto-aggregate path threads caller params (e.g. _meta trace context) into every page request', async () => { const { clientTx, listParams } = await scriptedModernServer([[TOOL_A], [TOOL_B], [TOOL_A]]); const client = modernClient(); From 36ca7f1fe542a1ed11b78152be362fbe81fd7d6d Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sat, 29 Aug 2026 09:36:25 +0800 Subject: [PATCH 2/3] test(client): cover repeated pagination cursors --- packages/client/test/client/responseCache.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index 600d7e6e62..25045adfba 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -332,6 +332,7 @@ interface ScriptOptions { async function scriptedModernServer(pages: Tool[][], opts: ScriptOptions = {}): Promise { const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); let lists = 0; + let pageIndex = 0; const wireCounts = new Map(); const params: ({ cursor?: string; _meta?: unknown } | undefined)[] = []; serverTx.onmessage = m => { @@ -353,7 +354,9 @@ async function scriptedModernServer(pages: Tool[][], opts: ScriptOptions = {}): lists++; params.push(r.params as { cursor?: string; _meta?: unknown } | undefined); const cursor = (r.params as { cursor?: string } | undefined)?.cursor; - const idx = cursor === undefined ? 0 : Number(cursor); + // Cursor values are opaque: advance the scripted page by request + // order instead of interpreting the cursor as a page number. + const idx = cursor === undefined ? (pageIndex = 0) : ++pageIndex; const next = opts.nextCursors && lists - 1 < opts.nextCursors.length ? opts.nextCursors[lists - 1] : idx + 1 < pages.length ? String(idx + 1) : undefined; From 16a3e1752de0ef0884fa78a3b547acceb39db6d1 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sat, 29 Aug 2026 09:39:38 +0800 Subject: [PATCH 3/3] style(client): format pagination regression test --- .../client/test/client/responseCache.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index 25045adfba..a00dc9d881 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -357,9 +357,12 @@ async function scriptedModernServer(pages: Tool[][], opts: ScriptOptions = {}): // Cursor values are opaque: advance the scripted page by request // order instead of interpreting the cursor as a page number. const idx = cursor === undefined ? (pageIndex = 0) : ++pageIndex; - const next = opts.nextCursors && lists - 1 < opts.nextCursors.length - ? opts.nextCursors[lists - 1] - : idx + 1 < pages.length ? String(idx + 1) : undefined; + const next = + opts.nextCursors && lists - 1 < opts.nextCursors.length + ? opts.nextCursors[lists - 1] + : idx + 1 < pages.length + ? String(idx + 1) + : undefined; void serverTx.send({ jsonrpc: '2.0', id: r.id, @@ -443,10 +446,9 @@ describe('Client response-cache substrate', () => { }); it('listTools() follows repeated opaque cursors until the page limit or server termination', async () => { - const { clientTx, listParams } = await scriptedModernServer( - [[TOOL_A], [TOOL_B], [TOOL_A]], - { nextCursors: ['same', 'same', undefined] } - ); + const { clientTx, listParams } = await scriptedModernServer([[TOOL_A], [TOOL_B], [TOOL_A]], { + nextCursors: ['same', 'same', undefined] + }); const client = modernClient(); await client.connect(clientTx);