From e987453ac82a3f46b7490146d2082200727a6434 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 19 Jun 2026 15:41:55 +0000 Subject: [PATCH] feat(client)!: response-cache substrate; no-arg list*() auto-aggregate every page The legacy cacheToolMetadata / _cachedToolOutputValidators path is retired: callTool()'''s output-schema validation now reads the cached tools/list entry via ClientResponseCache.outputValidator (the substrate'''s first production caller), so the response cache is the single source for tool metadata. The name -> validator index is stamp-memoized against the tools/list entry and re-derives only when the backing entry changes; a list_changed eviction invalidates it via the stamp (no separate state to keep in sync). Validator-lifecycle behavior change (every era): compilation is now lazy (first callTool against the cached entry, not eagerly inside listTools) and non-throwing - an uncompilable outputSchema is console.warn-ed and validation is skipped for that tool only; listTools() no longer throws on it. --- .changeset/client-response-cache-substrate.md | 7 + docs/client.md | 44 +- docs/migration-SKILL.md | 4 + docs/migration.md | 26 ++ examples/guides/clientGuide.examples.ts | 32 +- packages/client/src/client/client.examples.ts | 44 +- packages/client/src/client/client.ts | 335 +++++++++++--- packages/client/src/client/responseCache.ts | 308 +++++++++++++ packages/client/src/index.ts | 2 + .../jsonSchemaValidatorOverride.test.ts | 23 +- .../client/test/client/responseCache.test.ts | 429 ++++++++++++++++++ packages/core/src/errors/sdkErrors.ts | 9 + .../core/test/types/errorSurfacePins.test.ts | 1 + test/e2e/requirements.ts | 32 +- test/e2e/scenarios/pagination.test.ts | 23 +- test/e2e/scenarios/prompts.test.ts | 51 +-- test/e2e/scenarios/resources.test.ts | 94 ++-- test/e2e/scenarios/tools.test.ts | 54 +-- test/e2e/scenarios/validation.test.ts | 12 +- 19 files changed, 1193 insertions(+), 337 deletions(-) create mode 100644 .changeset/client-response-cache-substrate.md create mode 100644 packages/client/src/client/responseCache.ts create mode 100644 packages/client/test/client/responseCache.test.ts diff --git a/.changeset/client-response-cache-substrate.md b/.changeset/client-response-cache-substrate.md new file mode 100644 index 0000000000..16a0621cac --- /dev/null +++ b/.changeset/client-response-cache-substrate.md @@ -0,0 +1,7 @@ +--- +'@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. + +**Behavior change (every era):** output-schema validator compilation is now lazy — validators are compiled on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()` — and non-throwing: an uncompilable `outputSchema` is `console.warn`-ed and validation is skipped for that tool only (previously `listTools()` threw). A pluggable `jsonSchemaValidator` provider therefore observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is unchanged at the wire level but is observably different at the validator-lifecycle level. diff --git a/docs/client.md b/docs/client.md index 6b7c0df110..79ddd12157 100644 --- a/docs/client.md +++ b/docs/client.md @@ -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, @@ -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({ @@ -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' }); @@ -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({ diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 14d9330dfd..8b14aa8cc1 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -560,6 +560,10 @@ 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`). + +Output-schema validator compilation is now lazy (first `callTool()` against the cached `tools/list` entry) and non-throwing (an uncompilable `outputSchema` is `console.warn`-ed and validation is skipped for that tool only); `listTools()` no longer throws on an uncompilable `outputSchema`. Applies on every era — the legacy-era `listTools()` path is unchanged at the wire level only. + ### Server (Streamable HTTP transport) No code changes required; these are wire-behavior notes: diff --git a/docs/migration.md b/docs/migration.md index 2e79e8964b..7e74ad8843 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -553,6 +553,32 @@ 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). + +**Output-schema validator lifecycle (every era):** validator compilation is now lazy — validators are compiled on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()` — and non-throwing: an uncompilable `outputSchema` is `console.warn`-ed +and validation is skipped for that tool only. In v1, `listTools()` threw on an uncompilable `outputSchema`; now it succeeds, and a pluggable `jsonSchemaValidator` provider observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is +unchanged at the wire level but is observably different at the validator-lifecycle level. + ### `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. diff --git a/examples/guides/clientGuide.examples.ts b/examples/guides/clientGuide.examples.ts index b44f78a223..68ef6015a3 100644 --- a/examples/guides/clientGuide.examples.ts +++ b/examples/guides/clientGuide.examples.ts @@ -8,7 +8,7 @@ */ //#region imports -import type { AuthProvider, Prompt, Resource, Tool } from '@modelcontextprotocol/client'; +import type { AuthProvider } from '@modelcontextprotocol/client'; import { applyMiddlewares, Client, @@ -196,16 +196,10 @@ async function auth_crossAppAccess(getIdToken: () => Promise) { /** 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({ @@ -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' }); @@ -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({ diff --git a/packages/client/src/client/client.examples.ts b/packages/client/src/client/client.examples.ts index 0789b1501a..85f815c189 100644 --- a/packages/client/src/client/client.examples.ts +++ b/packages/client/src/client/client.examples.ts @@ -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'; @@ -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 } diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index e6ae442893..a43aee20a3 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -78,6 +78,8 @@ import { SubscriptionFilterSchema } from '@modelcontextprotocol/core'; +import type { ResponseCacheStore } from './responseCache.js'; +import { ClientResponseCache, InMemoryResponseCacheStore } from './responseCache.js'; import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation.js'; import { detectProbeEnvironment, detectProbeTransportKind, negotiateEra, resolveVersionNegotiation } from './versionNegotiation.js'; @@ -255,8 +257,50 @@ export type ClientOptions = ProtocolOptions & { * ``` */ listChanged?: ListChangedHandlers; + + /** + * Cap on the number of pages the auto-aggregating + * {@linkcode Client.listTools | listTools()} / + * {@linkcode Client.listPrompts | listPrompts()} / + * {@linkcode Client.listResources | listResources()} / + * {@linkcode Client.listResourceTemplates | listResourceTemplates()} walk + * fetches before throwing (a defence against a server whose `nextCursor` + * never converges). `0` disables the cap. Default: `64`. Applies only to + * the no-argument auto-aggregate path; an explicit-`cursor` per-page call + * is never capped. + */ + listMaxPages?: number; + + /** + * The response-cache store backing the client's derived views (the cached + * `tools/list` result that {@linkcode Client.callTool | callTool}'s output + * validation and SEP-2243 header mirroring will read once the stacked + * SEP-2243 PR lands; this commit ships only the seam). Defaults to a fresh + * {@linkcode InMemoryResponseCacheStore} per client. + * + * **Do not share one store across clients at all in v2.0.x** — entries + * are keyed by method + params only, so two clients connected to + * different servers (even under the same credential) collide on + * `tools/list`, and one client's `list_changed` evicts every co-tenant's + * entry. Supply your own only as a single-client backing store. + * Per-principal partitioning that enables safe sharing is #39. + */ + responseCacheStore?: ResponseCacheStore; }; +/** + * `list_changed` notification → response-cache method(s) to evict. `resources` + * covers both list verbs (the spec's "relevant notification ⇒ immediately + * stale"). + */ +const LIST_CHANGED_EVICTIONS: Readonly> = { + 'notifications/tools/list_changed': ['tools/list'], + 'notifications/prompts/list_changed': ['prompts/list'], + 'notifications/resources/list_changed': ['resources/list', 'resources/templates/list'] +}; + +const DEFAULT_LIST_MAX_PAGES = 64; + /** * A handle to an open `subscriptions/listen` stream (protocol revision * 2026-07-28). Change notifications delivered on the stream dispatch to the @@ -339,7 +383,20 @@ export class Client extends Protocol { private _capabilities: ClientCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; - private _cachedToolOutputValidators: Map> = new Map(); + /** + * The response-cache substrate. Owns the backing store, the per-method + * eviction-generation counter, the user-supplied/default flag, and the + * stamp-memoized derived `name → Tool` / `name → output-validator` + * indices — the cache-coordination state that used to live as separate + * private fields here. The internal aggregating walk writes one entry per + * list verb; `list_changed` evicts the matching method; + * `_resetConnectionState` resets the lot. {@linkcode callTool}'s + * output-schema validation reads the derived `outputValidator` index (the + * substrate's first production caller); the stacked SEP-2243 PR wires + * `Mcp-Param-*` mirroring through `toolDefinition` on top. + */ + private readonly _cache: ClientResponseCache; + private readonly _listMaxPages: number; private _listChangedDebounceTimers: Map> = new Map(); /** * The constructor `listChanged` configuration. Durable across reconnects: @@ -396,7 +453,12 @@ export class Client extends Protocol { clearTimeout(timer); } this._listChangedDebounceTimers.clear(); - this._cachedToolOutputValidators.clear(); + // A user-supplied store is NOT cleared on reconnect/close — that would + // defeat the only reason to supply one. The per-instance default IS + // cleared (it is connection-scoped); derived indices and the + // generation map are dropped regardless. The default impl is + // synchronous, so the call returns plain void here. + this._cache.resetForReconnect(); } override async close(): Promise { @@ -426,6 +488,15 @@ export class Client extends Protocol { // Multi-round-trip auto-fulfilment driver (2026-07-28): on by default, // configurable via ClientOptions.inputRequired. this._inputRequiredDriverConfig = resolveInputRequiredDriverConfig(options?.inputRequired); + // Response-cache substrate. A fresh in-memory store is allocated when + // the caller does not supply one — never share a default across + // instances (see ClientOptions.responseCacheStore). + this._cache = new ClientResponseCache( + options?.responseCacheStore ?? new InMemoryResponseCacheStore(), + options?.responseCacheStore !== undefined, + error => this._reportStoreError(error) + ); + this._listMaxPages = options?.listMaxPages ?? DEFAULT_LIST_MAX_PAGES; // Store list changed config for setup after connection (when we know server capabilities) if (options?.listChanged) { @@ -1227,24 +1298,28 @@ export class Client extends Protocol { } /** - * Lists available prompts. Results may be paginated — loop on `nextCursor` to collect all pages. + * Lists available prompts. + * + * Called without a `cursor` (the common case), this walks every page and + * returns the complete aggregated list with `nextCursor: undefined`; the + * aggregate is also written to the {@linkcode ResponseCacheStore}. Pass an + * explicit `{ cursor }` to fetch a single page and walk pagination + * yourself — the per-page path returns the server's raw page (with + * `nextCursor` for the next call) and does not write the response cache. + * The auto-aggregate path is capped by + * {@linkcode ClientOptions | ClientOptions.listMaxPages} (default 64); the per-page path + * is not. * * Returns an empty list if the server does not advertise prompts capability * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). * * @example * ```ts source="./client.examples.ts#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) * ); * ``` */ @@ -1254,28 +1329,35 @@ export class Client extends Protocol { console.debug('Client.listPrompts() called but server does not advertise prompts capability - returning empty list'); return { prompts: [] }; } - return this.request({ method: 'prompts/list', params }, options); + if (params?.cursor !== undefined) { + return this.request({ method: 'prompts/list', params }, options); + } + return this._listAllPages('prompts/list', params, options, (acc, page) => acc.prompts.push(...page.prompts)); } /** - * Lists available resources. Results may be paginated — loop on `nextCursor` to collect all pages. + * Lists available resources. + * + * Called without a `cursor` (the common case), this walks every page and + * returns the complete aggregated list with `nextCursor: undefined`; the + * aggregate is also written to the {@linkcode ResponseCacheStore}. Pass an + * explicit `{ cursor }` to fetch a single page and walk pagination + * yourself — the per-page path returns the server's raw page (with + * `nextCursor` for the next call) and does not write the response cache. + * The auto-aggregate path is capped by + * {@linkcode ClientOptions | ClientOptions.listMaxPages} (default 64); the per-page path + * is not. * * Returns an empty list if the server does not advertise resources capability * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). * * @example * ```ts source="./client.examples.ts#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) * ); * ``` */ @@ -1285,11 +1367,22 @@ export class Client extends Protocol { console.debug('Client.listResources() called but server does not advertise resources capability - returning empty list'); return { resources: [] }; } - return this.request({ method: 'resources/list', params }, options); + if (params?.cursor !== undefined) { + return this.request({ method: 'resources/list', params }, options); + } + return this._listAllPages('resources/list', params, options, (acc, page) => + acc.resources.push(...page.resources) + ); } /** - * Lists available resource URI templates for dynamic resources. Results may be paginated — see {@linkcode listResources | listResources()} for the cursor pattern. + * Lists available resource URI templates for dynamic resources. + * + * Called without a `cursor`, this walks every page and returns the + * complete aggregated list with `nextCursor: undefined`; the aggregate is + * also written to the {@linkcode ResponseCacheStore}. Pass an explicit + * `{ cursor }` to fetch a single page — see + * {@linkcode listResources | listResources()} for the per-page contract. * * Returns an empty list if the server does not advertise resources capability * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). @@ -1305,7 +1398,96 @@ export class Client extends Protocol { ); return { resourceTemplates: [] }; } - return this.request({ method: 'resources/templates/list', params }, options); + if (params?.cursor !== undefined) { + return this.request({ method: 'resources/templates/list', params }, options); + } + return this._listAllPages('resources/templates/list', params, options, (acc, page) => + acc.resourceTemplates.push(...page.resourceTemplates) + ); + } + + /** + * Walk every page of a paginated list verb, aggregate, and write ONE + * entry to the response cache. Internal — backs the public `list*` + * 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); + * {@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 + * mid-walk, so the eviction is never overwritten by a stale aggregate. + * `finalize` runs on the complete aggregate before the cache write — the + * SEP-2243 invalid-`x-mcp-header` exclusion hooks here so the cached + * `tools/list` entry is already filtered. + * + * The caller's `baseParams` (everything except `cursor`) is threaded into + * every page request — page 1 sends `{...baseParams}`, later pages + * `{...baseParams, cursor}` — so a typed, documented `_meta` (e.g. W3C + * trace context) supplied to the public `list*()` reaches every wire + * request the walk issues. + */ + private async _listAllPages( + method: RequestMethod, + baseParams: { readonly [key: string]: unknown } | undefined, + options: RequestOptions | undefined, + append: (acc: R, page: R) => void, + finalize?: (acc: R) => void + ): Promise { + // Capture the eviction generation BEFORE page 1: a `list_changed` that + // lands mid-walk bumps the counter, and the terminal `write` skips + // when it observes the bump (the result still returns to the caller — + // it just is not cached). + 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)) { + if (this._listMaxPages !== 0 && pages >= this._listMaxPages) { + throw new SdkError( + SdkErrorCode.ListPaginationExceeded, + `${method}: exceeded listMaxPages (${this._listMaxPages}); server pagination did not terminate`, + { 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; + pages++; + } + acc.nextCursor = undefined; + finalize?.(acc); + await this._cache.write(method, acc, generation); + return acc; + } + + /** Route a custom-store failure to `onerror` without aborting the surrounding dispatch. */ + private _reportStoreError(e: unknown): void { + this.onerror?.(e instanceof Error ? e : new Error(String(e))); + } + + /** + * Compile a single tool's `outputSchema` (or `undefined` when absent / + * uncompilable). Passed as the compile callback to + * {@linkcode ClientResponseCache.outputValidator} so the cache class stays + * free of any validator-provider dependency. One tool's uncompilable + * `outputSchema` (e.g. an invalid `pattern` regex or unresolvable `$ref`) + * must not poison every other tool's `callTool` — warn naming the + * offender and return `undefined` so the validator index simply omits it. + */ + private _compileOutputValidator(tool: Tool): JsonSchemaValidator | undefined { + if (!tool.outputSchema) return undefined; + try { + return this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); + } catch (error) { + console.warn( + `[mcp-sdk] tool '${tool.name}': outputSchema failed to compile and will not be validated — ${error instanceof Error ? error.message : String(error)}` + ); + return undefined; + } } /** Reads the contents of a resource by URI. */ @@ -1542,6 +1724,28 @@ export class Client extends Protocol { * being silently swallowed. */ protected override _onnotification(raw: JSONRPCNotification, extra?: MessageExtraInfo): void { + // Response-cache invalidation: a `list_changed` notification means the + // matching cached list result is stale. Evict (do NOT refetch) before + // dispatch so a handler that reaches the cache observes the cleared + // entry. Runs regardless of whether the user + // configured `listChanged` — derived views (the tool index, output + // validators) must drop the stale entry either way. `raw.method` is + // server-controlled; guard with `Object.hasOwn` so an inherited + // `Object.prototype` member name (`constructor`, `toString`, …) does + // not reach the iteration as a non-iterable function. + const evicted = Object.hasOwn(LIST_CHANGED_EVICTIONS, raw.method) ? LIST_CHANGED_EVICTIONS[raw.method] : undefined; + if (evicted !== undefined) { + for (const method of evicted) { + // `evict()` bumps the generation FIRST and unconditionally + // (the `_cacheListResult` race guard relies on the bump, not + // on the store's evict completing), then awaits the store. A + // custom store's `evict()` may throw or reject; route to + // `onerror` and proceed so dispatch (and the user's + // `listChanged` handler) runs regardless. Fire-and-forget — + // dispatch must not block on an async store. + void this._cache.evict(method).catch(error => this._reportStoreError(error)); + } + } if (raw.method === 'notifications/subscriptions/acknowledged') { const params = raw.params as { _meta?: Record; notifications?: unknown } | undefined; const subscriptionId = params?._meta?.[SUBSCRIPTION_ID_META_KEY]; @@ -1664,8 +1868,22 @@ export class Client extends Protocol { // construction). const result = await this.request({ method: 'tools/call', params }, options); - // Check if the tool has an outputSchema - const validator = this.getToolOutputValidator(params.name); + // Check if the tool has an outputSchema. Reads the cached + // `tools/list` entry (via the response cache's stamp-memoized + // `outputValidator` index) — `callTool` never issues a `tools/list` + // itself; the cache is populated by the caller's own + // {@linkcode listTools | listTools()}. A cold cache means validation + // is skipped (the v1.x opportunistic behaviour, kept so a legacy/stdio + // `callTool` still issues zero extra requests). The cache read is + // guarded the same way as `evict()`/`set()`: a custom store whose + // `get()` rejects AFTER the server has already executed the call must + // not surface as a `callTool()` rejection (a caller that retries on + // failure would re-execute a possibly side-effecting tool). Route to + // `onerror` and degrade to skipping validation — the same outcome as + // a cold cache. + const validator = await this._cache + .outputValidator(params.name, tool => this._compileOutputValidator(tool)) + .catch(error => void this._reportStoreError(error)); if (validator) { // If tool has outputSchema, it MUST return structuredContent (unless it's an error) if (!result.structuredContent && !result.isError) { @@ -1703,47 +1921,29 @@ export class Client extends Protocol { } /** - * Cache validators for tool output schemas. - * Called after {@linkcode listTools | listTools()} to pre-compile validators for better performance. - */ - private cacheToolMetadata(tools: Tool[]): void { - this._cachedToolOutputValidators.clear(); - - for (const tool of tools) { - // If the tool has an outputSchema, create and cache the validator - if (tool.outputSchema) { - const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); - this._cachedToolOutputValidators.set(tool.name, toolValidator); - } - } - } - - /** - * Get cached validator for a tool - */ - private getToolOutputValidator(toolName: string): JsonSchemaValidator | undefined { - return this._cachedToolOutputValidators.get(toolName); - } - - /** - * Lists available tools. Results may be paginated — loop on `nextCursor` to collect all pages. + * Lists available tools. + * + * Called without a `cursor` (the common case), this walks every page and + * returns the complete aggregated list with `nextCursor: undefined`; the + * aggregate is also written to the {@linkcode ResponseCacheStore} (the + * source for {@linkcode callTool | callTool()}'s output-schema validation + * and SEP-2243 `Mcp-Param-*` header mirroring). Pass an explicit + * `{ cursor }` to fetch a single page and walk pagination yourself — the + * per-page path returns the server's raw page (with `nextCursor` for the + * next call) and does not write the response cache. The auto-aggregate + * path is capped by {@linkcode ClientOptions | ClientOptions.listMaxPages} (default 64); + * the per-page path is not. * * Returns an empty list if the server does not advertise tools capability * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). * * @example * ```ts source="./client.examples.ts#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) * ); * ``` */ @@ -1753,12 +1953,13 @@ export class Client extends Protocol { console.debug('Client.listTools() called but server does not advertise tools capability - returning empty list'); return { tools: [] }; } - const result = await this.request({ method: 'tools/list', params }, options); - - // Cache the tools and their output schemas for future validation - this.cacheToolMetadata(result.tools); - - return result; + if (params?.cursor !== undefined) { + // Explicit-cursor per-page contract: return one page; do NOT touch + // the response cache (a single page is not the complete aggregate + // the derived `outputValidator` index keys against). + return await this.request({ method: 'tools/list', params }, options); + } + return this._listAllPages('tools/list', params, options, (acc, page) => acc.tools.push(...page.tools)); } /** diff --git a/packages/client/src/client/responseCache.ts b/packages/client/src/client/responseCache.ts new file mode 100644 index 0000000000..faa3f54195 --- /dev/null +++ b/packages/client/src/client/responseCache.ts @@ -0,0 +1,308 @@ +import type { ListToolsResult, Tool } from '@modelcontextprotocol/core'; + +/** + * Minimal response-cache substrate (the kernel of #39's design). + * + * The store is a dumb keyed-value carrier: every freshness, scope and + * invalidation decision lives in the {@linkcode ClientResponseCache} (the + * `Client`'s single cache-coordination collaborator). This file + * deliberately + * ships only what the SEP-2243 mirroring path and the existing + * `tools/list`-derived validators need today — the full `cacheHints` engine + * (TTL short-circuiting, public/private partitioning, `CacheMode`) lands with + * the rest of #39 on top of the same interface. + * + * Reference design: mcp.d `client/cache.d` / `client/client.d` (`CacheStore`, + * `cachedTool`). The `stamp` field is mcp.d's re-derivation key — a derived + * view (e.g. the `name → Tool` index) re-computes only when the backing + * entry's stamp changes. + */ + +/** A value or a promise of one. The store interface is async-ready; the in-memory default returns plain values. */ +export type MaybePromise = T | Promise; + +/** The freshness scope of a cached entry (#39's `cacheHints.scope`). */ +export type CacheScope = 'public' | 'private'; + +/** + * A logical cache address. `params` is the canonical result-affecting params + * key (`''` for the four list ops, the `uri` for `resources/read`); omitted is + * equivalent to `''`. `partition` is the per-principal identity slot reserved + * for #39's shared-store partitioning — always `''` today (the + * `Client` never populates it); omitted is equivalent to `''`. + */ +export interface CacheKey { + readonly method: string; + readonly params?: string; + readonly partition?: string; +} + +/** + * One cached response body. `value` is the verbatim decoded result; `stamp` is + * the store-generated monotonically increasing write counter — opaque to + * callers. Derived views (e.g. a `name → Tool` index) memoize against it and + * re-derive only when it changes. `expiresAt` and `scope` are the + * client-computed freshness metadata (#39 — `expiresAt = now + ttlMs`, + * `scope` from `cacheHints`); the substrate does not populate them yet, but + * the slot exists so a custom store written today persists them once #39 + * lands without a signature change. + */ +export interface CacheEntry { + readonly value: unknown; + readonly stamp: number; + readonly expiresAt?: number; + readonly scope?: CacheScope; +} + +/** + * The pluggable response-cache store. The interface is intentionally narrow; + * the in-memory default is the only implementation the SDK ships. + * + * Every method is async-ready ({@linkcode MaybePromise}) so a Redis-style + * store can implement the same interface without a later breaking change; the + * in-memory default stays synchronous (plain values are valid under + * `MaybePromise`). The `Client` `await`s every call site. + * + * **A store instance MUST NOT be shared across `Client` instances at + * all in v2.0.x.** Entries are keyed by method + params only (the + * `Client` never populates `partition` today), so two clients + * connected to different servers — even under the same credential — collide on + * `tools/list` (server-identity confusion); a `list_changed` from one server + * evicts every co-tenant's entry; and one client reconnecting drops the + * derived indices that read the shared store. The `Client` + * constructor always allocates a fresh {@linkcode InMemoryResponseCacheStore} + * when `responseCacheStore` is not supplied; pass your own only as a + * single-client backing store. Per-principal partitioning that enables safe + * sharing arrives with the full #39 `cacheHints` engine. + */ +export interface ResponseCacheStore { + get(key: CacheKey): MaybePromise; + /** + * Writes `entry` under `key` and returns the store-generated stamp the + * resulting {@linkcode CacheEntry} carries. The store owns the stamp + * counter; callers do not supply one. The caller owns `expiresAt` and + * `scope` (the client-computed freshness metadata; not yet populated by + * the substrate — #39 wires them); the store MUST persist them and hand + * them back on `get`. + */ + set(key: CacheKey, entry: { value: unknown; expiresAt?: number; scope?: CacheScope }): MaybePromise; + /** Drop every entry for `method` (the `list_changed` invalidation). */ + evict(method: string): MaybePromise; + /** Drop every entry (connection reset). */ + clear(): MaybePromise; +} + +/** + * In-memory default. Unbounded — the four list ops write at most one entry + * each, so a bound is not yet useful; the LRU cap arrives with `resources/read` + * caching in #39. + */ +export class InMemoryResponseCacheStore implements ResponseCacheStore { + private readonly _entries = new Map(); + private _stamp = 0; + + get(key: CacheKey): CacheEntry | undefined { + return this._entries.get(keyOf(key)); + } + + set(key: CacheKey, entry: { value: unknown; expiresAt?: number; scope?: CacheScope }): number { + const stamp = ++this._stamp; + this._entries.set(keyOf(key), { ...entry, stamp }); + return stamp; + } + + evict(method: string): void { + const prefix = `${method}\0`; + for (const k of this._entries.keys()) { + if (k.startsWith(prefix)) this._entries.delete(k); + } + } + + clear(): void { + this._entries.clear(); + } +} + +function keyOf(key: CacheKey): string { + return `${key.method}\0${key.partition ?? ''}\0${key.params ?? ''}`; +} + +/** + * The `Client`'s cache-coordination collaborator. + * + * Owns the per-connection cache state that used to live as five private + * fields on `Client` — the backing {@linkcode ResponseCacheStore}, the + * per-method eviction-generation counter, the user-supplied/default flag, and + * the stamp-memoized derived indices over the `tools/list` entry. `Client` + * holds exactly one instance and never reaches past it to the store. + * + * Not exported from the package index — internal to the client package. + * + * @internal + */ +export class ClientResponseCache { + /** + * Per-method eviction-generation counter. {@linkcode evict} bumps it before + * touching the store; {@linkcode captureGeneration} reads it before a list + * walk's page 1; {@linkcode write} skips when it moved — so a + * `list_changed` arriving mid-walk is not overwritten by the walk's stale + * aggregate. + */ + private readonly _evictionGeneration = new Map(); + /** + * `name → Tool` index derived from the cached `tools/list` entry, memoized + * against the entry's `stamp` so it re-derives only when the backing entry + * changes (mcp.d's `cachedTool` pattern). + */ + private _toolIndex?: { stamp: number; byName: Map }; + /** + * `name → compiled output-schema validator` derived from the cached + * `tools/list` entry; same stamp-keyed memoization as `_toolIndex`. Typed + * `unknown` so this class stays free of any validator-provider dependency + * — the compile callback supplied to {@linkcode outputValidator} owns the + * concrete type. + */ + private _toolOutputValidatorIndex?: { stamp: number; byName: Map }; + + constructor( + private readonly _store: ResponseCacheStore, + /** + * Whether `_store` was supplied by the caller. A user-supplied store is + * never `clear()`ed by {@linkcode resetForReconnect} (defeats the only + * reason to supply one). + */ + private readonly _isUserSupplied: boolean, + /** + * Sink for a custom store's `set()`/`evict()` failure. {@linkcode write} + * never lets a store rejection cost the caller a result it already + * fetched — the failure is reported here and the write resolves. The + * `Client` wires this to `onerror`. + */ + private readonly _reportError: (error: unknown) => void = () => {} + ) {} + + /** + * Bump the per-method generation (so an in-flight {@linkcode write} for the + * same method becomes a no-op) and evict the store entry. The generation + * bump is unconditional and FIRST — the {@linkcode write} race guard relies + * on the bump, not on the store's evict completing. A custom store's + * `evict()` may throw or reject; the caller routes that to `onerror`. + */ + async evict(method: string): Promise { + this._evictionGeneration.set(method, (this._evictionGeneration.get(method) ?? 0) + 1); + await this._store.evict(method); + } + + /** Snapshot the eviction generation for `method` before a list walk's page 1. */ + captureGeneration(method: string): number { + return this._evictionGeneration.get(method) ?? 0; + } + + /** + * Write `value` under `{method}` unless the per-method generation moved + * since `capturedGen` was taken — a `list_changed` that landed mid-walk has + * already invalidated the result the caller is about to write, and + * overwriting the eviction with the stale aggregate would lose the + * invalidation. + * + * The stored value is a `structuredClone` of `value`, so a caller + * mutating the aggregate it was returned (e.g. `result.tools.sort(...)`) + * cannot reach the cache or the stamp-memoized indices derived from it. A + * custom store whose `set()` throws or rejects is routed to the + * `reportError` sink and the write resolves — cache bookkeeping never + * costs the caller a result it already fetched (consistent with the + * eviction path). + */ + async write(method: string, value: unknown, capturedGen: number): Promise { + if ((this._evictionGeneration.get(method) ?? 0) !== capturedGen) return; + try { + await this._store.set({ method }, { value: structuredClone(value) }); + } catch (error) { + this._reportError(error); + } + } + + /** Read the cached entry for `{method}` (the four list verbs). */ + async read(method: string): Promise { + return this._store.get({ method }); + } + + /** + * Connection reset. The per-instance default store IS cleared + * (connection-scoped); a user-supplied store is NOT — that would defeat + * the only reason to supply one. The generation map and every derived + * index are dropped regardless: they are connection-scoped even when the + * backing store survives, so the next read re-derives from whatever the + * store still holds. The default impl is synchronous, so the + * `MaybePromise` return is a plain void here and the caller need not + * await. + */ + resetForReconnect(): void { + if (!this._isUserSupplied) void this._store.clear(); + this._evictionGeneration.clear(); + this._toolIndex = undefined; + this._toolOutputValidatorIndex = undefined; + } + + /** + * The descriptor for tool `name` taken from the cached `tools/list` entry. + * The `name → Tool` index is memoized against the entry's `stamp` and + * re-derived only when the backing entry changes (mcp.d's `cachedTool`). + * Returns `undefined` only when no `tools/list` response is held at all, + * or the held list does not contain `name`. + * + * No production caller in the substrate commit — the stacked SEP-2243 PR + * wires `callTool()`'s `Mcp-Param-*` mirroring through it. + * {@linkcode outputValidator} is the substrate's own derived view over the + * same entry. + */ + async toolDefinition(name: string): Promise { + const entry = await this._store.get({ method: 'tools/list' }); + if (entry === undefined) { + this._toolIndex = undefined; + return undefined; + } + if (this._toolIndex?.stamp !== entry.stamp) { + const byName = new Map(); + for (const tool of (entry.value as ListToolsResult).tools) byName.set(tool.name, tool); + this._toolIndex = { stamp: entry.stamp, byName }; + } + return this._toolIndex.byName.get(name); + } + + /** + * The compiled output-schema validator for tool `name`, derived from the + * cached `tools/list` entry — same source and same stamp-keyed + * memoization as {@linkcode toolDefinition}. The `name → validator` index + * re-derives only when the backing entry's stamp changes (a refetched + * `tools/list` recompiles; a `list_changed` eviction drops it). Returns + * `undefined` when no `tools/list` is held, the tool is absent, or it has + * no `outputSchema`. + * + * `compile` is the caller-supplied validator-compile callback (the + * `Client` passes its `_jsonSchemaValidator` wrapper) so this + * class carries no validator-provider dependency. One tool's uncompilable + * `outputSchema` (e.g. an invalid `pattern` regex or unresolvable `$ref`) + * must not poison every other tool's `callTool` — the callback returns + * `undefined` (and warns naming the offender) for the bad one and the + * index simply omits it. + */ + async outputValidator(name: string, compile: (tool: Tool) => V | undefined): Promise { + const entry = await this._store.get({ method: 'tools/list' }); + if (entry === undefined) { + this._toolOutputValidatorIndex = undefined; + return undefined; + } + if (this._toolOutputValidatorIndex?.stamp !== entry.stamp) { + const byName = new Map(); + for (const tool of (entry.value as ListToolsResult).tools) { + if (tool.outputSchema) { + const validator = compile(tool); + if (validator !== undefined) byName.set(tool.name, validator); + } + } + this._toolOutputValidatorIndex = { stamp: entry.stamp, byName }; + } + return this._toolOutputValidatorIndex.byName.get(name) as V | undefined; + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7fe7acb958..de5814a8c2 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -59,6 +59,8 @@ export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, Request export { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from './client/crossAppAccess.js'; export type { LoggingOptions, Middleware, RequestLogger } from './client/middleware.js'; export { applyMiddlewares, createMiddleware, withLogging, withOAuth } from './client/middleware.js'; +export type { CacheEntry, CacheKey, CacheScope, MaybePromise, ResponseCacheStore } from './client/responseCache.js'; +export { InMemoryResponseCacheStore } from './client/responseCache.js'; export type { SSEClientTransportOptions } from './client/sse.js'; export { SSEClientTransport, SseError } from './client/sse.js'; export type { VersionNegotiationMode, VersionNegotiationOptions, VersionNegotiationProbeOptions } from './client/versionNegotiation.js'; diff --git a/packages/client/test/client/jsonSchemaValidatorOverride.test.ts b/packages/client/test/client/jsonSchemaValidatorOverride.test.ts index 2e38f618c5..87ff1e9055 100644 --- a/packages/client/test/client/jsonSchemaValidatorOverride.test.ts +++ b/packages/client/test/client/jsonSchemaValidatorOverride.test.ts @@ -48,6 +48,12 @@ async function connectInitializedClient(client: Client) { ] } } satisfies JSONRPCMessage); + } else if ('method' in message && 'id' in message && message.method === 'tools/call') { + await serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { content: [], structuredContent: { count: 42 } } + } satisfies JSONRPCMessage); } }; @@ -56,7 +62,7 @@ async function connectInitializedClient(client: Client) { } describe('client JSON Schema validator overrides', () => { - test('Client constructor uses a custom validator for tool output schema caching', async () => { + test('Client uses the custom validator for tool output validation (derived from the cached tools/list entry)', async () => { const validator = new RecordingValidator(); const client = new Client( { name: 'test-client', version: '1.0.0' }, @@ -67,6 +73,8 @@ describe('client JSON Schema validator overrides', () => { ); const { clientTransport, serverTransport } = await connectInitializedClient(client); + // The validator index reads the cached `tools/list` entry; populate it + // via the public auto-aggregating listTools(). await expect(client.listTools()).resolves.toMatchObject({ tools: [ { @@ -80,6 +88,14 @@ describe('client JSON Schema validator overrides', () => { ] }); + // Derived-view behavior: the validator index re-derives lazily on the + // first callTool against the cached entry's stamp — populating the + // cache alone does not compile. + expect(validator.schemas).toEqual([]); + + await expect(client.callTool({ name: 'structured-tool' })).resolves.toMatchObject({ + structuredContent: { count: 42 } + }); expect(validator.schemas).toEqual([ { type: 'object', @@ -87,6 +103,11 @@ describe('client JSON Schema validator overrides', () => { required: ['count'] } ]); + expect(validator.values).toEqual([{ count: 42 }]); + + // Same backing entry stamp → memoized; a second callTool does not recompile. + await client.callTool({ name: 'structured-tool' }); + expect(validator.schemas).toHaveLength(1); await client.close(); await clientTransport.close(); diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts new file mode 100644 index 0000000000..127be04bf8 --- /dev/null +++ b/packages/client/test/client/responseCache.test.ts @@ -0,0 +1,429 @@ +/** + * Response-cache substrate: store primitives, the {@linkcode ClientResponseCache} + * coordinator, and the Client's wiring (mcp.d's `cachedTool` pattern). + * + * Covers: `list*` auto-aggregation writing one entry; `list_changed` evicts + * (does not refetch); `resetForReconnect` respects the user-supplied flag; + * `toolDefinition` hit/miss and re-derivation only on a stamp change; the + * generation guard skipping a stale write. + */ +import type { JSONRPCMessage, JSONRPCRequest, Tool } from '@modelcontextprotocol/core'; +import { InMemoryTransport, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { Client } from '../../src/client/client.js'; +import type { ResponseCacheStore } from '../../src/client/responseCache.js'; +import { ClientResponseCache, InMemoryResponseCacheStore } from '../../src/client/responseCache.js'; + +const MODERN = '2026-07-28'; + +const TOOL_A: Tool = { name: 'a', inputSchema: { type: 'object', properties: {} } }; +const TOOL_B: Tool = { name: 'b', inputSchema: { type: 'object', properties: {} } }; + +describe('InMemoryResponseCacheStore', () => { + it('get/set/evict/clear round-trip; evict is method-scoped; set returns the store-generated stamp', () => { + const store = new InMemoryResponseCacheStore(); + const s1 = store.set({ method: 'tools/list' }, { value: 1 }); + const s2 = store.set({ method: 'prompts/list' }, { value: 2 }); + const s3 = store.set({ method: 'resources/read', params: 'file:///a' }, { value: 3, expiresAt: 123, scope: 'private' }); + // Store owns the stamp counter: monotonic, opaque to callers, surfaced on the entry. + expect(s2).toBeGreaterThan(s1); + expect(s3).toBeGreaterThan(s2); + expect(store.get({ method: 'tools/list' })).toEqual({ value: 1, stamp: s1 }); + // Store persists caller-supplied freshness metadata (#39 wires population; the slot exists today). + expect(store.get({ method: 'resources/read', params: 'file:///a' })).toEqual({ + value: 3, + stamp: s3, + expiresAt: 123, + scope: 'private' + }); + expect(store.get({ method: 'tools/list', params: '', partition: '' })?.value).toBe(1); + store.evict('tools/list'); + expect(store.get({ method: 'tools/list' })).toBeUndefined(); + expect(store.get({ method: 'prompts/list' })?.value).toBe(2); + expect(store.get({ method: 'resources/read', params: 'file:///a' })?.value).toBe(3); + store.clear(); + expect(store.get({ method: 'prompts/list' })).toBeUndefined(); + }); + + it('partition is part of the key serialization (always empty today; #39 wires population)', () => { + const store = new InMemoryResponseCacheStore(); + store.set({ method: 'tools/list', partition: 'p1' }, { value: 'a' }); + store.set({ method: 'tools/list', partition: 'p2' }, { value: 'b' }); + expect(store.get({ method: 'tools/list', partition: 'p1' })?.value).toBe('a'); + expect(store.get({ method: 'tools/list', partition: 'p2' })?.value).toBe('b'); + // The Client never populates partition today, so the default-partition slot is distinct. + expect(store.get({ method: 'tools/list' })).toBeUndefined(); + // evict(method) is partition-agnostic. + store.evict('tools/list'); + expect(store.get({ method: 'tools/list', partition: 'p1' })).toBeUndefined(); + expect(store.get({ method: 'tools/list', partition: 'p2' })).toBeUndefined(); + }); +}); + +describe('ClientResponseCache', () => { + it('write skips when the captured generation moved (list_changed-during-walk guard)', async () => { + const store = new InMemoryResponseCacheStore(); + const cache = new ClientResponseCache(store, false); + const gen = cache.captureGeneration('tools/list'); + await cache.evict('tools/list'); + await cache.write('tools/list', { tools: [TOOL_A] }, gen); + // Generation moved between capture and write → the stale aggregate is dropped. + expect(store.get({ method: 'tools/list' })).toBeUndefined(); + // A fresh capture after the evict writes through. + const gen2 = cache.captureGeneration('tools/list'); + await cache.write('tools/list', { tools: [TOOL_A] }, gen2); + expect(store.get({ method: 'tools/list' })).toBeDefined(); + }); + + it('resetForReconnect: clears the default store, leaves a user-supplied store, ALWAYS drops generation + indices', async () => { + // User-supplied: store survives, generation map + derived index are dropped. + const userStore = new InMemoryResponseCacheStore(); + const userCache = new ClientResponseCache(userStore, true); + await userCache.write('tools/list', { tools: [TOOL_A] }, userCache.captureGeneration('tools/list')); + expect((await userCache.toolDefinition('a'))?.name).toBe('a'); + await userCache.evict('prompts/list'); + expect(userCache.captureGeneration('prompts/list')).toBe(1); + userCache.resetForReconnect(); + expect(userStore.get({ method: 'tools/list' })).toBeDefined(); + expect(userCache.captureGeneration('prompts/list')).toBe(0); + // Index dropped → re-derived from the (still-populated) store on next read. + expect((userCache as unknown as { _toolIndex?: unknown })._toolIndex).toBeUndefined(); + expect((await userCache.toolDefinition('a'))?.name).toBe('a'); + + // Default: store is cleared. + const defStore = new InMemoryResponseCacheStore(); + const defCache = new ClientResponseCache(defStore, false); + await defCache.write('tools/list', { tools: [TOOL_A] }, defCache.captureGeneration('tools/list')); + defCache.resetForReconnect(); + expect(defStore.get({ method: 'tools/list' })).toBeUndefined(); + expect(await defCache.toolDefinition('a')).toBeUndefined(); + }); + + it('write stores a defensive copy: caller-side mutation cannot reach the cache or its derived index', async () => { + const store = new InMemoryResponseCacheStore(); + const cache = new ClientResponseCache(store, false); + const value = { tools: [{ ...TOOL_A }, { ...TOOL_B }] }; + await cache.write('tools/list', value, cache.captureGeneration('tools/list')); + // Mutate the caller's reference (the same object _listAllPages returns). + value.tools.length = 0; + // The cached entry is a structuredClone, so the store and the + // stamp-memoized index are unaffected. + expect((store.get({ method: 'tools/list' })?.value as { tools: Tool[] }).tools.map(t => t.name)).toEqual(['a', 'b']); + expect((await cache.toolDefinition('a'))?.name).toBe('a'); + expect((await cache.toolDefinition('b'))?.name).toBe('b'); + }); + + it('a custom store whose set() rejects is routed to reportError and write still resolves', async () => { + const store: ResponseCacheStore = new InMemoryResponseCacheStore(); + store.set = () => Promise.reject(new Error('redis down')); + const reported: unknown[] = []; + const cache = new ClientResponseCache(store, true, e => reported.push(e)); + // The write resolves (cache bookkeeping never costs the caller a fetched + // result) and the failure is reported via the sink. + await expect(cache.write('tools/list', { tools: [TOOL_A] }, cache.captureGeneration('tools/list'))).resolves.toBeUndefined(); + expect(reported).toHaveLength(1); + expect((reported[0] as Error).message).toBe('redis down'); + }); + + it('toolDefinition: miss before any list, hit after, memoized index re-derives only on stamp change', async () => { + const store = new InMemoryResponseCacheStore(); + const cache = new ClientResponseCache(store, true); + expect(await cache.toolDefinition('a')).toBeUndefined(); + + store.set({ method: 'tools/list' }, { value: { tools: [TOOL_A, TOOL_B] } }); + const hit = await cache.toolDefinition('a'); + expect(hit?.name).toBe('a'); + // Same backing entry → identical reference (memoized index, not re-derived). + expect(await cache.toolDefinition('a')).toBe(hit); + + // A fresh write bumps the store stamp → the index re-derives (the new + // entry's tool instance is what comes back, not the memoized one). + store.set({ method: 'tools/list' }, { value: { tools: [{ ...TOOL_A }, { ...TOOL_B }] } }); + const hit2 = await cache.toolDefinition('a'); + expect(hit2?.name).toBe('a'); + expect(hit2).not.toBe(hit); + }); +}); + +interface Scripted { + clientTx: InMemoryTransport; + serverTx: InMemoryTransport; + listCount: () => number; + listParams: () => ({ cursor?: string; _meta?: unknown } | undefined)[]; +} + +async function scriptedModernServer(pages: Tool[][]): Promise { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + let lists = 0; + const params: ({ cursor?: string; _meta?: unknown } | undefined)[] = []; + serverTx.onmessage = m => { + const r = m as JSONRPCRequest; + if (r.id === undefined) return; + if (r.method === 'server/discover') { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: { listChanged: true }, prompts: {}, resources: {} }, + serverInfo: { name: 'scripted', version: '1.0.0' } + } + }); + } else if (r.method === 'tools/list') { + 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); + const next = idx + 1 < pages.length ? String(idx + 1) : undefined; + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + tools: pages[idx] ?? [], + ...(next !== undefined && { nextCursor: next }) + } + }); + } else if (r.method === 'prompts/list' || r.method === 'resources/list' || r.method === 'resources/templates/list') { + const key = r.method === 'prompts/list' ? 'prompts' : r.method === 'resources/list' ? 'resources' : 'resourceTemplates'; + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { resultType: 'complete', ttlMs: 0, cacheScope: 'private', [key]: [] } + }); + } + }; + await serverTx.start(); + return { clientTx, serverTx, listCount: () => lists, listParams: () => params }; +} + +function modernClient(store?: InMemoryResponseCacheStore): Client { + return new Client( + { name: 'cache-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, ...(store && { responseCacheStore: store }) } + ); +} + +/** Reach the private `_cache` collaborator for testing the derived view through the Client wiring. */ +const cacheOf = (client: Client): ClientResponseCache => (client as unknown as { _cache: ClientResponseCache })._cache; +const toolDef = (client: Client, name: string): Promise => cacheOf(client).toolDefinition(name); + +describe('Client response-cache substrate', () => { + it('listTools() with no cursor reads every page, writes one cache entry; listTools({cursor}) stays per-page and does not write', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, listCount } = await scriptedModernServer([[TOOL_A], [TOOL_B]]); + const client = modernClient(store); + await client.connect(clientTx); + + // Explicit cursor → one page, NO cache write (partial pages never go in). + const page = await client.listTools({ cursor: '1' }); + expect(page.tools.map(t => t.name)).toEqual(['b']); + expect(page.nextCursor).toBeUndefined(); + expect(store.get({ method: 'tools/list' })).toBeUndefined(); + expect(listCount()).toBe(1); + + // No cursor → aggregates every page and writes one entry. + const { tools, nextCursor } = await client.listTools(); + expect(tools.map(t => t.name)).toEqual(['a', 'b']); + expect(nextCursor).toBeUndefined(); + expect(listCount()).toBe(3); + + const entry = store.get({ method: 'tools/list' }); + expect((entry?.value as { tools: Tool[] }).tools.map(t => t.name)).toEqual(['a', 'b']); + }); + + 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(); + await client.connect(clientTx); + + const traceparent = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; + const { tools } = await client.listTools({ _meta: { traceparent } }); + expect(tools.map(t => t.name)).toEqual(['a', 'b', 'a']); + // _listAllPages threads {...baseParams} on page 1 and {...baseParams, cursor} + // on every follow-up page, so the caller's _meta reaches every wire + // request the walk issues. + expect(listParams()).toHaveLength(3); + for (const p of listParams()) { + // The Protocol layer may auto-attach the modern-era envelope into + // _meta; assert the caller's key is present rather than exact-match. + expect((p?._meta as { traceparent?: string } | undefined)?.traceparent).toBe(traceparent); + } + expect(listParams().map(p => p?.cursor)).toEqual([undefined, '1', '2']); + }); + + it('mutating the returned aggregate does not corrupt the cache or its derived index', async () => { + const { clientTx } = await scriptedModernServer([[TOOL_A], [TOOL_B]]); + const client = modernClient(); + await client.connect(clientTx); + + const result = await client.listTools(); + expect((await toolDef(client, 'a'))?.name).toBe('a'); + // Common previously-harmless caller patterns. + result.tools.sort((x, y) => y.name.localeCompare(x.name)); + result.tools.length = 0; + // ClientResponseCache.write stored a structuredClone, so neither the + // backing entry nor the stamp-memoized name → Tool index moved. + expect((await toolDef(client, 'a'))?.name).toBe('a'); + expect((await toolDef(client, 'b'))?.name).toBe('b'); + }); + + it('the auto-aggregate path throws SdkError(ListPaginationExceeded) when listMaxPages is hit and does not write a partial entry', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx } = await scriptedModernServer([[TOOL_A], [TOOL_B], [TOOL_A]]); + const client = new Client( + { name: 'cache-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, responseCacheStore: store, listMaxPages: 2 } + ); + await client.connect(clientTx); + + const error = await client.listTools().catch(e => e as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.ListPaginationExceeded); + expect((error as SdkError).message).toMatch(/exceeded listMaxPages \(2\); server pagination did not terminate/); + expect((error as SdkError).data).toEqual({ method: 'tools/list', listMaxPages: 2 }); + // Aggregate-then-write: the throw happens before the cache write, so nothing is cached. + expect(store.get({ method: 'tools/list' })).toBeUndefined(); + // The per-page path is never capped. + const page = await client.listTools({ cursor: '2' }); + expect(page.tools.map(t => t.name)).toEqual(['a']); + }); + + it('listPrompts/listResources/listResourceTemplates auto-aggregate and write the response cache', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + await client.connect(clientTx); + + await client.listPrompts(); + await client.listResources(); + await client.listResourceTemplates(); + expect(store.get({ method: 'prompts/list' })).toBeDefined(); + expect(store.get({ method: 'resources/list' })).toBeDefined(); + expect(store.get({ method: 'resources/templates/list' })).toBeDefined(); + }); + + it('toolDefinition through the Client wiring: miss before any list, hit after', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx } = await scriptedModernServer([[TOOL_A, TOOL_B]]); + const client = modernClient(store); + await client.connect(clientTx); + + expect(await toolDef(client, 'a')).toBeUndefined(); + await client.listTools(); + expect((await toolDef(client, 'a'))?.name).toBe('a'); + expect((await toolDef(client, 'b'))?.name).toBe('b'); + }); + + it('notifications/tools/list_changed evicts the tools/list entry (no refetch)', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, serverTx, listCount } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + await client.connect(clientTx); + await client.listTools(); + expect(store.get({ method: 'tools/list' })).toBeDefined(); + expect(await toolDef(client, 'a')).toBeDefined(); + + const before = listCount(); + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' } as JSONRPCMessage); + // Evicted, not refetched. + expect(store.get({ method: 'tools/list' })).toBeUndefined(); + expect(await toolDef(client, 'a')).toBeUndefined(); + expect(listCount()).toBe(before); + }); + + it('notifications/resources/list_changed evicts both resources list verbs', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, serverTx } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + await client.connect(clientTx); + await client.listResources(); + await client.listResourceTemplates(); + expect(store.get({ method: 'resources/list' })).toBeDefined(); + expect(store.get({ method: 'resources/templates/list' })).toBeDefined(); + + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/resources/list_changed' } as JSONRPCMessage); + expect(store.get({ method: 'resources/list' })).toBeUndefined(); + expect(store.get({ method: 'resources/templates/list' })).toBeUndefined(); + }); + + it('_resetConnectionState leaves a user-supplied store untouched and drops the derived index', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + await client.connect(clientTx); + await client.listTools(); + expect(store.get({ method: 'tools/list' })).toBeDefined(); + + await client.close(); + // A user-supplied store is NOT cleared on close/reconnect (defeats the + // only reason to supply one); the per-instance default IS cleared. + expect(store.get({ method: 'tools/list' })).toBeDefined(); + // The derived index is connection-scoped regardless: it is dropped, and + // the next read re-derives from the (still-populated) store. + expect((cacheOf(client) as unknown as { _toolIndex?: unknown })._toolIndex).toBeUndefined(); + }); + + it('a notification whose method is an Object.prototype name does not abort dispatch', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, serverTx } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + let fallback: string | undefined; + client.fallbackNotificationHandler = async n => { + fallback = n.method; + }; + let errored = false; + client.onerror = () => { + errored = true; + }; + await client.connect(clientTx); + + await serverTx.send({ jsonrpc: '2.0', method: 'constructor' } as JSONRPCMessage); + // The `Object.hasOwn` guard means `constructor` (an inherited prototype + // member) is NOT looked up as an eviction list and dispatch reaches the + // fallback handler without an error. + expect(errored).toBe(false); + expect(fallback).toBe('constructor'); + }); + + it('a custom store whose set() rejects is routed to onerror and the aggregate still returns', async () => { + const store = new InMemoryResponseCacheStore(); + (store as ResponseCacheStore).set = () => Promise.reject(new Error('redis down')); + const { clientTx } = await scriptedModernServer([[TOOL_A], [TOOL_B]]); + const client = modernClient(store); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + await client.connect(clientTx); + + // Cache bookkeeping never costs the caller a result it already fetched + // (consistent with the eviction path): the store failure is reported + // via onerror and the fully-fetched aggregate still comes back. + const { tools } = await client.listTools(); + expect(tools.map(t => t.name)).toEqual(['a', 'b']); + expect(errors.map(e => e.message)).toContain('redis down'); + }); + + it('a custom store whose evict() throws is routed to onerror and dispatch still runs', async () => { + const store = new InMemoryResponseCacheStore(); + store.evict = () => { + throw new Error('boom'); + }; + const { clientTx, serverTx } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + let dispatched = false; + client.setNotificationHandler('notifications/tools/list_changed', async () => { + dispatched = true; + }); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + await client.connect(clientTx); + + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' } as JSONRPCMessage); + expect(errors.map(e => e.message)).toContain('boom'); + expect(dispatched).toBe(true); + }); +}); diff --git a/packages/core/src/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts index ac9435102e..b808d98877 100644 --- a/packages/core/src/errors/sdkErrors.ts +++ b/packages/core/src/errors/sdkErrors.ts @@ -43,6 +43,15 @@ export enum SdkErrorCode { * the flow manually. */ InputRequiredRoundsExceeded = 'INPUT_REQUIRED_ROUNDS_EXCEEDED', + /** + * The auto-aggregating no-`cursor` `listTools()` / `listPrompts()` / + * `listResources()` / `listResourceTemplates()` walk hit the + * `ClientOptions.listMaxPages` cap without the server's pagination + * converging. `data.method` carries the list verb and + * `data.listMaxPages` the cap that was hit; raise the cap or fall back to + * explicit per-page `{ cursor }` calls. + */ + ListPaginationExceeded = 'LIST_PAGINATION_EXCEEDED', /** * The spec method being sent does not exist on the negotiated protocol * version's wire era (e.g. `tasks/get` toward a 2026-07-28 peer, or diff --git a/packages/core/test/types/errorSurfacePins.test.ts b/packages/core/test/types/errorSurfacePins.test.ts index bfd6730385..afef72e2ee 100644 --- a/packages/core/test/types/errorSurfacePins.test.ts +++ b/packages/core/test/types/errorSurfacePins.test.ts @@ -76,6 +76,7 @@ describe('SdkErrorCode', () => { InvalidResult: 'INVALID_RESULT', UnsupportedResultType: 'UNSUPPORTED_RESULT_TYPE', InputRequiredRoundsExceeded: 'INPUT_REQUIRED_ROUNDS_EXCEEDED', + ListPaginationExceeded: 'LIST_PAGINATION_EXCEEDED', MethodNotSupportedByProtocolVersion: 'METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION', EraNegotiationFailed: 'ERA_NEGOTIATION_FAILED', ClientHttpNotImplemented: 'CLIENT_HTTP_NOT_IMPLEMENTED', diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 5058dcdd5d..6fff6c79d6 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -441,13 +441,7 @@ export const REQUIREMENTS: Record = { 'tools:list:pagination': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#listing-tools', behavior: - 'tools/list supports cursor pagination: the nextCursor returned by a list handler round-trips back to the handler as an opaque cursor until the listing is exhausted.', - knownFailures: [ - { - test: 'mcpserver', - note: 'McpServer does not implement automatic pagination — handlers receive the cursor but the high-level API returns the full list with no nextCursor unless the user implements cursor handling in their own handler.' - } - ] + 'tools/list supports cursor pagination: the nextCursor returned by a list handler round-trips back to the handler as an opaque cursor until the listing is exhausted.' }, 'tools:call:concurrent': { source: 'sdk', @@ -571,13 +565,7 @@ export const REQUIREMENTS: Record = { }, 'resources:list:pagination': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/resources#listing-resources', - behavior: 'resources/list supports cursor pagination.', - knownFailures: [ - { - test: 'mcpserver', - note: 'McpServer does not implement automatic pagination — handlers receive the cursor but the high-level API returns the full list with no nextCursor unless the user implements cursor handling in their own handler.' - } - ] + behavior: 'resources/list supports cursor pagination.' }, 'resources:read:blob': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/resources#reading-resources', @@ -612,13 +600,7 @@ export const REQUIREMENTS: Record = { }, 'resources:templates:pagination': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination#operations-supporting-pagination', - behavior: 'resources/templates/list supports cursor pagination.', - knownFailures: [ - { - test: 'mcpserver', - note: 'McpServer does not implement automatic pagination — handlers receive the cursor but the high-level API returns the full list with no nextCursor unless the user implements cursor handling in their own handler.' - } - ] + behavior: 'resources/templates/list supports cursor pagination.' }, 'resources:unsubscribe:stops-updates': { transports: STATEFUL_TRANSPORTS, @@ -711,13 +693,7 @@ export const REQUIREMENTS: Record = { }, 'prompts:list:pagination': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/prompts#listing-prompts', - behavior: 'prompts/list supports cursor pagination.', - knownFailures: [ - { - test: 'mcpserver', - note: 'McpServer does not implement automatic pagination — handlers receive the cursor but the high-level API returns the full list with no nextCursor unless the user implements cursor handling in their own handler.' - } - ] + behavior: 'prompts/list supports cursor pagination.' }, 'prompts:get:multi-message': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/prompts#getting-a-prompt', diff --git a/test/e2e/scenarios/pagination.test.ts b/test/e2e/scenarios/pagination.test.ts index e0106494d6..93c522431a 100644 --- a/test/e2e/scenarios/pagination.test.ts +++ b/test/e2e/scenarios/pagination.test.ts @@ -95,19 +95,20 @@ verifies('pagination:client:cursor-handling', async ({ transport }: TestArgs) => await using _ = await wire(transport, makeServer, client); const tap = tapWire(client); - const collectedPages: string[][] = []; - let result = await client.listTools(); - collectedPages.push(result.tools.map(t => t.name)); - while (result.nextCursor !== undefined) { - // A run-away loop means the test fixture, not the SDK, is broken — fail fast instead of hitting the suite timeout. - if (collectedPages.length >= pages.size) throw new Error('nextCursor still present after the last page'); - result = await client.listTools({ cursor: result.nextCursor }); - collectedPages.push(result.tools.map(t => t.name)); - } + // No-arg listTools() auto-aggregates; the client walks the cursor chain on the wire. + const result = await client.listTools(); + expect(result.nextCursor).toBeUndefined(); + expect(result.tools.map(t => t.name)).toEqual([ + 'get_weather', + 'get_forecast', + 'get_alerts', + 'convert_units', + 'list_stations', + 'get_station' + ]); - // The handler got back exactly the cursors it issued, and every page arrived once, in order. + // The handler got back exactly the cursors it issued, once each, in order. expect(receivedCursors).toEqual([undefined, cursorToPage2, cursorToPage3]); - expect(collectedPages).toEqual([['get_weather', 'get_forecast', 'get_alerts'], ['convert_units'], ['list_stations', 'get_station']]); // The wire requests carried the server-issued strings byte-for-byte — opaque, unparsed, unmodified. const wireListRequests = tap.sent.filter(m => isJSONRPCRequest(m)).filter(m => m.method === 'tools/list'); diff --git a/test/e2e/scenarios/prompts.test.ts b/test/e2e/scenarios/prompts.test.ts index b5052b5572..fa941cafe0 100644 --- a/test/e2e/scenarios/prompts.test.ts +++ b/test/e2e/scenarios/prompts.test.ts @@ -128,21 +128,11 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const first = await client.listPrompts(); - expect(first.prompts.length).toBeLessThan(TOTAL); - expect(first.nextCursor).toBeDefined(); - - const seen = new Set(first.prompts.map(p => p.name)); - let result = first; - let pages = 1; - while (result.nextCursor !== undefined) { - result = await client.listPrompts({ cursor: result.nextCursor }); - for (const p of result.prompts) seen.add(p.name); - pages++; - expect(pages).toBeLessThan(50); - } - expect(seen.size).toBe(TOTAL); - expect(pages).toBeGreaterThan(1); + // No-arg listPrompts() auto-aggregates every page. + const all = await client.listPrompts(); + expect(all.prompts.length).toBe(TOTAL); + expect(all.nextCursor).toBeUndefined(); + expect(new Set(all.prompts.map(p => p.name)).size).toBe(TOTAL); }, { title: 'mcpserver' } ); @@ -174,29 +164,20 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const seen = new Set(); - const cursorsSent: string[] = []; - let pages = 0; - let result = await client.listPrompts(); - expect(result.nextCursor).toBeDefined(); - for (;;) { - for (const p of result.prompts) { - expect(seen.has(p.name)).toBe(false); - seen.add(p.name); - } - pages++; - if (result.nextCursor === undefined) break; - cursorsSent.push(result.nextCursor); - result = await client.listPrompts({ cursor: result.nextCursor }); - expect(pages).toBeLessThan(50); - } - - expect(pages).toBe(3); + // No-arg listPrompts() auto-aggregates every page; the server receives + // the cursor walk verbatim (protocol-level pagination is what is + // verified here). + const result = await client.listPrompts(); + expect(result.nextCursor).toBeUndefined(); + const seen = new Set(result.prompts.map(p => p.name)); expect(seen.size).toBe(TOTAL); for (const name of all) expect(seen.has(name)).toBe(true); - expect(cursorsReceived).toEqual([undefined, '10', '20']); - expect(cursorsSent).toEqual(['10', '20']); + + // Explicit cursor → one raw page (per-page path). + const page = await client.listPrompts({ cursor: '10' }); + expect(page.prompts.length).toBe(PAGE); + expect(page.nextCursor).toBe('20'); }, { title: 'raw server' } ); diff --git a/test/e2e/scenarios/resources.test.ts b/test/e2e/scenarios/resources.test.ts index ea83696915..50341df406 100644 --- a/test/e2e/scenarios/resources.test.ts +++ b/test/e2e/scenarios/resources.test.ts @@ -78,21 +78,11 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const first = await client.listResources(); - expect(first.resources.length).toBeLessThan(TOTAL); - expect(first.nextCursor).toBeDefined(); - - const seen = new Set(first.resources.map(r => r.uri)); - let result = first; - let pages = 1; - while (result.nextCursor !== undefined) { - result = await client.listResources({ cursor: result.nextCursor }); - for (const r of result.resources) seen.add(r.uri); - pages++; - expect(pages).toBeLessThan(50); - } - expect(seen.size).toBe(TOTAL); - expect(pages).toBeGreaterThan(1); + // No-arg listResources() auto-aggregates every page. + const all = await client.listResources(); + expect(all.resources.length).toBe(TOTAL); + expect(all.nextCursor).toBeUndefined(); + expect(new Set(all.resources.map(r => r.uri)).size).toBe(TOTAL); }, { title: 'mcpserver' } ); @@ -122,30 +112,20 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const seen = new Set(); - const cursorsSent: string[] = []; - let pages = 0; - let result = await client.listResources(); - expect(result.nextCursor).toBeDefined(); - for (;;) { - for (const r of result.resources) { - expect(seen.has(r.uri)).toBe(false); - seen.add(r.uri); - } - pages++; - if (result.nextCursor === undefined) break; - cursorsSent.push(result.nextCursor); - result = await client.listResources({ cursor: result.nextCursor }); - expect(pages).toBeLessThan(50); - } - + // No-arg listResources() auto-aggregates every page; the server + // receives the cursor walk verbatim (protocol-level pagination is + // what is verified here). + const result = await client.listResources(); expect(result.nextCursor).toBeUndefined(); - expect(pages).toBe(3); + const seen = new Set(result.resources.map(r => r.uri)); expect(seen.size).toBe(TOTAL); for (const name of all) expect(seen.has(name)).toBe(true); - expect(cursorsReceived).toEqual([undefined, '10', '20']); - expect(cursorsSent).toEqual(['10', '20']); + + // Explicit cursor → one raw page (per-page path). + const page = await client.listResources({ cursor: '10' }); + expect(page.resources.length).toBe(PAGE); + expect(page.nextCursor).toBe('20'); }, { title: 'raw server' } ); @@ -498,21 +478,11 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const first = await client.listResourceTemplates(); - expect(first.resourceTemplates.length).toBeLessThan(TOTAL); - expect(first.nextCursor).toBeDefined(); - - const seen = new Set(first.resourceTemplates.map(t => t.uriTemplate)); - let result = first; - let pages = 1; - while (result.nextCursor !== undefined) { - result = await client.listResourceTemplates({ cursor: result.nextCursor }); - for (const t of result.resourceTemplates) seen.add(t.uriTemplate); - pages++; - expect(pages).toBeLessThan(50); - } - expect(seen.size).toBe(TOTAL); - expect(pages).toBeGreaterThan(1); + // No-arg listResourceTemplates() auto-aggregates every page. + const all = await client.listResourceTemplates(); + expect(all.resourceTemplates.length).toBe(TOTAL); + expect(all.nextCursor).toBeUndefined(); + expect(new Set(all.resourceTemplates.map(t => t.uriTemplate)).size).toBe(TOTAL); }, { title: 'mcpserver' } ); @@ -539,25 +509,17 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const seen = new Set(); - let pages = 0; - let result = await client.listResourceTemplates(); - expect(result.nextCursor).toBeDefined(); - for (;;) { - for (const t of result.resourceTemplates) { - expect(seen.has(t.uriTemplate)).toBe(false); - seen.add(t.uriTemplate); - } - pages++; - if (result.nextCursor === undefined) break; - result = await client.listResourceTemplates({ cursor: result.nextCursor }); - expect(pages).toBeLessThan(50); - } - + // No-arg listResourceTemplates() auto-aggregates every page. + const result = await client.listResourceTemplates(); expect(result.nextCursor).toBeUndefined(); - expect(pages).toBe(3); + const seen = new Set(result.resourceTemplates.map(t => t.uriTemplate)); expect(seen.size).toBe(TOTAL); for (const name of all) expect(seen.has(name)).toBe(true); + + // Explicit cursor → one raw page (per-page path). + const page = await client.listResourceTemplates({ cursor: '10' }); + expect(page.resourceTemplates.length).toBe(PAGE); + expect(page.nextCursor).toBe('20'); }, { title: 'raw server' } ); diff --git a/test/e2e/scenarios/tools.test.ts b/test/e2e/scenarios/tools.test.ts index 408712f23a..cd840535e2 100644 --- a/test/e2e/scenarios/tools.test.ts +++ b/test/e2e/scenarios/tools.test.ts @@ -758,21 +758,11 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const first = await client.listTools(); - expect(first.tools.length).toBeLessThan(TOTAL); - expect(first.nextCursor).toBeDefined(); - - const seen = new Set(first.tools.map(t => t.name)); - let result = first; - let pages = 1; - while (result.nextCursor !== undefined) { - result = await client.listTools({ cursor: result.nextCursor }); - for (const t of result.tools) seen.add(t.name); - pages++; - expect(pages).toBeLessThan(50); - } - expect(seen.size).toBe(TOTAL); - expect(pages).toBeGreaterThan(1); + // No-arg listTools() auto-aggregates every page. + const all = await client.listTools(); + expect(all.tools.length).toBe(TOTAL); + expect(all.nextCursor).toBeUndefined(); + expect(new Set(all.tools.map(t => t.name)).size).toBe(TOTAL); }, { title: 'mcpserver' } ); @@ -803,30 +793,20 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const seen = new Set(); - const cursorsSent: string[] = []; - let pages = 0; - let result = await client.listTools(); - expect(result.nextCursor).toBeDefined(); - for (;;) { - for (const t of result.tools) { - expect(seen.has(t.name)).toBe(false); - seen.add(t.name); - } - pages++; - if (result.nextCursor === undefined) break; - cursorsSent.push(result.nextCursor); - result = await client.listTools({ cursor: result.nextCursor }); - expect(pages).toBeLessThan(50); - } - - expect(pages).toBeGreaterThan(1); + // No-arg listTools() auto-aggregates every page; the server receives + // the cursor walk verbatim (protocol-level pagination is what is + // verified here). + const result = await client.listTools(); + expect(result.nextCursor).toBeUndefined(); + const seen = new Set(result.tools.map(t => t.name)); + expect(seen.size).toBe(TOTAL); for (const name of all) expect(seen.has(name)).toBe(true); + expect(cursorsReceived).toEqual([undefined, '10', '20']); - // SDK plumbing: the server's request handler saw the cursors verbatim. - expect(cursorsReceived).toHaveLength(pages); - expect(cursorsReceived[0]).toBeUndefined(); - expect(cursorsReceived.slice(1)).toEqual(cursorsSent); + // Explicit cursor → one raw page (per-page path). + const page = await client.listTools({ cursor: '10' }); + expect(page.tools.length).toBe(PAGE); + expect(page.nextCursor).toBe('20'); }, { title: 'raw server' } ); diff --git a/test/e2e/scenarios/validation.test.ts b/test/e2e/scenarios/validation.test.ts index 21121240e7..c1470acd3e 100644 --- a/test/e2e/scenarios/validation.test.ts +++ b/test/e2e/scenarios/validation.test.ts @@ -149,14 +149,18 @@ verifies('validation:pluggable-provider', async ({ transport }: TestArgs) => { await client.listTools(); - // The custom provider compiled the advertised outputSchema (once per tool - // that declares one — both forecast tools share the same schema). - expect(recorder.compiledSchemas).toEqual([FORECAST_OUTPUT_SCHEMA, FORECAST_OUTPUT_SCHEMA]); + // Derived-view behavior: the validator index is compiled lazily on the + // first callTool against the cached tools/list entry's stamp, not eagerly + // at listTools time. + expect(recorder.compiledSchemas).toEqual([]); // The custom provider's validator is the one consulted on tools/call, and - // its (delegated) verdict is what the caller sees. + // its (delegated) verdict is what the caller sees. The first call + // re-derives the whole name → validator index (once per tool that + // declares an outputSchema — both forecast tools share the same schema). const result = await client.callTool({ name: 'forecast', arguments: {} }); expect(result.structuredContent).toEqual({ celsius: 21, summary: 'mild and sunny' }); + expect(recorder.compiledSchemas).toEqual([FORECAST_OUTPUT_SCHEMA, FORECAST_OUTPUT_SCHEMA]); expect(recorder.validatedValues).toEqual([{ celsius: 21, summary: 'mild and sunny' }]); await expect(client.callTool({ name: 'forecast-corrupted', arguments: {} })).rejects.toBeInstanceOf(ProtocolError);