Skip to content

Commit 4280d2d

Browse files
feat(client): minimal response-cache substrate; list* auto-paginate and write one cached entry
ResponseCacheStore / InMemoryResponseCacheStore (new file responseCache.ts) back the Client's derived views: a no-argument listTools / listPrompts / listResources / listResourceTemplates call walks every page and writes ONE aggregated entry; explicit-cursor calls still pass through. list_changed notifications evict the matching method (no refetch); _resetConnectionState clears the store. _toolDefinition(name) is the derived name->Tool view over the cached tools/list entry, memoized against the entry's stamp (mcp.d's cachedTool pattern). New ClientOptions: responseCacheStore (defaults to a fresh per-instance InMemoryResponseCacheStore — a store MUST NOT be shared across clients with different auth contexts; entries are keyed by method only) and listMaxPages (auto-pagination cap, default 64). The e2e *:list:pagination 'raw server' bodies and pagination:client: cursor-handling are rewritten to assert the aggregated result and the verbatim wire-level cursor walk; the 'mcpserver' bodies are unchanged (still knownFailure — McpServer does not paginate server-side).
1 parent d8ed081 commit 4280d2d

10 files changed

Lines changed: 684 additions & 93 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': minor
3+
---
4+
5+
Response-cache substrate. `Client` now holds a pluggable `ResponseCacheStore` (default: a fresh per-instance `InMemoryResponseCacheStore`) that the four `list*` verbs write their aggregated result to and that `*/list_changed` notifications evict. A no-argument `listTools()` / `listPrompts()` / `listResources()` / `listResourceTemplates()` call now walks every page internally and returns the aggregated list (`nextCursor: undefined`); explicit-cursor calls still return one page. The cached `tools/list` entry is the single source for the existing output-schema validators and (on a 2026-07-28 connection) SEP-2243 `Mcp-Param-*` mirroring. New exports: `ResponseCacheStore`, `CacheKey`, `CacheEntry`, `CacheScope`, `MaybePromise`, `InMemoryResponseCacheStore`; new `ClientOptions.responseCacheStore` / `ClientOptions.listMaxPages`. The store interface is async-ready (`MaybePromise<…>`); the in-memory default stays synchronous. **A store instance must not be shared across `Client` instances at all in v2.0.x** — entries are keyed by method only (server-identity confusion + `clear()`/`evict()` cross-talk); per-principal partitioning that enables safe sharing arrives with the full caching engine.

docs/migration.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,14 @@ const result = specTypeSchemas.CallToolResult['~standard'].validate(value);
538538
`isSpecType` and `specTypeSchemas` are keyed by `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. `specTypeSchemas.X` is a `StandardSchemaV1Sync<In, Out>``validate()` returns the result synchronously,
539539
so you can access `.issues` / `.value` without `await`. It composes with any Standard-Schema-aware library. The pre-existing `isCallToolResult(value)` guard still works.
540540

541+
### Client list methods auto-paginate and feed the response cache
542+
543+
A no-argument `Client.listTools()` / `listPrompts()` / `listResources()` / `listResourceTemplates()` call now walks every page internally and returns the aggregated list (`nextCursor` is `undefined`). Explicit-cursor calls (`listTools({ cursor })`) still return one page, so the
544+
documented cursor loop continues to work — it just iterates once. The aggregated result is written to a per-client response cache (`ResponseCacheStore`, default `InMemoryResponseCacheStore`); a `*/list_changed` notification evicts the matching entry, and a reconnect clears the
545+
per-instance default store (a user-supplied store is left untouched). The cached `tools/list` entry is what `callTool`'s output validation and (on a 2026-07-28 connection) SEP-2243 `Mcp-Param-*` mirroring read. Pass `ClientOptions.responseCacheStore` to supply your own store —
546+
**do not share one store across `Client` instances at all in v2.0.x** (server-identity confusion + `clear()`/`evict()` cross-talk; per-principal partitioning that enables safe sharing arrives with the full caching engine). `ClientOptions.listMaxPages` (default `64`) bounds the
547+
auto-pagination loop.
548+
541549
### Client list methods return empty results for missing capabilities
542550

543551
`Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, and `listTools()` now return empty results when the server didn't advertise the corresponding capability, instead of sending the request. This respects the MCP spec's capability negotiation.

packages/client/src/client/client.ts

Lines changed: 215 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ import {
7878
SubscriptionFilterSchema
7979
} from '@modelcontextprotocol/core';
8080

81+
import type { ResponseCacheStore } from './responseCache.js';
82+
import { InMemoryResponseCacheStore } from './responseCache.js';
8183
import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation.js';
8284
import { detectProbeEnvironment, detectProbeTransportKind, negotiateEra, resolveVersionNegotiation } from './versionNegotiation.js';
8385

@@ -255,8 +257,43 @@ export type ClientOptions = ProtocolOptions & {
255257
* ```
256258
*/
257259
listChanged?: ListChangedHandlers;
260+
261+
/**
262+
* Cap on the number of pages a no-argument `list*` call walks before
263+
* throwing (a defence against a server whose `nextCursor` never
264+
* converges). `0` disables the cap. Default: `64`.
265+
*/
266+
listMaxPages?: number;
267+
268+
/**
269+
* The response-cache store backing the client's derived views (the cached
270+
* `tools/list` result that {@linkcode Client.callTool | callTool}'s output
271+
* validation and SEP-2243 header mirroring read). Defaults to a fresh
272+
* {@linkcode InMemoryResponseCacheStore} per client.
273+
*
274+
* **Do not share one store across clients at all in v2.0.x** — entries
275+
* are keyed by method + params only, so two clients connected to
276+
* different servers (even under the same credential) collide on
277+
* `tools/list`, and one client's `list_changed` evicts every co-tenant's
278+
* entry. Supply your own only as a single-client backing store.
279+
* Per-principal partitioning that enables safe sharing is #39.
280+
*/
281+
responseCacheStore?: ResponseCacheStore;
258282
};
259283

284+
/**
285+
* `list_changed` notification → response-cache method(s) to evict. `resources`
286+
* covers both list verbs (the spec's "relevant notification ⇒ immediately
287+
* stale").
288+
*/
289+
const LIST_CHANGED_EVICTIONS: Readonly<Record<string, readonly string[]>> = {
290+
'notifications/tools/list_changed': ['tools/list'],
291+
'notifications/prompts/list_changed': ['prompts/list'],
292+
'notifications/resources/list_changed': ['resources/list', 'resources/templates/list']
293+
};
294+
295+
const DEFAULT_LIST_MAX_PAGES = 64;
296+
260297
/**
261298
* A handle to an open `subscriptions/listen` stream (protocol revision
262299
* 2026-07-28). Change notifications delivered on the stream dispatch to the
@@ -340,6 +377,33 @@ export class Client extends Protocol<ClientContext> {
340377
private _instructions?: string;
341378
private _jsonSchemaValidator: jsonSchemaValidator;
342379
private _cachedToolOutputValidators: Map<string, JsonSchemaValidator<unknown>> = new Map();
380+
/**
381+
* The response-cache substrate. The four list verbs write their aggregated
382+
* result here; `_toolDefinition` and the output-validator map are derived
383+
* views over the `tools/list` entry. `list_changed` evicts the matching
384+
* method; `_resetConnectionState` clears the lot.
385+
*/
386+
private readonly _responseCache: ResponseCacheStore;
387+
/**
388+
* `name → Tool` index derived from the cached `tools/list` entry, memoized
389+
* against the entry's `stamp` so it re-derives only when the backing entry
390+
* changes (mcp.d's `cachedTool` pattern).
391+
*/
392+
private _toolIndex?: { stamp: number; byName: Map<string, Tool> };
393+
/**
394+
* Per-method eviction-generation counter. `_onnotification`'s
395+
* `list_changed` evict bumps it; `_listAllPages` captures it before page 1
396+
* and `_cacheListResult` skips the write if it moved — so a `list_changed`
397+
* arriving mid-walk is not overwritten by the walk's stale aggregate.
398+
*/
399+
private readonly _evictionGeneration = new Map<string, number>();
400+
/**
401+
* Whether `_responseCache` is the per-instance default the constructor
402+
* allocated. A user-supplied store is never `clear()`ed by
403+
* `_resetConnectionState` (defeats the only reason to supply one).
404+
*/
405+
private readonly _responseCacheIsDefault: boolean;
406+
private readonly _listMaxPages: number;
343407
private _listChangedDebounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
344408
/**
345409
* The constructor `listChanged` configuration. Durable across reconnects:
@@ -397,6 +461,13 @@ export class Client extends Protocol<ClientContext> {
397461
}
398462
this._listChangedDebounceTimers.clear();
399463
this._cachedToolOutputValidators.clear();
464+
// A user-supplied store is NOT cleared on reconnect/close — that would
465+
// defeat the only reason to supply one. The per-instance default IS
466+
// cleared (it is connection-scoped); the default impl is synchronous,
467+
// so the MaybePromise<void> return is a plain void here.
468+
if (this._responseCacheIsDefault) void this._responseCache.clear();
469+
this._toolIndex = undefined;
470+
this._evictionGeneration.clear();
400471
}
401472

402473
override async close(): Promise<void> {
@@ -426,6 +497,12 @@ export class Client extends Protocol<ClientContext> {
426497
// Multi-round-trip auto-fulfilment driver (2026-07-28): on by default,
427498
// configurable via ClientOptions.inputRequired.
428499
this._inputRequiredDriverConfig = resolveInputRequiredDriverConfig(options?.inputRequired);
500+
// Response-cache substrate. A fresh in-memory store is allocated when
501+
// the caller does not supply one — never share a default across
502+
// instances (see ClientOptions.responseCacheStore).
503+
this._responseCacheIsDefault = options?.responseCacheStore === undefined;
504+
this._responseCache = options?.responseCacheStore ?? new InMemoryResponseCacheStore();
505+
this._listMaxPages = options?.listMaxPages ?? DEFAULT_LIST_MAX_PAGES;
429506

430507
// Store list changed config for setup after connection (when we know server capabilities)
431508
if (options?.listChanged) {
@@ -1254,7 +1331,12 @@ export class Client extends Protocol<ClientContext> {
12541331
console.debug('Client.listPrompts() called but server does not advertise prompts capability - returning empty list');
12551332
return { prompts: [] };
12561333
}
1257-
return this.request({ method: 'prompts/list', params }, options);
1334+
if (params?.cursor !== undefined) return this.request({ method: 'prompts/list', params }, options);
1335+
const { result, generation } = await this._listAllPages<ListPromptsResult>('prompts/list', params, options, (acc, page) =>
1336+
acc.prompts.push(...page.prompts)
1337+
);
1338+
await this._cacheListResult('prompts/list', result, generation);
1339+
return result;
12581340
}
12591341

12601342
/**
@@ -1285,7 +1367,12 @@ export class Client extends Protocol<ClientContext> {
12851367
console.debug('Client.listResources() called but server does not advertise resources capability - returning empty list');
12861368
return { resources: [] };
12871369
}
1288-
return this.request({ method: 'resources/list', params }, options);
1370+
if (params?.cursor !== undefined) return this.request({ method: 'resources/list', params }, options);
1371+
const { result, generation } = await this._listAllPages<ListResourcesResult>('resources/list', params, options, (acc, page) =>
1372+
acc.resources.push(...page.resources)
1373+
);
1374+
await this._cacheListResult('resources/list', result, generation);
1375+
return result;
12891376
}
12901377

12911378
/**
@@ -1305,7 +1392,96 @@ export class Client extends Protocol<ClientContext> {
13051392
);
13061393
return { resourceTemplates: [] };
13071394
}
1308-
return this.request({ method: 'resources/templates/list', params }, options);
1395+
if (params?.cursor !== undefined) return this.request({ method: 'resources/templates/list', params }, options);
1396+
const { result, generation } = await this._listAllPages<ListResourceTemplatesResult>(
1397+
'resources/templates/list',
1398+
params,
1399+
options,
1400+
(acc, page) => acc.resourceTemplates.push(...page.resourceTemplates)
1401+
);
1402+
await this._cacheListResult('resources/templates/list', result, generation);
1403+
return result;
1404+
}
1405+
1406+
/**
1407+
* Walk every page of a paginated list verb and return the aggregated
1408+
* result. Page 1's result object is mutated in place (its items array is
1409+
* extended; `nextCursor` is cleared); page-1 metadata (`ttlMs`,
1410+
* `cacheScope`, `_meta`) is preserved. A `nextCursor` that repeats stops
1411+
* the walk (defence against a non-converging server, mcp.d's
1412+
* `drainList` guard); `listMaxPages` is a hard cap — hitting it throws
1413+
* (the caller's `_cacheListResult` is never reached, so a partial
1414+
* aggregate is never cached).
1415+
*/
1416+
private async _listAllPages<R extends { nextCursor?: string }>(
1417+
method: RequestMethod,
1418+
params: Record<string, unknown> | undefined,
1419+
options: RequestOptions | undefined,
1420+
append: (acc: R, page: R) => void
1421+
): Promise<{ result: R; generation: number }> {
1422+
// Capture the eviction generation BEFORE page 1: a `list_changed` that
1423+
// lands mid-walk bumps the counter, and `_cacheListResult` skips the
1424+
// write when it observes the bump (the result still returns to the
1425+
// caller — it just is not cached).
1426+
const generation = this._evictionGeneration.get(method) ?? 0;
1427+
const acc = (await this.request({ method, params }, options)) as R;
1428+
let cursor = acc.nextCursor;
1429+
const seen = new Set<string>();
1430+
let pages = 1;
1431+
while (cursor !== undefined && !seen.has(cursor)) {
1432+
if (this._listMaxPages !== 0 && pages >= this._listMaxPages) {
1433+
throw new Error(`${method}: exceeded listMaxPages (${this._listMaxPages}); server pagination did not terminate`);
1434+
}
1435+
seen.add(cursor);
1436+
const page = (await this.request({ method, params: { ...params, cursor } }, options)) as R;
1437+
append(acc, page);
1438+
cursor = page.nextCursor;
1439+
pages++;
1440+
}
1441+
acc.nextCursor = undefined;
1442+
return { result: acc, generation };
1443+
}
1444+
1445+
/**
1446+
* Write an aggregated list result to the response-cache substrate (the
1447+
* store generates and owns the stamp). Skips the write when the
1448+
* per-method eviction generation moved while the walk was in flight — a
1449+
* `list_changed` that landed mid-walk has already invalidated the result
1450+
* the caller is about to write, and overwriting the eviction with the
1451+
* stale aggregate would lose the invalidation.
1452+
*/
1453+
private async _cacheListResult(method: string, value: unknown, generation: number): Promise<void> {
1454+
if ((this._evictionGeneration.get(method) ?? 0) !== generation) return;
1455+
await this._responseCache.set({ method }, { value });
1456+
}
1457+
1458+
/** Route a custom-store failure to `onerror` without aborting the surrounding dispatch. */
1459+
private _reportStoreError(e: unknown): void {
1460+
this.onerror?.(e instanceof Error ? e : new Error(String(e)));
1461+
}
1462+
1463+
/**
1464+
* The descriptor for tool `name` taken from the cached `tools/list` entry —
1465+
* the single source for output-schema validation and SEP-2243
1466+
* `x-mcp-header` mirroring. The `name → Tool` index is memoized against
1467+
* the entry's `stamp` and re-derived only when the backing entry changes
1468+
* (mcp.d's `cachedTool`). Returns `undefined` only when no `tools/list`
1469+
* response is held at all, or the held list does not contain `name`.
1470+
*
1471+
* @internal
1472+
*/
1473+
private async _toolDefinition(name: string): Promise<Tool | undefined> {
1474+
const entry = await this._responseCache.get({ method: 'tools/list' });
1475+
if (entry === undefined) {
1476+
this._toolIndex = undefined;
1477+
return undefined;
1478+
}
1479+
if (this._toolIndex?.stamp !== entry.stamp) {
1480+
const byName = new Map<string, Tool>();
1481+
for (const tool of (entry.value as ListToolsResult).tools) byName.set(tool.name, tool);
1482+
this._toolIndex = { stamp: entry.stamp, byName };
1483+
}
1484+
return this._toolIndex.byName.get(name);
13091485
}
13101486

13111487
/** Reads the contents of a resource by URI. */
@@ -1542,6 +1718,34 @@ export class Client extends Protocol<ClientContext> {
15421718
* being silently swallowed.
15431719
*/
15441720
protected override _onnotification(raw: JSONRPCNotification, extra?: MessageExtraInfo): void {
1721+
// Response-cache invalidation: a `list_changed` notification means the
1722+
// matching cached list result is stale. Evict (do NOT refetch) before
1723+
// dispatch so a `listChanged.onChanged` handler that calls `listTools()`
1724+
// observes the cleared entry. Runs regardless of whether the user
1725+
// configured `listChanged` — derived views (`_toolDefinition`, output
1726+
// validators) must drop the stale entry either way. `raw.method` is
1727+
// server-controlled; guard with `Object.hasOwn` so an inherited
1728+
// `Object.prototype` member name (`constructor`, `toString`, …) does
1729+
// not reach the iteration as a non-iterable function.
1730+
const evicted = Object.hasOwn(LIST_CHANGED_EVICTIONS, raw.method) ? LIST_CHANGED_EVICTIONS[raw.method] : undefined;
1731+
if (evicted !== undefined) {
1732+
for (const method of evicted) {
1733+
// Bump the generation FIRST and unconditionally: the
1734+
// `_cacheListResult` race guard relies on the bump, not on
1735+
// the store's evict completing.
1736+
this._evictionGeneration.set(method, (this._evictionGeneration.get(method) ?? 0) + 1);
1737+
// A custom store's `evict()` may throw or reject; route to
1738+
// `onerror` and proceed so dispatch (and the user's
1739+
// `listChanged` handler) runs regardless. The store interface
1740+
// is async-ready; an async evict is fire-and-forget here —
1741+
// dispatch must not block on it.
1742+
try {
1743+
void Promise.resolve(this._responseCache.evict(method)).catch(error => this._reportStoreError(error));
1744+
} catch (error) {
1745+
this._reportStoreError(error);
1746+
}
1747+
}
1748+
}
15451749
if (raw.method === 'notifications/subscriptions/acknowledged') {
15461750
const params = raw.params as { _meta?: Record<string, unknown>; notifications?: unknown } | undefined;
15471751
const subscriptionId = params?._meta?.[SUBSCRIPTION_ID_META_KEY];
@@ -1753,11 +1957,16 @@ export class Client extends Protocol<ClientContext> {
17531957
console.debug('Client.listTools() called but server does not advertise tools capability - returning empty list');
17541958
return { tools: [] };
17551959
}
1756-
const result = await this.request({ method: 'tools/list', params }, options);
1757-
1960+
// Explicit-cursor calls pass through (one page, no caching) so existing
1961+
// cursor loops keep working; the no-argument call walks every page and
1962+
// writes ONE aggregated entry — `_toolDefinition` reads that entry.
1963+
if (params?.cursor !== undefined) return this.request({ method: 'tools/list', params }, options);
1964+
const { result, generation } = await this._listAllPages<ListToolsResult>('tools/list', params, options, (acc, page) =>
1965+
acc.tools.push(...page.tools)
1966+
);
1967+
await this._cacheListResult('tools/list', result, generation);
17581968
// Cache the tools and their output schemas for future validation
17591969
this.cacheToolMetadata(result.tools);
1760-
17611970
return result;
17621971
}
17631972

0 commit comments

Comments
 (0)