Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/client-response-cache-substrate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': major
---

`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 `SdkError` with `SdkErrorCode.ListPaginationExceeded` 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.
44 changes: 13 additions & 31 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ A client connects to a server, discovers what it offers — tools, resources, pr
The examples below use these imports. Adjust based on which features and transport you need:

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

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

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
all pages:
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
the complete list (pass an explicit `{ cursor }` for per-page control):

```ts source="../examples/guides/clientGuide.examples.ts#callTool_basic"
const allTools: Tool[] = [];
let toolCursor: string | undefined;
do {
const { tools, nextCursor } = await client.listTools({ cursor: toolCursor });
allTools.push(...tools);
toolCursor = nextCursor;
} while (toolCursor);
const { tools } = await client.listTools();
console.log(
'Available tools:',
allTools.map(t => t.name)
tools.map(t => t.name)
);

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

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

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
`nextCursor` to collect all pages:
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
behalf and returns the complete list (pass an explicit `{ cursor }` for per-page control):

```ts source="../examples/guides/clientGuide.examples.ts#readResource_basic"
const allResources: Resource[] = [];
let resourceCursor: string | undefined;
do {
const { resources, nextCursor } = await client.listResources({ cursor: resourceCursor });
allResources.push(...resources);
resourceCursor = nextCursor;
} while (resourceCursor);
const { resources } = await client.listResources();
console.log(
'Available resources:',
allResources.map(r => r.name)
resources.map(r => r.name)
);

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

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

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
`nextCursor` to collect all pages:
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
your behalf and returns the complete list (pass an explicit `{ cursor }` for per-page control):

```ts source="../examples/guides/clientGuide.examples.ts#getPrompt_basic"
const allPrompts: Prompt[] = [];
let promptCursor: string | undefined;
do {
const { prompts, nextCursor } = await client.listPrompts({ cursor: promptCursor });
allPrompts.push(...prompts);
promptCursor = nextCursor;
} while (promptCursor);
const { prompts } = await client.listPrompts();
console.log(
'Available prompts:',
allPrompts.map(p => p.name)
prompts.map(p => p.name)
);

