Skip to content

Commit d43e3ff

Browse files
feat(client)!: response-cache substrate; no-arg list*() auto-aggregate every page
1 parent d8ed081 commit d43e3ff

15 files changed

Lines changed: 937 additions & 300 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': major
3+
---
4+
5+
`Client.listTools()` / `listPrompts()` / `listResources()` / `listResourceTemplates()` now **auto-aggregate every page** when called without a `cursor` and return the complete result with `nextCursor: undefined` (matching the C#, Java, and mcp.d SDKs). Pass an explicit `{ cursor }` string to fetch a single page; the per-page path is unchanged. Existing manual pagination loops keep working — the first iteration returns everything and the loop exits — but can be deleted. The aggregated result is written to the new pluggable `ResponseCacheStore` (default: a fresh per-instance `InMemoryResponseCacheStore`); a `ClientResponseCache` collaborator owns the eviction-generation guard and the derived `tools/list` index that `callTool`'s output validation and SEP-2243 `Mcp-Param-*` mirroring read. New exports: `ResponseCacheStore`, `CacheKey`, `CacheEntry`, `CacheScope`, `MaybePromise`, `InMemoryResponseCacheStore`; new `ClientOptions.responseCacheStore` / `ClientOptions.listMaxPages` (caps the auto-aggregate walk at 64 pages by default; throws on overrun so a partial aggregate is never cached). The store interface is async-ready (`MaybePromise<…>`); the in-memory default stays synchronous. **A store instance must not be shared across `Client` instances at all in v2.0.x** — entries are keyed by method only (server-identity confusion + `clear()`/`evict()` cross-talk); per-principal partitioning that enables safe sharing arrives with the full caching engine.

docs/client.md

Lines changed: 13 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ A client connects to a server, discovers what it offers — tools, resources, pr
1313
The examples below use these imports. Adjust based on which features and transport you need:
1414

1515
```ts source="../examples/guides/clientGuide.examples.ts#imports"
16-
import type { AuthProvider, Prompt, Resource, Tool } from '@modelcontextprotocol/client';
16+
import type { AuthProvider } from '@modelcontextprotocol/client';
1717
import {
1818
applyMiddlewares,
1919
Client,
@@ -252,20 +252,14 @@ For manual control over the token exchange steps, use the Layer 2 utilities from
252252

253253
Tools are callable actions offered by servers — discovering and invoking them is usually how your client enables an LLM to take action (see [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) in the MCP overview).
254254

255-
Use {@linkcode @modelcontextprotocol/client!client/client.Client#listTools | listTools()} to discover available tools, and {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} to invoke one. Results may be paginated — loop on `nextCursor` to collect
256-
all pages:
255+
Use {@linkcode @modelcontextprotocol/client!client/client.Client#listTools | listTools()} to discover available tools, and {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} to invoke one. `listTools()` walks every page on your behalf and returns
256+
the complete list (pass an explicit `{ cursor }` for per-page control):
257257

258258
```ts source="../examples/guides/clientGuide.examples.ts#callTool_basic"
259-
const allTools: Tool[] = [];
260-
let toolCursor: string | undefined;
261-
do {
262-
const { tools, nextCursor } = await client.listTools({ cursor: toolCursor });
263-
allTools.push(...tools);
264-
toolCursor = nextCursor;
265-
} while (toolCursor);
259+
const { tools } = await client.listTools();
266260
console.log(
267261
'Available tools:',
268-
allTools.map(t => t.name)
262+
tools.map(t => t.name)
269263
);
270264

271265
const result = await client.callTool({
@@ -311,20 +305,14 @@ console.log(result.content);
311305

312306
Resources are read-only data — files, database schemas, configuration — that your application can retrieve from a server and attach as context for the model (see [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) in the MCP overview).
313307

314-
Use {@linkcode @modelcontextprotocol/client!client/client.Client#listResources | listResources()} and {@linkcode @modelcontextprotocol/client!client/client.Client#readResource | readResource()} to discover and read server-provided data. Results may be paginated — loop on
315-
`nextCursor` to collect all pages:
308+
Use {@linkcode @modelcontextprotocol/client!client/client.Client#listResources | listResources()} and {@linkcode @modelcontextprotocol/client!client/client.Client#readResource | readResource()} to discover and read server-provided data. `listResources()` walks every page on your
309+
behalf and returns the complete list (pass an explicit `{ cursor }` for per-page control):
316310

317311
```ts source="../examples/guides/clientGuide.examples.ts#readResource_basic"
318-
const allResources: Resource[] = [];
319-
let resourceCursor: string | undefined;
320-
do {
321-
const { resources, nextCursor } = await client.listResources({ cursor: resourceCursor });
322-
allResources.push(...resources);
323-
resourceCursor = nextCursor;
324-
} while (resourceCursor);
312+
const { resources } = await client.listResources();
325313
console.log(
326314
'Available resources:',
327-
allResources.map(r => r.name)
315+
resources.map(r => r.name)
328316
);
329317

330318
const { contents } = await client.readResource({ uri: 'config://app' });
@@ -357,20 +345,14 @@ await client.unsubscribeResource({ uri: 'config://app' });
357345

358346
Prompts are reusable message templates that servers offer to help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview).
359347

360-
Use {@linkcode @modelcontextprotocol/client!client/client.Client#listPrompts | listPrompts()} and {@linkcode @modelcontextprotocol/client!client/client.Client#getPrompt | getPrompt()} to list available prompts and retrieve them with arguments. Results may be paginated — loop on
361-
`nextCursor` to collect all pages:
348+
Use {@linkcode @modelcontextprotocol/client!client/client.Client#listPrompts | listPrompts()} and {@linkcode @modelcontextprotocol/client!client/client.Client#getPrompt | getPrompt()} to list available prompts and retrieve them with arguments. `listPrompts()` walks every page on
349+
your behalf and returns the complete list (pass an explicit `{ cursor }` for per-page control):
362350

363351
```ts source="../examples/guides/clientGuide.examples.ts#getPrompt_basic"
364-
const allPrompts: Prompt[] = [];
365-
let promptCursor: string | undefined;
366-
do {
367-
const { prompts, nextCursor } = await client.listPrompts({ cursor: promptCursor });
368-
allPrompts.push(...prompts);
369-
promptCursor = nextCursor;
370-
} while (promptCursor);
352+
const { prompts } = await client.listPrompts();
371353
console.log(
372354
'Available prompts:',
373-
allPrompts.map(p => p.name)
355+
prompts.map(p => p.name)
374356
);
375357

376358
const { messages } = await client.getPrompt({

docs/migration-SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,8 @@ side: auto-fulfilment is on by default (`ClientOptions.inputRequired`, `maxRound
560560

561561
`Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` now return empty results when the server lacks the corresponding capability (instead of sending the request). Set `enforceStrictCapabilities: true` in `ClientOptions` to throw an error instead.
562562

563+
`Client.listTools()`, `listPrompts()`, `listResources()`, `listResourceTemplates()` called without a `cursor` now auto-aggregate every page and return the complete result (`nextCursor: undefined`); an explicit `{ cursor }` string still returns one page. Manual `do { … } while (cursor !== undefined)` loops keep working (the first call returns everything and the loop exits after one iteration) — replace them with the bare no-arg call. New `ClientOptions.listMaxPages` (default 64) caps the aggregate walk only.
564+
563565
### Server (Streamable HTTP transport)
564566

565567
No code changes required; these are wire-behavior notes:

docs/migration.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,28 @@ const client = new Client(
553553
);
554554
```
555555

556+
### Client list methods auto-aggregate pagination
557+
558+
`Client.listTools()`, `listPrompts()`, `listResources()`, and `listResourceTemplates()` called **without a `cursor`** now walk every page on your behalf and return the complete aggregated result with `nextCursor: undefined`. This matches the C#, Java, and mcp.d SDKs. Passing an explicit `{ cursor }` string still fetches a single page (the v1 per-page contract).
559+
560+
Existing manual pagination loops keep working — the first iteration returns everything and the loop exits after one pass — but they can now be deleted:
561+
562+
```typescript
563+
// v1 — manual pagination loop
564+
const allTools: Tool[] = [];
565+
let cursor: string | undefined;
566+
do {
567+
const { tools, nextCursor } = await client.listTools({ cursor });
568+
allTools.push(...tools);
569+
cursor = nextCursor;
570+
} while (cursor !== undefined);
571+
572+
// v2 — auto-aggregated
573+
const { tools } = await client.listTools();
574+
```
575+
576+
The auto-aggregate walk is capped at `ClientOptions.listMaxPages` pages (default 64; `0` disables) and throws if the server's pagination does not converge, so a partial aggregate is never returned. The cap applies only to the no-`cursor` aggregate path; explicit per-page calls are never capped. The aggregated result is also written to the client's response cache (the source for `callTool`'s output-schema validation and SEP-2243 header mirroring).
577+
556578
### `InMemoryTransport` moved
557579

558580
`InMemoryTransport` is now exported from `@modelcontextprotocol/client` and `@modelcontextprotocol/server` (both re-export it). It is still intended for in-process client-server connections and testing.

examples/guides/clientGuide.examples.ts

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
//#region imports
11-
import type { AuthProvider, Prompt, Resource, Tool } from '@modelcontextprotocol/client';
11+
import type { AuthProvider } from '@modelcontextprotocol/client';
1212
import {
1313
applyMiddlewares,
1414
Client,
@@ -196,16 +196,10 @@ async function auth_crossAppAccess(getIdToken: () => Promise<string>) {
196196
/** Example: List and call tools. */
197197
async function callTool_basic(client: Client) {
198198
//#region callTool_basic
199-
const allTools: Tool[] = [];
200-
let toolCursor: string | undefined;
201-
do {
202-
const { tools, nextCursor } = await client.listTools({ cursor: toolCursor });
203-
allTools.push(...tools);
204-
toolCursor = nextCursor;
205-
} while (toolCursor);
199+
const { tools } = await client.listTools();
206200
console.log(
207201
'Available tools:',
208-
allTools.map(t => t.name)
202+
tools.map(t => t.name)
209203
);
210204

211205
const result = await client.callTool({
@@ -251,16 +245,10 @@ async function callTool_progress(client: Client) {
251245
/** Example: List and read resources. */
252246
async function readResource_basic(client: Client) {
253247
//#region readResource_basic
254-
const allResources: Resource[] = [];
255-
let resourceCursor: string | undefined;
256-
do {
257-
const { resources, nextCursor } = await client.listResources({ cursor: resourceCursor });
258-
allResources.push(...resources);
259-
resourceCursor = nextCursor;
260-
} while (resourceCursor);
248+
const { resources } = await client.listResources();
261249
console.log(
262250
'Available resources:',
263-
allResources.map(r => r.name)
251+
resources.map(r => r.name)
264252
);
265253

266254
const { contents } = await client.readResource({ uri: 'config://app' });
@@ -290,16 +278,10 @@ async function subscribeResource_basic(client: Client) {
290278
/** Example: List and get prompts. */
291279
async function getPrompt_basic(client: Client) {
292280
//#region getPrompt_basic
293-
const allPrompts: Prompt[] = [];
294-
let promptCursor: string | undefined;
295-
do {
296-
const { prompts, nextCursor } = await client.listPrompts({ cursor: promptCursor });
297-
allPrompts.push(...prompts);
298-
promptCursor = nextCursor;
299-
} while (promptCursor);
281+
const { prompts } = await client.listPrompts();
300282
console.log(
301283
'Available prompts:',
302-
allPrompts.map(p => p.name)
284+
prompts.map(p => p.name)
303285
);
304286

305287
const { messages } = await client.getPrompt({

packages/client/src/client/client.examples.ts

Lines changed: 12 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
* @module
88
*/
99

10-
import type { Prompt, Resource, Tool } from '@modelcontextprotocol/core';
11-
1210
import { Client } from './client.js';
1311
import { SSEClientTransport } from './sse.js';
1412
import { StdioClientTransport } from './stdio.js';
@@ -137,61 +135,43 @@ function Client_setRequestHandler_sampling(client: Client) {
137135
}
138136

139137
/**
140-
* Example: List tools with cursor-based pagination.
138+
* Example: List tools (auto-aggregated across pages).
141139
*/
142140
async function Client_listTools_pagination(client: Client) {
143141
//#region Client_listTools_pagination
144-
const allTools: Tool[] = [];
145-
let cursor: string | undefined;
146-
// Note: an empty-string cursor is valid and does not signal the end of results.
147-
do {
148-
const { tools, nextCursor } = await client.listTools({ cursor });
149-
allTools.push(...tools);
150-
cursor = nextCursor;
151-
} while (cursor !== undefined);
142+
// No cursor → all pages aggregated for you.
143+
const { tools } = await client.listTools();
152144
console.log(
153145
'Available tools:',
154-
allTools.map(t => t.name)
146+
tools.map(t => t.name)
155147
);
156148
//#endregion Client_listTools_pagination
157149
}
158150

159151
/**
160-
* Example: List prompts with cursor-based pagination.
152+
* Example: List prompts (auto-aggregated across pages).
161153
*/
162154
async function Client_listPrompts_pagination(client: Client) {
163155
//#region Client_listPrompts_pagination
164-
const allPrompts: Prompt[] = [];
165-
let cursor: string | undefined;
166-
// Note: an empty-string cursor is valid and does not signal the end of results.
167-
do {
168-
const { prompts, nextCursor } = await client.listPrompts({ cursor });
169-
allPrompts.push(...prompts);
170-
cursor = nextCursor;
171-
} while (cursor !== undefined);
156+
// No cursor → all pages aggregated for you.
157+
const { prompts } = await client.listPrompts();
172158
console.log(
173159
'Available prompts:',
174-
allPrompts.map(p => p.name)
160+
prompts.map(p => p.name)
175161
);
176162
//#endregion Client_listPrompts_pagination
177163
}
178164

179165
/**
180-
* Example: List resources with cursor-based pagination.
166+
* Example: List resources (auto-aggregated across pages).
181167
*/
182168
async function Client_listResources_pagination(client: Client) {
183169
//#region Client_listResources_pagination
184-
const allResources: Resource[] = [];
185-
let cursor: string | undefined;
186-
// Note: an empty-string cursor is valid and does not signal the end of results.
187-
do {
188-
const { resources, nextCursor } = await client.listResources({ cursor });
189-
allResources.push(...resources);
190-
cursor = nextCursor;
191-
} while (cursor !== undefined);
170+
// No cursor → all pages aggregated for you.
171+
const { resources } = await client.listResources();
192172
console.log(
193173
'Available resources:',
194-
allResources.map(r => r.name)
174+
resources.map(r => r.name)
195175
);
196176
//#endregion Client_listResources_pagination
197177
}

0 commit comments

Comments
 (0)