From 79b260d54aa36ddcc200749db225d1ab701404ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=A4=E6=81=A9=E8=B1=AA?= <113435208+liangqianxing@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:30:47 +0000 Subject: [PATCH 01/12] fix(client): reject pre-reset generation-0 cache writes after resetForReconnect When `ClientResponseCache.resetForReconnect()` clears `_evictionGeneration`, an in-flight request from before the reset whose generation snapshot was `0` previously passed the `write()` guard (`_evictionGeneration.get(gk) ?? 0`) both when no post-reset capture had happened yet and when a post-reset request had also captured generation `0` and written. As a result, a delayed pre-reset response could populate the new server identity's partition or overwrite a fresh post-reset cache entry. Require `_evictionGeneration.get(gk) === capturedGen` (rejecting writes when no capture has occurred since construction or reset) and track keys that had a pre-reset generation `0` capture across `resetForReconnect()` so that after the post-reset generation `0` capture writes once, any delayed pre-reset generation `0` write for that key is ignored. Fixes #3075 Co-Authored-By: Claude Code --- .changeset/reject-pre-reset-cache-writes.md | 5 +++ packages/client/src/client/responseCache.ts | 37 ++++++++++++++--- .../client/test/client/responseCache.test.ts | 40 +++++++++++++++++++ 3 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 .changeset/reject-pre-reset-cache-writes.md diff --git a/.changeset/reject-pre-reset-cache-writes.md b/.changeset/reject-pre-reset-cache-writes.md new file mode 100644 index 0000000000..3070b11868 --- /dev/null +++ b/.changeset/reject-pre-reset-cache-writes.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Reject pre-reset generation-0 cache writes after `resetForReconnect()` so a response whose generation snapshot was captured on a previous connection cannot populate the new connection's partition or overwrite a fresh post-reset cache entry. diff --git a/packages/client/src/client/responseCache.ts b/packages/client/src/client/responseCache.ts index e6103d34ce..a87e40cfbe 100644 --- a/packages/client/src/client/responseCache.ts +++ b/packages/client/src/client/responseCache.ts @@ -270,11 +270,13 @@ export class ClientResponseCache { * Per-logical-key eviction-generation counter. {@linkcode evict} (whole * method) and {@linkcode evictKey} (single `{method, params}`) bump it * before touching the store; {@linkcode captureGeneration} reads it before - * the request; {@linkcode write} skips when it moved — so a `list_changed` - * arriving mid-walk, or a `resources/updated` arriving while a - * `readResource()` for the same URI is in flight, is not overwritten by - * the in-flight request's stale write. The map key is `method` for the - * list singletons and `` `${method}\0${params}` `` for per-URI keys. + * the request; {@linkcode write} skips when it moved or when the key is + * absent (e.g. cleared by {@linkcode resetForReconnect}) — so a + * `list_changed` arriving mid-walk, a `resources/updated` arriving while a + * `readResource()` for the same URI is in flight, or a pre-reset response + * reaching `write` after a reconnect is not written. The map key is + * `method` for the list singletons and `` `${method}\0${params}` `` for + * per-URI keys. * * Growth is bounded by keys the CLIENT has issued a `captureGeneration` * for: {@linkcode captureGeneration} records the key (so an interleaved @@ -284,6 +286,15 @@ export class ClientResponseCache { * has never read therefore cannot grow this map. */ private readonly _evictionGeneration = new Map(); + /** + * Keys whose generation `0` snapshot was captured on a previous connection + * (populated by {@linkcode resetForReconnect}) and has already been + * redeemed by a post-reset generation-`0` {@linkcode write}. Once the + * post-reset generation-`0` write completes, any second generation-`0` + * write for that key (the delayed pre-reset request) is rejected. + */ + private readonly _preResetZeroKeys = new Set(); + private readonly _postResetZeroWrittenKeys = new Set(); /** * `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 @@ -415,6 +426,8 @@ export class ClientResponseCache { * dispatch proceeds. */ async evict(method: string): Promise { + this._preResetZeroKeys.delete(method); + this._postResetZeroWrittenKeys.delete(method); this._evictionGeneration.set(method, (this._evictionGeneration.get(method) ?? 0) + 1); await this._deleteBoth(method, ''); } @@ -464,6 +477,8 @@ export class ClientResponseCache { // be held even when the generation entry has since been cleared by // `resetForReconnect`). const current = this._evictionGeneration.get(gk); + this._preResetZeroKeys.delete(gk); + this._postResetZeroWrittenKeys.delete(gk); if (current !== undefined) this._evictionGeneration.set(gk, current + 1); await this._deleteBoth(method, params); } @@ -480,6 +495,7 @@ export class ClientResponseCache { const gk = genKey(method, params); const current = this._evictionGeneration.get(gk) ?? 0; this._evictionGeneration.set(gk, current); + this._postResetZeroWrittenKeys.delete(gk); return current; } @@ -525,7 +541,12 @@ export class ClientResponseCache { capturedGen: number, freshness?: { expiresAt: number; scope: CacheScope; params?: string } ): Promise { - if ((this._evictionGeneration.get(genKey(method, freshness?.params)) ?? 0) !== capturedGen) return; + const gk = genKey(method, freshness?.params); + if (this._evictionGeneration.get(gk) !== capturedGen) return; + if (capturedGen === 0 && this._preResetZeroKeys.has(gk)) { + if (this._postResetZeroWrittenKeys.has(gk)) return; + this._postResetZeroWrittenKeys.add(gk); + } const params = freshness?.params ?? ''; const ownPartition = this._partitionFor('private'); const sharedPartition = this._partitionFor('public'); @@ -592,6 +613,10 @@ export class ClientResponseCache { */ resetForReconnect(): void { if (!this._isUserSupplied) void this._store.clear(); + for (const [gk, gen] of this._evictionGeneration) { + if (gen === 0) this._preResetZeroKeys.add(gk); + } + this._postResetZeroWrittenKeys.clear(); this._evictionGeneration.clear(); this._toolIndex = undefined; this._toolOutputValidatorIndex = undefined; diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index e2e2326705..8e319a6dd9 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -1336,4 +1336,44 @@ describe('Client honours cacheHints (SEP-2549)', () => { expect(listCount()).toBe(1); expect(errors.map(e => e.message)).toContain('redis down'); }); + + it.each( + (['private', 'public'] as const).flatMap(scope => + [false, true].flatMap(changeServer => + [false, true].map(freshWriteBeforeStale => ({ scope, changeServer, freshWriteBeforeStale })) + ) + ) + )( + 'rejects pre-reset generation-0 writes after resetForReconnect (scope=$scope, changeServer=$changeServer, freshWriteBeforeStale=$freshWriteBeforeStale)', + async ({ scope, changeServer, freshWriteBeforeStale }) => { + const store = new InMemoryResponseCacheStore(); + const cache = new ClientResponseCache(store, true, undefined, 'alice'); + cache.setServerIdentity('server-a'); + const oldGeneration = cache.captureGeneration('tools/list'); + + cache.resetForReconnect(); + const targetServer = changeServer ? 'server-b' : 'server-a'; + cache.setServerIdentity(targetServer); + const freshness = { expiresAt: Date.now() + 60_000, scope }; + + if (freshWriteBeforeStale) { + const newGeneration = cache.captureGeneration('tools/list'); + await cache.write('tools/list', { tools: [TOOL_B] }, newGeneration, freshness); + } + + // A stale response whose generation was captured before resetForReconnect() + // must not overwrite a fresh response or populate the new connection's partition. + await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); + + const hit = await cache.read('tools/list'); + if (freshWriteBeforeStale) { + expect(hit).toEqual({ value: { tools: [TOOL_B] } }); + expect((await cache.toolDefinition('b'))?.name).toBe('b'); + expect(await cache.toolDefinition('a')).toBeUndefined(); + } else { + expect(hit).toBeUndefined(); + expect(await cache.toolDefinition('a')).toBeUndefined(); + } + } + ); }); From 96daba18e6a0bf726a7bc4aeaae63a7b9e77aef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=A4=E6=81=A9=E8=B1=AA?= <113435208+liangqianxing@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:13:57 +0000 Subject: [PATCH 02/12] fix(client): use monotonic generation baseline across resetForReconnect Replace the per-key generation-0 tracking sets with a monotonic `_generationBase` that starts at `0` and advances past the highest recorded generation when `resetForReconnect()` clears a non-empty `_evictionGeneration` map. `captureGeneration()` and `evict()` now fall back to `this._generationBase` instead of `0`, so post-reset generation snapshots are strictly greater than any pre-reset snapshot (including keys evicted before reset) while `_evictionGeneration` remains cleared on reconnect and concurrent post-reset captures can write normally. Co-Authored-By: Claude Code --- .changeset/reject-pre-reset-cache-writes.md | 2 +- packages/client/src/client/responseCache.ts | 40 ++++++------- .../client/test/client/responseCache.test.ts | 56 +++++++++++++++---- 3 files changed, 63 insertions(+), 35 deletions(-) diff --git a/.changeset/reject-pre-reset-cache-writes.md b/.changeset/reject-pre-reset-cache-writes.md index 3070b11868..ef446febca 100644 --- a/.changeset/reject-pre-reset-cache-writes.md +++ b/.changeset/reject-pre-reset-cache-writes.md @@ -2,4 +2,4 @@ '@modelcontextprotocol/client': patch --- -Reject pre-reset generation-0 cache writes after `resetForReconnect()` so a response whose generation snapshot was captured on a previous connection cannot populate the new connection's partition or overwrite a fresh post-reset cache entry. +Reject pre-reset cache writes after `resetForReconnect()` by advancing a monotonic generation baseline on reset so a response whose generation snapshot was captured on a previous connection cannot populate the new connection's partition or overwrite a fresh post-reset cache entry. diff --git a/packages/client/src/client/responseCache.ts b/packages/client/src/client/responseCache.ts index a87e40cfbe..7b6bc0471a 100644 --- a/packages/client/src/client/responseCache.ts +++ b/packages/client/src/client/responseCache.ts @@ -287,14 +287,13 @@ export class ClientResponseCache { */ private readonly _evictionGeneration = new Map(); /** - * Keys whose generation `0` snapshot was captured on a previous connection - * (populated by {@linkcode resetForReconnect}) and has already been - * redeemed by a post-reset generation-`0` {@linkcode write}. Once the - * post-reset generation-`0` write completes, any second generation-`0` - * write for that key (the delayed pre-reset request) is rejected. + * Baseline generation for keys not yet recorded in + * {@linkcode _evictionGeneration} on the current connection. Starts at `0` + * and advances past the highest generation issued so far on every + * {@linkcode resetForReconnect}, so a pre-reset generation snapshot can + * never match a post-reset state even after the map is cleared. */ - private readonly _preResetZeroKeys = new Set(); - private readonly _postResetZeroWrittenKeys = new Set(); + private _generationBase = 0; /** * `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 @@ -426,9 +425,7 @@ export class ClientResponseCache { * dispatch proceeds. */ async evict(method: string): Promise { - this._preResetZeroKeys.delete(method); - this._postResetZeroWrittenKeys.delete(method); - this._evictionGeneration.set(method, (this._evictionGeneration.get(method) ?? 0) + 1); + this._evictionGeneration.set(method, (this._evictionGeneration.get(method) ?? this._generationBase) + 1); await this._deleteBoth(method, ''); } @@ -477,8 +474,6 @@ export class ClientResponseCache { // be held even when the generation entry has since been cleared by // `resetForReconnect`). const current = this._evictionGeneration.get(gk); - this._preResetZeroKeys.delete(gk); - this._postResetZeroWrittenKeys.delete(gk); if (current !== undefined) this._evictionGeneration.set(gk, current + 1); await this._deleteBoth(method, params); } @@ -493,9 +488,8 @@ export class ClientResponseCache { */ captureGeneration(method: string, params?: string): number { const gk = genKey(method, params); - const current = this._evictionGeneration.get(gk) ?? 0; + const current = this._evictionGeneration.get(gk) ?? this._generationBase; this._evictionGeneration.set(gk, current); - this._postResetZeroWrittenKeys.delete(gk); return current; } @@ -541,12 +535,7 @@ export class ClientResponseCache { capturedGen: number, freshness?: { expiresAt: number; scope: CacheScope; params?: string } ): Promise { - const gk = genKey(method, freshness?.params); - if (this._evictionGeneration.get(gk) !== capturedGen) return; - if (capturedGen === 0 && this._preResetZeroKeys.has(gk)) { - if (this._postResetZeroWrittenKeys.has(gk)) return; - this._postResetZeroWrittenKeys.add(gk); - } + if (this._evictionGeneration.get(genKey(method, freshness?.params)) !== capturedGen) return; const params = freshness?.params ?? ''; const ownPartition = this._partitionFor('private'); const sharedPartition = this._partitionFor('public'); @@ -613,11 +602,14 @@ export class ClientResponseCache { */ resetForReconnect(): void { if (!this._isUserSupplied) void this._store.clear(); - for (const [gk, gen] of this._evictionGeneration) { - if (gen === 0) this._preResetZeroKeys.add(gk); + if (this._evictionGeneration.size > 0) { + let maxGeneration = this._generationBase; + for (const gen of this._evictionGeneration.values()) { + if (gen > maxGeneration) maxGeneration = gen; + } + this._generationBase = maxGeneration + 1; + this._evictionGeneration.clear(); } - this._postResetZeroWrittenKeys.clear(); - this._evictionGeneration.clear(); this._toolIndex = undefined; this._toolOutputValidatorIndex = undefined; this._serverIdentity = ''; diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index 8e319a6dd9..b29a27e145 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -173,7 +173,8 @@ describe('ClientResponseCache', () => { expect(userCache.captureGeneration('prompts/list')).toBe(1); userCache.resetForReconnect(); expect(userStore.get({ method: 'tools/list', partition: PRE })).toBeDefined(); - expect(userCache.captureGeneration('prompts/list')).toBe(0); + expect((userCache as unknown as { _evictionGeneration: Map })._evictionGeneration.size).toBe(0); + expect(userCache.captureGeneration('prompts/list')).toBe(2); // 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'); @@ -1340,15 +1341,31 @@ describe('Client honours cacheHints (SEP-2549)', () => { it.each( (['private', 'public'] as const).flatMap(scope => [false, true].flatMap(changeServer => - [false, true].map(freshWriteBeforeStale => ({ scope, changeServer, freshWriteBeforeStale })) + ( + [ + 'none', + 'capture-write-stale', + 'capture-stale-write', + 'write-recapture-stale', + 'concurrent-post-reset', + 'evicted-pre-reset-and-post-reset' + ] as const + ).map(ordering => ({ + scope, + changeServer, + ordering + })) ) ) )( - 'rejects pre-reset generation-0 writes after resetForReconnect (scope=$scope, changeServer=$changeServer, freshWriteBeforeStale=$freshWriteBeforeStale)', - async ({ scope, changeServer, freshWriteBeforeStale }) => { + 'rejects pre-reset writes after resetForReconnect (scope=$scope, changeServer=$changeServer, ordering=$ordering)', + async ({ scope, changeServer, ordering }) => { const store = new InMemoryResponseCacheStore(); const cache = new ClientResponseCache(store, true, undefined, 'alice'); cache.setServerIdentity('server-a'); + if (ordering === 'evicted-pre-reset-and-post-reset') { + await cache.evict('tools/list'); + } const oldGeneration = cache.captureGeneration('tools/list'); cache.resetForReconnect(); @@ -1356,17 +1373,36 @@ describe('Client honours cacheHints (SEP-2549)', () => { cache.setServerIdentity(targetServer); const freshness = { expiresAt: Date.now() + 60_000, scope }; - if (freshWriteBeforeStale) { + if (ordering === 'none') { + await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); + } else if (ordering === 'capture-write-stale') { + const newGeneration = cache.captureGeneration('tools/list'); + await cache.write('tools/list', { tools: [TOOL_B] }, newGeneration, freshness); + await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); + } else if (ordering === 'capture-stale-write') { const newGeneration = cache.captureGeneration('tools/list'); + await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); await cache.write('tools/list', { tools: [TOOL_B] }, newGeneration, freshness); + } else if (ordering === 'write-recapture-stale') { + const firstPostResetGen = cache.captureGeneration('tools/list'); + await cache.write('tools/list', { tools: [TOOL_B] }, firstPostResetGen, freshness); + cache.captureGeneration('tools/list'); + await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); + } else if (ordering === 'concurrent-post-reset') { + const gen1 = cache.captureGeneration('tools/list'); + const gen2 = cache.captureGeneration('tools/list'); + await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); + await cache.write('tools/list', { tools: [TOOL_A] }, gen1, freshness); + await cache.write('tools/list', { tools: [TOOL_B] }, gen2, freshness); + } else { + await cache.evict('tools/list'); + const newGeneration = cache.captureGeneration('tools/list'); + await cache.write('tools/list', { tools: [TOOL_B] }, newGeneration, freshness); + await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); } - // A stale response whose generation was captured before resetForReconnect() - // must not overwrite a fresh response or populate the new connection's partition. - await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); - const hit = await cache.read('tools/list'); - if (freshWriteBeforeStale) { + if (ordering !== 'none') { expect(hit).toEqual({ value: { tools: [TOOL_B] } }); expect((await cache.toolDefinition('b'))?.name).toBe('b'); expect(await cache.toolDefinition('a')).toBeUndefined(); From 0892bdfb3b28a35dd3008542dfc8a8160b56986c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=A4=E6=81=A9=E8=B1=AA?= <113435208+liangqianxing@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:04:56 +0000 Subject: [PATCH 03/12] test(client): cover consecutive resetForReconnect calls before stale write Co-Authored-By: Claude Code --- packages/client/test/client/responseCache.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index b29a27e145..bfea5b270f 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -1344,6 +1344,7 @@ describe('Client honours cacheHints (SEP-2549)', () => { ( [ 'none', + 'double-reset', 'capture-write-stale', 'capture-stale-write', 'write-recapture-stale', @@ -1375,6 +1376,12 @@ describe('Client honours cacheHints (SEP-2549)', () => { if (ordering === 'none') { await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); + } else if (ordering === 'double-reset') { + cache.resetForReconnect(); + cache.setServerIdentity(targetServer); + const newGeneration = cache.captureGeneration('tools/list'); + await cache.write('tools/list', { tools: [TOOL_B] }, newGeneration, freshness); + await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); } else if (ordering === 'capture-write-stale') { const newGeneration = cache.captureGeneration('tools/list'); await cache.write('tools/list', { tools: [TOOL_B] }, newGeneration, freshness); From e9c6f1295e912ae0c275ad5a088f4853f619d20b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=A4=E6=81=A9=E8=B1=AA?= <113435208+liangqianxing@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:45:49 +0000 Subject: [PATCH 04/12] test(client): cover reset-capture-reset-stale ordering in responseCache Co-Authored-By: Claude Code --- packages/client/test/client/responseCache.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index bfea5b270f..aec43f2f80 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -1345,6 +1345,7 @@ describe('Client honours cacheHints (SEP-2549)', () => { [ 'none', 'double-reset', + 'reset-capture-reset-stale', 'capture-write-stale', 'capture-stale-write', 'write-recapture-stale', @@ -1382,6 +1383,16 @@ describe('Client honours cacheHints (SEP-2549)', () => { const newGeneration = cache.captureGeneration('tools/list'); await cache.write('tools/list', { tools: [TOOL_B] }, newGeneration, freshness); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); + } else if (ordering === 'reset-capture-reset-stale') { + const midGeneration = cache.captureGeneration('tools/list'); + cache.resetForReconnect(); + cache.setServerIdentity(targetServer); + await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); + await cache.write('tools/list', { tools: [TOOL_A] }, midGeneration, freshness); + const newGeneration = cache.captureGeneration('tools/list'); + await cache.write('tools/list', { tools: [TOOL_B] }, newGeneration, freshness); + await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); + await cache.write('tools/list', { tools: [TOOL_A] }, midGeneration, freshness); } else if (ordering === 'capture-write-stale') { const newGeneration = cache.captureGeneration('tools/list'); await cache.write('tools/list', { tools: [TOOL_B] }, newGeneration, freshness); From 0efcbb326e2ade42577f7cac8efa81d3104a2472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=A4=E6=81=A9=E8=B1=AA?= <113435208+liangqianxing@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:21:27 +0000 Subject: [PATCH 05/12] test(client): cover post-reset capture-evict-write ordering in responseCache Co-Authored-By: Claude Code --- packages/client/test/client/responseCache.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index aec43f2f80..2de8816652 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -1350,7 +1350,8 @@ describe('Client honours cacheHints (SEP-2549)', () => { 'capture-stale-write', 'write-recapture-stale', 'concurrent-post-reset', - 'evicted-pre-reset-and-post-reset' + 'evicted-pre-reset-and-post-reset', + 'post-reset-capture-evict-write' ] as const ).map(ordering => ({ scope, @@ -1412,11 +1413,20 @@ describe('Client honours cacheHints (SEP-2549)', () => { await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); await cache.write('tools/list', { tools: [TOOL_A] }, gen1, freshness); await cache.write('tools/list', { tools: [TOOL_B] }, gen2, freshness); - } else { + } else if (ordering === 'evicted-pre-reset-and-post-reset') { await cache.evict('tools/list'); const newGeneration = cache.captureGeneration('tools/list'); await cache.write('tools/list', { tools: [TOOL_B] }, newGeneration, freshness); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); + } else { + const postResetBeforeEvict = cache.captureGeneration('tools/list'); + await cache.evict('tools/list'); + await cache.write('tools/list', { tools: [TOOL_A] }, postResetBeforeEvict, freshness); + await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); + const postEvictGeneration = cache.captureGeneration('tools/list'); + await cache.write('tools/list', { tools: [TOOL_B] }, postEvictGeneration, freshness); + await cache.write('tools/list', { tools: [TOOL_A] }, postResetBeforeEvict, freshness); + await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); } const hit = await cache.read('tools/list'); From 6696ea114f64bdc169e779c9403e56f3131317d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=A4=E6=81=A9=E8=B1=AA?= <113435208+liangqianxing@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:41:25 +0000 Subject: [PATCH 06/12] test(client): assert intermediate cache miss after early stale writes Co-Authored-By: Claude Code --- packages/client/test/client/responseCache.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index 2de8816652..34b2fd8bf5 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -1376,11 +1376,18 @@ describe('Client honours cacheHints (SEP-2549)', () => { cache.setServerIdentity(targetServer); const freshness = { expiresAt: Date.now() + 60_000, scope }; + const expectCacheMiss = async (): Promise => { + expect(await cache.read('tools/list')).toBeUndefined(); + expect(await cache.toolDefinition('a')).toBeUndefined(); + }; + if (ordering === 'none') { await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); } else if (ordering === 'double-reset') { cache.resetForReconnect(); cache.setServerIdentity(targetServer); + await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); + await expectCacheMiss(); const newGeneration = cache.captureGeneration('tools/list'); await cache.write('tools/list', { tools: [TOOL_B] }, newGeneration, freshness); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); @@ -1390,6 +1397,7 @@ describe('Client honours cacheHints (SEP-2549)', () => { cache.setServerIdentity(targetServer); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); await cache.write('tools/list', { tools: [TOOL_A] }, midGeneration, freshness); + await expectCacheMiss(); const newGeneration = cache.captureGeneration('tools/list'); await cache.write('tools/list', { tools: [TOOL_B] }, newGeneration, freshness); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); @@ -1401,6 +1409,7 @@ describe('Client honours cacheHints (SEP-2549)', () => { } else if (ordering === 'capture-stale-write') { const newGeneration = cache.captureGeneration('tools/list'); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); + await expectCacheMiss(); await cache.write('tools/list', { tools: [TOOL_B] }, newGeneration, freshness); } else if (ordering === 'write-recapture-stale') { const firstPostResetGen = cache.captureGeneration('tools/list'); @@ -1411,10 +1420,14 @@ describe('Client honours cacheHints (SEP-2549)', () => { const gen1 = cache.captureGeneration('tools/list'); const gen2 = cache.captureGeneration('tools/list'); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); + await expectCacheMiss(); await cache.write('tools/list', { tools: [TOOL_A] }, gen1, freshness); + expect(await cache.read('tools/list')).toEqual({ value: { tools: [TOOL_A] } }); await cache.write('tools/list', { tools: [TOOL_B] }, gen2, freshness); } else if (ordering === 'evicted-pre-reset-and-post-reset') { await cache.evict('tools/list'); + await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); + await expectCacheMiss(); const newGeneration = cache.captureGeneration('tools/list'); await cache.write('tools/list', { tools: [TOOL_B] }, newGeneration, freshness); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); @@ -1423,6 +1436,7 @@ describe('Client honours cacheHints (SEP-2549)', () => { await cache.evict('tools/list'); await cache.write('tools/list', { tools: [TOOL_A] }, postResetBeforeEvict, freshness); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); + await expectCacheMiss(); const postEvictGeneration = cache.captureGeneration('tools/list'); await cache.write('tools/list', { tools: [TOOL_B] }, postEvictGeneration, freshness); await cache.write('tools/list', { tools: [TOOL_A] }, postResetBeforeEvict, freshness); From 743faf0776b680fc66e8bb3ccfba05bd0404667c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=A4=E6=81=A9=E8=B1=AA?= <113435208+liangqianxing@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:07:40 +0000 Subject: [PATCH 07/12] test(client): cover stale write between concurrent post-reset writes Co-Authored-By: Claude Code --- packages/client/test/client/responseCache.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index 34b2fd8bf5..f06829752f 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -1417,13 +1417,18 @@ describe('Client honours cacheHints (SEP-2549)', () => { cache.captureGeneration('tools/list'); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); } else if (ordering === 'concurrent-post-reset') { + const toolC: Tool = { name: 'c', inputSchema: { type: 'object', properties: {} } }; const gen1 = cache.captureGeneration('tools/list'); const gen2 = cache.captureGeneration('tools/list'); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); await expectCacheMiss(); await cache.write('tools/list', { tools: [TOOL_A] }, gen1, freshness); expect(await cache.read('tools/list')).toEqual({ value: { tools: [TOOL_A] } }); + await cache.write('tools/list', { tools: [toolC] }, oldGeneration, freshness); + expect(await cache.read('tools/list')).toEqual({ value: { tools: [TOOL_A] } }); + expect(await cache.toolDefinition('c')).toBeUndefined(); await cache.write('tools/list', { tools: [TOOL_B] }, gen2, freshness); + await cache.write('tools/list', { tools: [toolC] }, oldGeneration, freshness); } else if (ordering === 'evicted-pre-reset-and-post-reset') { await cache.evict('tools/list'); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); From 42f64e6480831f01abde8f24075ea3cfb615976a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=A4=E6=81=A9=E8=B1=AA?= <113435208+liangqianxing@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:38:19 +0000 Subject: [PATCH 08/12] test(client): cover concurrent post-reset captures invalidated by evict Co-Authored-By: Claude Code --- packages/client/test/client/responseCache.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index f06829752f..7ba82840cf 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -1437,14 +1437,17 @@ describe('Client honours cacheHints (SEP-2549)', () => { await cache.write('tools/list', { tools: [TOOL_B] }, newGeneration, freshness); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); } else { - const postResetBeforeEvict = cache.captureGeneration('tools/list'); + const postResetBeforeEvict1 = cache.captureGeneration('tools/list'); + const postResetBeforeEvict2 = cache.captureGeneration('tools/list'); await cache.evict('tools/list'); - await cache.write('tools/list', { tools: [TOOL_A] }, postResetBeforeEvict, freshness); + await cache.write('tools/list', { tools: [TOOL_A] }, postResetBeforeEvict1, freshness); + await cache.write('tools/list', { tools: [TOOL_A] }, postResetBeforeEvict2, freshness); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); await expectCacheMiss(); const postEvictGeneration = cache.captureGeneration('tools/list'); await cache.write('tools/list', { tools: [TOOL_B] }, postEvictGeneration, freshness); - await cache.write('tools/list', { tools: [TOOL_A] }, postResetBeforeEvict, freshness); + await cache.write('tools/list', { tools: [TOOL_A] }, postResetBeforeEvict1, freshness); + await cache.write('tools/list', { tools: [TOOL_A] }, postResetBeforeEvict2, freshness); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); } From 98c1e4a18df2dec30d91f2cdd3d6eccdd0175a22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=A4=E6=81=A9=E8=B1=AA?= <113435208+liangqianxing@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:59:43 +0000 Subject: [PATCH 09/12] test(client): use distinct stale payloads and cover per-URI post-reset writes Co-Authored-By: Claude Code --- .../client/test/client/responseCache.test.ts | 58 +++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index 7ba82840cf..d824559d1c 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -1437,18 +1437,24 @@ describe('Client honours cacheHints (SEP-2549)', () => { await cache.write('tools/list', { tools: [TOOL_B] }, newGeneration, freshness); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); } else { + const toolStale1: Tool = { name: 'stale1', inputSchema: { type: 'object', properties: {} } }; + const toolStale2: Tool = { name: 'stale2', inputSchema: { type: 'object', properties: {} } }; const postResetBeforeEvict1 = cache.captureGeneration('tools/list'); const postResetBeforeEvict2 = cache.captureGeneration('tools/list'); await cache.evict('tools/list'); - await cache.write('tools/list', { tools: [TOOL_A] }, postResetBeforeEvict1, freshness); - await cache.write('tools/list', { tools: [TOOL_A] }, postResetBeforeEvict2, freshness); + await cache.write('tools/list', { tools: [toolStale1] }, postResetBeforeEvict1, freshness); + await cache.write('tools/list', { tools: [toolStale2] }, postResetBeforeEvict2, freshness); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); await expectCacheMiss(); + expect(await cache.toolDefinition('stale1')).toBeUndefined(); + expect(await cache.toolDefinition('stale2')).toBeUndefined(); const postEvictGeneration = cache.captureGeneration('tools/list'); await cache.write('tools/list', { tools: [TOOL_B] }, postEvictGeneration, freshness); - await cache.write('tools/list', { tools: [TOOL_A] }, postResetBeforeEvict1, freshness); - await cache.write('tools/list', { tools: [TOOL_A] }, postResetBeforeEvict2, freshness); + await cache.write('tools/list', { tools: [toolStale1] }, postResetBeforeEvict1, freshness); + await cache.write('tools/list', { tools: [toolStale2] }, postResetBeforeEvict2, freshness); await cache.write('tools/list', { tools: [TOOL_A] }, oldGeneration, freshness); + expect(await cache.toolDefinition('stale1')).toBeUndefined(); + expect(await cache.toolDefinition('stale2')).toBeUndefined(); } const hit = await cache.read('tools/list'); @@ -1462,4 +1468,48 @@ describe('Client honours cacheHints (SEP-2549)', () => { } } ); + + it.each((['private', 'public'] as const).flatMap(scope => [false, true].map(changeServer => ({ scope, changeServer }))))( + 'rejects pre-reset and pre-evictKey resources/read writes after resetForReconnect (scope=$scope, changeServer=$changeServer)', + async ({ scope, changeServer }) => { + const store = new InMemoryResponseCacheStore(); + const cache = new ClientResponseCache(store, true, undefined, 'alice'); + cache.setServerIdentity('server-a'); + const oldGen = cache.captureGeneration('resources/read', 'res://a'); + + cache.resetForReconnect(); + const targetServer = changeServer ? 'server-b' : 'server-a'; + cache.setServerIdentity(targetServer); + const freshnessA = { expiresAt: Date.now() + 60_000, scope, params: 'res://a' }; + const freshnessB = { expiresAt: Date.now() + 60_000, scope, params: 'res://b' }; + + await cache.write('resources/read', { contents: [{ text: 'old-a' }] }, oldGen, freshnessA); + expect(await cache.read('resources/read', 'res://a')).toBeUndefined(); + + const postResetBeforeEvict1 = cache.captureGeneration('resources/read', 'res://a'); + const postResetBeforeEvict2 = cache.captureGeneration('resources/read', 'res://a'); + const siblingGenB = cache.captureGeneration('resources/read', 'res://b'); + await cache.evictKey('resources/read', 'res://a'); + + await cache.write('resources/read', { contents: [{ text: 'stale-a1' }] }, postResetBeforeEvict1, freshnessA); + await cache.write('resources/read', { contents: [{ text: 'stale-a2' }] }, postResetBeforeEvict2, freshnessA); + await cache.write('resources/read', { contents: [{ text: 'old-a' }] }, oldGen, freshnessA); + expect(await cache.read('resources/read', 'res://a')).toBeUndefined(); + + await cache.write('resources/read', { contents: [{ text: 'fresh-b' }] }, siblingGenB, freshnessB); + expect(await cache.read('resources/read', 'res://b')).toEqual({ + value: { contents: [{ text: 'fresh-b' }] } + }); + + const postEvictGen = cache.captureGeneration('resources/read', 'res://a'); + await cache.write('resources/read', { contents: [{ text: 'fresh-a' }] }, postEvictGen, freshnessA); + await cache.write('resources/read', { contents: [{ text: 'stale-a1' }] }, postResetBeforeEvict1, freshnessA); + await cache.write('resources/read', { contents: [{ text: 'stale-a2' }] }, postResetBeforeEvict2, freshnessA); + await cache.write('resources/read', { contents: [{ text: 'old-a' }] }, oldGen, freshnessA); + + expect(await cache.read('resources/read', 'res://a')).toEqual({ + value: { contents: [{ text: 'fresh-a' }] } + }); + } + ); }); From 8eb40401221dbce94bc05095864b326157e4c1ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=A4=E6=81=A9=E8=B1=AA?= <113435208+liangqianxing@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:09:32 +0000 Subject: [PATCH 10/12] test(client): cover pre-reset sibling URI token rejection in resources/read Co-Authored-By: Claude Code --- packages/client/test/client/responseCache.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index d824559d1c..f14dc9ff41 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -1476,6 +1476,7 @@ describe('Client honours cacheHints (SEP-2549)', () => { const cache = new ClientResponseCache(store, true, undefined, 'alice'); cache.setServerIdentity('server-a'); const oldGen = cache.captureGeneration('resources/read', 'res://a'); + const oldGenB = cache.captureGeneration('resources/read', 'res://b'); cache.resetForReconnect(); const targetServer = changeServer ? 'server-b' : 'server-a'; @@ -1484,7 +1485,9 @@ describe('Client honours cacheHints (SEP-2549)', () => { const freshnessB = { expiresAt: Date.now() + 60_000, scope, params: 'res://b' }; await cache.write('resources/read', { contents: [{ text: 'old-a' }] }, oldGen, freshnessA); + await cache.write('resources/read', { contents: [{ text: 'old-b' }] }, oldGenB, freshnessB); expect(await cache.read('resources/read', 'res://a')).toBeUndefined(); + expect(await cache.read('resources/read', 'res://b')).toBeUndefined(); const postResetBeforeEvict1 = cache.captureGeneration('resources/read', 'res://a'); const postResetBeforeEvict2 = cache.captureGeneration('resources/read', 'res://a'); @@ -1494,9 +1497,12 @@ describe('Client honours cacheHints (SEP-2549)', () => { await cache.write('resources/read', { contents: [{ text: 'stale-a1' }] }, postResetBeforeEvict1, freshnessA); await cache.write('resources/read', { contents: [{ text: 'stale-a2' }] }, postResetBeforeEvict2, freshnessA); await cache.write('resources/read', { contents: [{ text: 'old-a' }] }, oldGen, freshnessA); + await cache.write('resources/read', { contents: [{ text: 'old-b' }] }, oldGenB, freshnessB); expect(await cache.read('resources/read', 'res://a')).toBeUndefined(); + expect(await cache.read('resources/read', 'res://b')).toBeUndefined(); await cache.write('resources/read', { contents: [{ text: 'fresh-b' }] }, siblingGenB, freshnessB); + await cache.write('resources/read', { contents: [{ text: 'old-b' }] }, oldGenB, freshnessB); expect(await cache.read('resources/read', 'res://b')).toEqual({ value: { contents: [{ text: 'fresh-b' }] } }); From a49eee65e1c9bac9572508ae1822ed277f470f29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=A4=E6=81=A9=E8=B1=AA?= <113435208+liangqianxing@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:23:40 +0000 Subject: [PATCH 11/12] test(client): assert post-reset generation baseline for unseen keys Co-Authored-By: Claude Code --- packages/client/test/client/responseCache.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index f14dc9ff41..99fad8f52a 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -175,6 +175,7 @@ describe('ClientResponseCache', () => { expect(userStore.get({ method: 'tools/list', partition: PRE })).toBeDefined(); expect((userCache as unknown as { _evictionGeneration: Map })._evictionGeneration.size).toBe(0); expect(userCache.captureGeneration('prompts/list')).toBe(2); + expect(userCache.captureGeneration('resources/list')).toBe(2); // 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'); @@ -1483,12 +1484,20 @@ describe('Client honours cacheHints (SEP-2549)', () => { cache.setServerIdentity(targetServer); const freshnessA = { expiresAt: Date.now() + 60_000, scope, params: 'res://a' }; const freshnessB = { expiresAt: Date.now() + 60_000, scope, params: 'res://b' }; + const freshnessC = { expiresAt: Date.now() + 60_000, scope, params: 'res://c' }; await cache.write('resources/read', { contents: [{ text: 'old-a' }] }, oldGen, freshnessA); await cache.write('resources/read', { contents: [{ text: 'old-b' }] }, oldGenB, freshnessB); expect(await cache.read('resources/read', 'res://a')).toBeUndefined(); expect(await cache.read('resources/read', 'res://b')).toBeUndefined(); + const unseenGenC = cache.captureGeneration('resources/read', 'res://c'); + expect(unseenGenC).toBe(1); + await cache.write('resources/read', { contents: [{ text: 'fresh-c' }] }, unseenGenC, freshnessC); + expect(await cache.read('resources/read', 'res://c')).toEqual({ + value: { contents: [{ text: 'fresh-c' }] } + }); + const postResetBeforeEvict1 = cache.captureGeneration('resources/read', 'res://a'); const postResetBeforeEvict2 = cache.captureGeneration('resources/read', 'res://a'); const siblingGenB = cache.captureGeneration('resources/read', 'res://b'); From ced22dfee6112acb63c23e7775f29a00dcf33b5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=A4=E6=81=A9=E8=B1=AA?= <113435208+liangqianxing@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:56:30 +0800 Subject: [PATCH 12/12] test(client): verify write() with literal 0 for an uncaptured key is a no-op --- packages/client/test/client/responseCache.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index 99fad8f52a..bac18fae69 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -149,9 +149,12 @@ describe('InMemoryResponseCacheStore', () => { }); describe('ClientResponseCache', () => { - it('write skips when the captured generation moved (list_changed-during-walk guard)', async () => { + it('write skips when the captured generation moved or the key was never captured (list_changed-during-walk guard)', async () => { const store = new InMemoryResponseCacheStore(); const cache = new ClientResponseCache(store, false); + // Uncaptured key with literal generation 0 → no-op without a prior captureGeneration(). + await cache.write('tools/list', { tools: [TOOL_A] }, 0); + expect(store.get({ method: 'tools/list', partition: PRE })).toBeUndefined(); const gen = cache.captureGeneration('tools/list'); await cache.evict('tools/list'); await cache.write('tools/list', { tools: [TOOL_A] }, gen);