const { messages } = await client.getPrompt({
Expand Down
2 changes: 2 additions & 0 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,8 @@ side: auto-fulfilment is on by default (`ClientOptions.inputRequired`, `maxRound

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

`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; overrun throws `SdkError` (`SdkErrorCode.ListPaginationExceeded`).

### Server (Streamable HTTP transport)

No code changes required; these are wire-behavior notes:
Expand Down
22 changes: 22 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,28 @@ const client = new Client(
);
```

### Client list methods auto-aggregate pagination

`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).

Existing manual pagination loops keep working — the first iteration returns everything and the loop exits after one pass — but they can now be deleted:

```typescript
// v1 — manual pagination loop
const allTools: Tool[] = [];
let cursor: string | undefined;
do {
const { tools, nextCursor } = await client.listTools({ cursor });
allTools.push(...tools);
cursor = nextCursor;
} while (cursor !== undefined);

// v2 — auto-aggregated
const { tools } = await client.listTools();
```

The auto-aggregate walk is capped at `ClientOptions.listMaxPages` pages (default 64; `0` disables) and throws an `SdkError` with `SdkErrorCode.ListPaginationExceeded` 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).

### `InMemoryTransport` moved

`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.
Expand Down
32 changes: 7 additions & 25 deletions examples/guides/clientGuide.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

//#region imports
import type { AuthProvider, Prompt, Resource, Tool } from '@modelcontextprotocol/client';
import type { AuthProvider } from '@modelcontextprotocol/client';
import {
applyMiddlewares,
Client,
Expand Down Expand Up @@ -196,16 +196,10 @@ async function auth_crossAppAccess(getIdToken: () => Promise<string>) {
/** Example: List and call tools. */
async function callTool_basic(client: Client) {
//#region callTool_basic
const allTools: Tool[] = [];
let toolCursor: string | undefined;
do {
const { tools, nextCursor } = await client.listTools({ cursor: toolCursor });
allTools.push(...tools);
toolCursor = nextCursor;
} while (toolCursor);
const { tools } = await client.listTools();
console.log(
'Available tools:',
allTools.map(t => t.name)
tools.map(t => t.name)
);

const result = await client.callTool({
Expand Down Expand Up @@ -251,16 +245,10 @@ async function callTool_progress(client: Client) {
/** Example: List and read resources. */
async function readResource_basic(client: Client) {
//#region readResource_basic
const allResources: Resource[] = [];
let resourceCursor: string | undefined;
do {
const { resources, nextCursor } = await client.listResources({ cursor: resourceCursor });
allResources.push(...resources);
resourceCursor = nextCursor;
} while (resourceCursor);
const { resources } = await client.listResources();
console.log(
'Available resources:',
allResources.map(r => r.name)
resources.map(r => r.name)
);

const { contents } = await client.readResource({ uri: 'config://app' });
Expand Down Expand Up @@ -290,16 +278,10 @@ async function subscribeResource_basic(client: Client) {
/** Example: List and get prompts. */
async function getPrompt_basic(client: Client) {
//#region getPrompt_basic
const allPrompts: Prompt[] = [];
let promptCursor: string | undefined;
do {
const { prompts, nextCursor } = await client.listPrompts({ cursor: promptCursor });
allPrompts.push(...prompts);
promptCursor = nextCursor;
} while (promptCursor);
const { prompts } = await client.listPrompts();
console.log(
'Available prompts:',
allPrompts.map(p => p.name)
prompts.map(p => p.name)
);

const { messages } = await client.getPrompt({
Expand Down
44 changes: 12 additions & 32 deletions packages/client/src/client/client.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
* @module
*/

import type { Prompt, Resource, Tool } from '@modelcontextprotocol/core';

import { Client } from './client.js';
import { SSEClientTransport } from './sse.js';
import { StdioClientTransport } from './stdio.js';
Expand Down Expand Up @@ -137,61 +135,43 @@ function Client_setRequestHandler_sampling(client: Client) {
}

/**
* Example: List tools with cursor-based pagination.
* Example: List tools (auto-aggregated across pages).
*/
async function Client_listTools_pagination(client: Client) {
//#region Client_listTools_pagination
const allTools: Tool[] = [];
let cursor: string | undefined;
// Note: an empty-string cursor is valid and does not signal the end of results.
do {
const { tools, nextCursor } = await client.listTools({ cursor });
allTools.push(...tools);
cursor = nextCursor;
} while (cursor !== undefined);
// No cursor → all pages aggregated for you.
const { tools } = await client.listTools();
console.log(
'Available tools:',
allTools.map(t => t.name)
tools.map(t => t.name)
);
//#endregion Client_listTools_pagination
}

/**
* Example: List prompts with cursor-based pagination.
* Example: List prompts (auto-aggregated across pages).
*/
async function Client_listPrompts_pagination(client: Client) {
//#region Client_listPrompts_pagination
const allPrompts: Prompt[] = [];
let cursor: string | undefined;
// Note: an empty-string cursor is valid and does not signal the end of results.
do {
const { prompts, nextCursor } = await client.listPrompts({ cursor });
allPrompts.push(...prompts);
cursor = nextCursor;
} while (cursor !== undefined);
// No cursor → all pages aggregated for you.
const { prompts } = await client.listPrompts();
console.log(
'Available prompts:',
allPrompts.map(p => p.name)
prompts.map(p => p.name)
);
//#endregion Client_listPrompts_pagination
}

/**
* Example: List resources with cursor-based pagination.
* Example: List resources (auto-aggregated across pages).
*/
async function Client_listResources_pagination(client: Client) {
//#region Client_listResources_pagination
const allResources: Resource[] = [];
let cursor: string | undefined;
// Note: an empty-string cursor is valid and does not signal the end of results.
do {
const { resources, nextCursor } = await client.listResources({ cursor });
allResources.push(...resources);
cursor = nextCursor;
} while (cursor !== undefined);
// No cursor → all pages aggregated for you.
const { resources } = await client.listResources();
console.log(
'Available resources:',
allResources.map(r => r.name)
resources.map(r => r.name)
);
//#endregion Client_listResources_pagination
}
Loading
Loading