refactor(client): response cache stores serialized documents; drop structuredClone (#2468)
This commit is contained in:
parent
3f07a325c6
commit
5db6e38f2b
3 changed files with 457 additions and 112 deletions
|
|
@ -132,9 +132,9 @@ describe('SEP-2243 Mcp-Param-* mirroring (modern era)', () => {
|
|||
const { tools } = await client.listTools();
|
||||
expect(tools.map(t => t.name)).toEqual(['route']);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("excluding tool 'broken'"));
|
||||
expect((store.get({ method: 'tools/list', partition: PART })?.value as { tools: Tool[] }).tools.map(t => t.name)).toEqual([
|
||||
'route'
|
||||
]);
|
||||
expect(
|
||||
(JSON.parse(store.get({ method: 'tools/list', partition: PART })!.value) as { tools: Tool[] }).tools.map(t => t.name)
|
||||
).toEqual(['route']);
|
||||
// The explicit-cursor per-page path is filtered too (the spec's MUST
|
||||
// has no carve-out for paginated reads).
|
||||
const page = await client.listTools({ cursor: '0' });
|
||||
|
|
@ -178,14 +178,14 @@ describe('SEP-2243 Mcp-Param-* mirroring (modern era)', () => {
|
|||
store.set(
|
||||
{ method: 'tools/list', partition: PART },
|
||||
{
|
||||
value: {
|
||||
value: JSON.stringify({
|
||||
tools: [
|
||||
{
|
||||
name: 'route',
|
||||
inputSchema: { type: 'object', properties: { region: { type: 'string', 'x-mcp-header': 'Stale-Region' } } }
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -197,7 +197,7 @@ describe('SEP-2243 Mcp-Param-* mirroring (modern era)', () => {
|
|||
expect(callHeaders).toEqual([{ 'Mcp-Param-Stale-Region': 'ap' }, { 'Mcp-Param-Region': 'ap' }]);
|
||||
// The recovery refetch wrote a fresh cache entry (REGION_TOOL, with the declaration).
|
||||
expect(
|
||||
(store.get({ method: 'tools/list', partition: PART })?.value as { tools: Tool[] }).tools[0]?.inputSchema.properties
|
||||
(JSON.parse(store.get({ method: 'tools/list', partition: PART })!.value) as { tools: Tool[] }).tools[0]?.inputSchema.properties
|
||||
).toHaveProperty('region');
|
||||
});
|
||||
|
||||
|
|
@ -217,14 +217,14 @@ describe('SEP-2243 Mcp-Param-* mirroring (modern era)', () => {
|
|||
store.set(
|
||||
{ method: 'tools/list', partition: PART },
|
||||
{
|
||||
value: {
|
||||
value: JSON.stringify({
|
||||
tools: [
|
||||
{
|
||||
name: 'route',
|
||||
inputSchema: { type: 'object', properties: { region: { type: 'string', 'x-mcp-header': 'Stale-Region' } } }
|
||||
}
|
||||
]
|
||||
},
|
||||
}),
|
||||
expiresAt: Date.now() + 60_000,
|
||||
scope: 'public'
|
||||
}
|
||||
|
|
@ -240,7 +240,7 @@ describe('SEP-2243 Mcp-Param-* mirroring (modern era)', () => {
|
|||
// The refetch's write overwrote the stale entry (the no-op delete
|
||||
// never dropped it; the `'refresh'` write replaced it).
|
||||
expect(
|
||||
(store.get({ method: 'tools/list', partition: PART })?.value as { tools: Tool[] }).tools[0]?.inputSchema.properties
|
||||
(JSON.parse(store.get({ method: 'tools/list', partition: PART })!.value) as { tools: Tool[] }).tools[0]?.inputSchema.properties
|
||||
).toHaveProperty(['region', 'x-mcp-header'], 'Region');
|
||||
});
|
||||
|
||||
|
|
@ -309,7 +309,7 @@ describe('SEP-2243 Mcp-Param-* mirroring (modern era)', () => {
|
|||
store.set(
|
||||
{ method: 'tools/list', partition: PART },
|
||||
{
|
||||
value: {
|
||||
value: JSON.stringify({
|
||||
tools: [
|
||||
PAGE1,
|
||||
{
|
||||
|
|
@ -317,7 +317,7 @@ describe('SEP-2243 Mcp-Param-* mirroring (modern era)', () => {
|
|||
inputSchema: { type: 'object', properties: { region: { type: 'string', 'x-mcp-header': 'Stale-Region' } } }
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -344,7 +344,7 @@ describe('SEP-2243 Mcp-Param-* mirroring (modern era)', () => {
|
|||
// and sends without headers — callTool never refetches on its own.
|
||||
store.set(
|
||||
{ method: 'tools/list', partition: PART },
|
||||
{ value: { tools: [{ name: 'route', inputSchema: { type: 'object', properties: {} } }] } }
|
||||
{ value: JSON.stringify({ tools: [{ name: 'route', inputSchema: { type: 'object', properties: {} } }] }) }
|
||||
);
|
||||
expect(store.get({ method: 'tools/list', partition: PART })).toBeDefined();
|
||||
await serverTx.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' } as JSONRPCMessage);
|
||||
|
|
|
|||
|
|
@ -33,25 +33,25 @@ const PRE = JSON.stringify(['', '']);
|
|||
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' });
|
||||
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 });
|
||||
expect(store.get({ method: 'tools/list' })).toEqual({ value: '1', stamp: s1 });
|
||||
// Store persists caller-supplied freshness metadata.
|
||||
expect(store.get({ method: 'resources/read', params: 'file:///a' })).toEqual({
|
||||
value: 3,
|
||||
value: '3',
|
||||
stamp: s3,
|
||||
expiresAt: 123,
|
||||
scope: 'private'
|
||||
});
|
||||
expect(store.get({ method: 'tools/list', params: '', partition: '' })?.value).toBe(1);
|
||||
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);
|
||||
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();
|
||||
});
|
||||
|
|
@ -75,10 +75,10 @@ describe('InMemoryResponseCacheStore', () => {
|
|||
// A NUL in `partition` cannot smuggle into `params` (and vice versa) —
|
||||
// the `[partition, params]` JSON-array encoding escapes every control
|
||||
// and quote character.
|
||||
store.set({ method: 'resources/read', partition: 'a\0b', params: 'c' }, { value: 1 });
|
||||
store.set({ method: 'resources/read', partition: 'a', params: 'b\0c' }, { value: 2 });
|
||||
expect(store.get({ method: 'resources/read', partition: 'a\0b', params: 'c' })?.value).toBe(1);
|
||||
expect(store.get({ method: 'resources/read', partition: 'a', params: 'b\0c' })?.value).toBe(2);
|
||||
store.set({ method: 'resources/read', partition: 'a\0b', params: 'c' }, { value: '1' });
|
||||
store.set({ method: 'resources/read', partition: 'a', params: 'b\0c' }, { value: '2' });
|
||||
expect(store.get({ method: 'resources/read', partition: 'a\0b', params: 'c' })?.value).toBe('1');
|
||||
expect(store.get({ method: 'resources/read', partition: 'a', params: 'b\0c' })?.value).toBe('2');
|
||||
// Same for the partition's own JSON-shaped content: the outer
|
||||
// JSON.stringify escapes the inner quotes.
|
||||
store.set({ method: 'tools/list', partition: '["x",""]' }, { value: 'real' });
|
||||
|
|
@ -104,7 +104,7 @@ describe('InMemoryResponseCacheStore', () => {
|
|||
|
||||
// 0 disables the bound.
|
||||
const unbounded = new InMemoryResponseCacheStore({ maxEntries: 0 });
|
||||
for (let i = 0; i < 1000; i++) unbounded.set({ method: 'resources/read', params: String(i) }, { value: i });
|
||||
for (let i = 0; i < 1000; i++) unbounded.set({ method: 'resources/read', params: String(i) }, { value: String(i) });
|
||||
expect(unbounded.size).toBe(1000);
|
||||
});
|
||||
|
||||
|
|
@ -119,7 +119,7 @@ describe('InMemoryResponseCacheStore', () => {
|
|||
// Five exempt entries already exceed maxEntries=3; a resources/read
|
||||
// write does NOT evict any of them — the cap counts only non-exempt
|
||||
// keys.
|
||||
for (let i = 0; i < 5; i++) store.set({ method: 'resources/read', params: String(i) }, { value: i });
|
||||
for (let i = 0; i < 5; i++) store.set({ method: 'resources/read', params: String(i) }, { value: String(i) });
|
||||
expect(store.get({ method: 'tools/list' })?.value).toBe('T');
|
||||
expect(store.get({ method: 'prompts/list' })?.value).toBe('P');
|
||||
expect(store.get({ method: 'resources/list' })?.value).toBe('R');
|
||||
|
|
@ -128,21 +128,21 @@ describe('InMemoryResponseCacheStore', () => {
|
|||
// Only 3 resources/read entries survive (oldest two evicted).
|
||||
expect(store.get({ method: 'resources/read', params: '0' })).toBeUndefined();
|
||||
expect(store.get({ method: 'resources/read', params: '1' })).toBeUndefined();
|
||||
expect(store.get({ method: 'resources/read', params: '2' })?.value).toBe(2);
|
||||
expect(store.get({ method: 'resources/read', params: '4' })?.value).toBe(4);
|
||||
expect(store.get({ method: 'resources/read', params: '2' })?.value).toBe('2');
|
||||
expect(store.get({ method: 'resources/read', params: '4' })?.value).toBe('4');
|
||||
expect(store.size).toBe(8);
|
||||
// An exempt-method write at capacity never evicts a resources/read entry.
|
||||
store.set({ method: 'tools/list', partition: 'p2' }, { value: 'T2' });
|
||||
expect(store.get({ method: 'resources/read', params: '2' })?.value).toBe(2);
|
||||
expect(store.get({ method: 'resources/read', params: '2' })?.value).toBe('2');
|
||||
});
|
||||
|
||||
it('delete(key) drops the single entry; no-op when absent', () => {
|
||||
const store = new InMemoryResponseCacheStore();
|
||||
store.set({ method: 'resources/read', params: 'a', partition: 'p' }, { value: 1 });
|
||||
store.set({ method: 'resources/read', params: 'b', partition: 'p' }, { value: 2 });
|
||||
store.set({ method: 'resources/read', params: 'a', partition: 'p' }, { value: '1' });
|
||||
store.set({ method: 'resources/read', params: 'b', partition: 'p' }, { value: '2' });
|
||||
store.delete({ method: 'resources/read', params: 'a', partition: 'p' });
|
||||
expect(store.get({ method: 'resources/read', params: 'a', partition: 'p' })).toBeUndefined();
|
||||
expect(store.get({ method: 'resources/read', params: 'b', partition: 'p' })?.value).toBe(2);
|
||||
expect(store.get({ method: 'resources/read', params: 'b', partition: 'p' })?.value).toBe('2');
|
||||
// Absent key: no-op.
|
||||
store.delete({ method: 'resources/read', params: 'a', partition: 'p' });
|
||||
});
|
||||
|
|
@ -194,12 +194,11 @@ describe('ClientResponseCache', () => {
|
|||
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
|
||||
// The cache serialized the value on write, so the store and the
|
||||
// stamp-memoized index are unaffected.
|
||||
expect((store.get({ method: 'tools/list', partition: PRE })?.value as { tools: Tool[] }).tools.map(t => t.name)).toEqual([
|
||||
'a',
|
||||
'b'
|
||||
]);
|
||||
expect(
|
||||
(JSON.parse(store.get({ method: 'tools/list', partition: PRE })!.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');
|
||||
});
|
||||
|
|
@ -212,17 +211,25 @@ describe('ClientResponseCache', () => {
|
|||
// keyed per URI).
|
||||
const gen = cache.captureGeneration('resources/read', 'res://a');
|
||||
await cache.evictKey('resources/read', 'res://a');
|
||||
await cache.write('resources/read', { contents: [] }, gen, { expiresAt: 1e9, scope: 'private', params: 'res://a' });
|
||||
await cache.write('resources/read', { contents: [] }, gen, { expiresAt: Date.now() + 60_000, scope: 'private', params: 'res://a' });
|
||||
expect(store.get({ method: 'resources/read', params: 'res://a', partition: PRE })).toBeUndefined();
|
||||
// A sibling URI's generation is independent: evictKey('a') does not
|
||||
// suppress a write for 'b'.
|
||||
const genB = cache.captureGeneration('resources/read', 'res://b');
|
||||
await cache.evictKey('resources/read', 'res://a');
|
||||
await cache.write('resources/read', { contents: [] }, genB, { expiresAt: 1e9, scope: 'private', params: 'res://b' });
|
||||
await cache.write('resources/read', { contents: [] }, genB, {
|
||||
expiresAt: Date.now() + 60_000,
|
||||
scope: 'private',
|
||||
params: 'res://b'
|
||||
});
|
||||
expect(store.get({ method: 'resources/read', params: 'res://b', partition: PRE })).toBeDefined();
|
||||
// A fresh capture after the evictKey writes through.
|
||||
const gen2 = cache.captureGeneration('resources/read', 'res://a');
|
||||
await cache.write('resources/read', { contents: [] }, gen2, { expiresAt: 1e9, scope: 'private', params: 'res://a' });
|
||||
await cache.write('resources/read', { contents: [] }, gen2, {
|
||||
expiresAt: Date.now() + 60_000,
|
||||
scope: 'private',
|
||||
params: 'res://a'
|
||||
});
|
||||
expect(store.get({ method: 'resources/read', params: 'res://a', partition: PRE })).toBeDefined();
|
||||
});
|
||||
|
||||
|
|
@ -264,7 +271,7 @@ describe('ClientResponseCache', () => {
|
|||
};
|
||||
const cache = new ClientResponseCache(store, true);
|
||||
await cache.write('tools/list', { tools: [TOOL_A] }, cache.captureGeneration('tools/list'), {
|
||||
expiresAt: 1e9,
|
||||
expiresAt: Date.now() + 60_000,
|
||||
scope: 'private'
|
||||
});
|
||||
// The read path finds the entry the write path stored.
|
||||
|
|
@ -292,7 +299,7 @@ describe('ClientResponseCache', () => {
|
|||
const cache = new ClientResponseCache(store, true);
|
||||
expect(await cache.toolDefinition('a')).toBeUndefined();
|
||||
|
||||
store.set({ method: 'tools/list', partition: PRE }, { value: { tools: [TOOL_A, TOOL_B] } });
|
||||
store.set({ method: 'tools/list', partition: PRE }, { value: JSON.stringify({ 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).
|
||||
|
|
@ -300,7 +307,7 @@ describe('ClientResponseCache', () => {
|
|||
|
||||
// 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', partition: PRE }, { value: { tools: [{ ...TOOL_A }, { ...TOOL_B }] } });
|
||||
store.set({ method: 'tools/list', partition: PRE }, { value: JSON.stringify({ tools: [{ ...TOOL_A }, { ...TOOL_B }] }) });
|
||||
const hit2 = await cache.toolDefinition('a');
|
||||
expect(hit2?.name).toBe('a');
|
||||
expect(hit2).not.toBe(hit);
|
||||
|
|
@ -426,7 +433,7 @@ describe('Client response-cache substrate', () => {
|
|||
expect(listCount()).toBe(3);
|
||||
|
||||
const entry = store.get({ method: 'tools/list', partition: part() });
|
||||
expect((entry?.value as { tools: Tool[] }).tools.map(t => t.name)).toEqual(['a', 'b']);
|
||||
expect((JSON.parse(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 () => {
|
||||
|
|
@ -459,8 +466,8 @@ describe('Client response-cache substrate', () => {
|
|||
// 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.
|
||||
// The cache serialized the value on write, 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');
|
||||
});
|
||||
|
|
@ -642,7 +649,7 @@ describe('Client honours cacheHints (SEP-2549)', () => {
|
|||
const second = await client.listTools();
|
||||
expect(second.tools.map(t => t.name)).toEqual(['a', 'b']);
|
||||
expect(listCount()).toBe(1);
|
||||
// Clone-on-serve: hit is a fresh copy, not the stored object.
|
||||
// Parse-on-serve: hit is a fresh copy, not the stored object.
|
||||
expect(second).not.toBe(first);
|
||||
|
||||
// After TTL → stale, refetch.
|
||||
|
|
|
|||
267
packages/client/test/client/responseCacheCodec.test.ts
Normal file
267
packages/client/test/client/responseCacheCodec.test.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import type { JSONRPCRequest } from '@modelcontextprotocol/core-internal';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/core-internal';
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
import { Client } from '../../src/client/client';
|
||||
import type { CacheEntry, CacheKey, ResponseCacheStore } from '../../src/client/responseCache';
|
||||
import { ClientResponseCache, InMemoryResponseCacheStore } from '../../src/client/responseCache';
|
||||
|
||||
const savedStructuredClone = globalThis.structuredClone;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.structuredClone = savedStructuredClone;
|
||||
});
|
||||
|
||||
// Scripted server over a raw linked transport pair (the sibling
|
||||
// responseCache.test.ts pattern) — the client package's tests must not
|
||||
// depend on @modelcontextprotocol/server.
|
||||
async function connectedPair() {
|
||||
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
|
||||
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: ['2026-07-28'],
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: 's', version: '1.0.0' }
|
||||
}
|
||||
});
|
||||
} else if (r.method === 'tools/list') {
|
||||
void serverTx.send({
|
||||
jsonrpc: '2.0',
|
||||
id: r.id,
|
||||
result: {
|
||||
resultType: 'complete',
|
||||
ttlMs: 60_000,
|
||||
cacheScope: 'private',
|
||||
tools: [{ name: 'echo', description: 'echo', inputSchema: { type: 'object' } }]
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
await serverTx.start();
|
||||
const client = new Client({ name: 'c', version: '1.0.0' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } });
|
||||
await client.connect(clientTx);
|
||||
const server = { close: () => serverTx.close() };
|
||||
return { client, server };
|
||||
}
|
||||
|
||||
describe('response cache document codec', () => {
|
||||
test('the cache does not depend on structuredClone existing (jest-jsdom / Node < 17)', async () => {
|
||||
// Pre-codec, both cache edges called the global: environments without
|
||||
// it threw every write into the store-error swallow, silently
|
||||
// disabling caching. The codec (JSON round-trip) has no environment
|
||||
// dependency; this pins that the global is never required again.
|
||||
// @ts-expect-error simulating environments without the global
|
||||
delete globalThis.structuredClone;
|
||||
|
||||
const { client, server } = await connectedPair();
|
||||
const errors: unknown[] = [];
|
||||
client.onerror = e => errors.push(e);
|
||||
const first = await client.listTools();
|
||||
const second = await client.listTools();
|
||||
expect(first.tools.map(t => t.name)).toEqual(['echo']);
|
||||
expect(second.tools.map(t => t.name)).toEqual(['echo']);
|
||||
expect(errors).toEqual([]);
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test('served results are caller-owned: mutating one hit cannot reach the next', async () => {
|
||||
const { client, server } = await connectedPair();
|
||||
const first = await client.listTools();
|
||||
first.tools.length = 0;
|
||||
const second = await client.listTools();
|
||||
expect(second.tools.map(t => t.name)).toEqual(['echo']);
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test('custom stores receive the serialized document, not a live object graph', async () => {
|
||||
const seen: unknown[] = [];
|
||||
const store = new InMemoryResponseCacheStore();
|
||||
const originalSet = store.set.bind(store);
|
||||
store.set = (key, entry) => {
|
||||
seen.push(entry.value);
|
||||
return originalSet(key, entry);
|
||||
};
|
||||
const cache = new ClientResponseCache(store, true);
|
||||
await cache.write(
|
||||
'tools/list',
|
||||
{ tools: [{ name: 'a', inputSchema: { type: 'object' } }] },
|
||||
cache.captureGeneration('tools/list'),
|
||||
{
|
||||
expiresAt: Date.now() + 60_000,
|
||||
scope: 'private'
|
||||
}
|
||||
);
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(typeof seen[0]).toBe('string');
|
||||
expect(JSON.parse(seen[0] as string).tools[0].name).toBe('a');
|
||||
});
|
||||
|
||||
test('a non-JSON-serializable value fails the write loudly and does not poison later calls', async () => {
|
||||
const reported: unknown[] = [];
|
||||
const store = new InMemoryResponseCacheStore();
|
||||
const cache = new ClientResponseCache(store, true, error => reported.push(error));
|
||||
const cyclic: { tools: unknown[]; self?: unknown } = { tools: [] };
|
||||
cyclic.self = cyclic;
|
||||
|
||||
await cache.write('tools/list', cyclic, cache.captureGeneration('tools/list'), {
|
||||
expiresAt: Date.now() + 60_000,
|
||||
scope: 'private'
|
||||
});
|
||||
// Reported (TypeError naming the cause), nothing stored, read is a miss.
|
||||
expect(reported).toHaveLength(1);
|
||||
expect(String(reported[0])).toMatch(/not JSON-serializable/);
|
||||
expect(await cache.read('tools/list')).toBeUndefined();
|
||||
|
||||
// The cache stays functional for well-formed values afterwards.
|
||||
await cache.write(
|
||||
'tools/list',
|
||||
{ tools: [{ name: 'b', inputSchema: { type: 'object' } }] },
|
||||
cache.captureGeneration('tools/list'),
|
||||
{
|
||||
expiresAt: Date.now() + 60_000,
|
||||
scope: 'private'
|
||||
}
|
||||
);
|
||||
expect(((await cache.read('tools/list'))?.value as { tools: { name: string }[] }).tools[0]?.name).toBe('b');
|
||||
});
|
||||
|
||||
test('a corrupted document in an external store reads as a miss, not a crash', async () => {
|
||||
const reported: unknown[] = [];
|
||||
const store: ResponseCacheStore = {
|
||||
get: () => ({ value: '{ definitely not json', stamp: 1, expiresAt: Date.now() + 60_000, scope: 'private' as const }),
|
||||
set: () => 1,
|
||||
delete: () => {},
|
||||
evict: () => {},
|
||||
clear: () => {}
|
||||
};
|
||||
const cache = new ClientResponseCache(store, true, error => reported.push(error));
|
||||
expect(await cache.read('tools/list')).toBeUndefined();
|
||||
expect(await cache.toolDefinition('a')).toBeUndefined();
|
||||
expect(reported.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('a value JSON.stringify silently cannot represent (top-level undefined) fails the write loudly, not as a stored "undefined"', async () => {
|
||||
const reported: unknown[] = [];
|
||||
const sets: unknown[] = [];
|
||||
const store = new InMemoryResponseCacheStore();
|
||||
const originalSet = store.set.bind(store);
|
||||
store.set = (key, entry) => {
|
||||
sets.push(entry.value);
|
||||
return originalSet(key, entry);
|
||||
};
|
||||
const cache = new ClientResponseCache(store, true, error => reported.push(error));
|
||||
await cache.write('tools/list', undefined, cache.captureGeneration('tools/list'), {
|
||||
expiresAt: Date.now() + 60_000,
|
||||
scope: 'private'
|
||||
});
|
||||
expect(reported).toHaveLength(1);
|
||||
expect(String(reported[0])).toMatch(/not JSON-serializable/);
|
||||
expect(sets).toHaveLength(0);
|
||||
expect(await cache.read('tools/list')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a fresh undecodable entry is dropped on read, so it is reported once, not on every read until expiry', async () => {
|
||||
const reported: unknown[] = [];
|
||||
const deletes: CacheKey[] = [];
|
||||
let entry: CacheEntry | undefined = { value: '{ not json', stamp: 1, expiresAt: Date.now() + 60_000, scope: 'private' };
|
||||
const store: ResponseCacheStore = {
|
||||
get: () => entry,
|
||||
set: () => 1,
|
||||
delete: key => {
|
||||
deletes.push(key);
|
||||
entry = undefined;
|
||||
},
|
||||
evict: () => {},
|
||||
clear: () => {}
|
||||
};
|
||||
const cache = new ClientResponseCache(store, true, error => reported.push(error));
|
||||
expect(await cache.read('resources/read', 'file:///a')).toBeUndefined();
|
||||
expect(reported).toHaveLength(1);
|
||||
expect(deletes.length).toBeGreaterThan(0);
|
||||
expect(deletes[0]).toMatchObject({ method: 'resources/read', params: 'file:///a' });
|
||||
// Entry gone: the next read is a clean miss, no second report.
|
||||
expect(await cache.read('resources/read', 'file:///a')).toBeUndefined();
|
||||
expect(reported).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a valid-JSON but wrong-shape tools/list document is reported once per stamp and treated as absent, not thrown', async () => {
|
||||
for (const document of ['null', '{}', '[]']) {
|
||||
const reported: unknown[] = [];
|
||||
const store: ResponseCacheStore = {
|
||||
get: () => ({ value: document, stamp: 7, scope: 'private' as const }),
|
||||
set: () => 1,
|
||||
delete: () => {},
|
||||
evict: () => {},
|
||||
clear: () => {}
|
||||
};
|
||||
const cache = new ClientResponseCache(store, true, error => reported.push(error));
|
||||
await expect(cache.toolDefinition('a')).resolves.toBeUndefined();
|
||||
await expect(cache.toolDefinition('a')).resolves.toBeUndefined();
|
||||
await expect(cache.outputValidator('a', () => undefined)).resolves.toBeUndefined();
|
||||
// Memoized against the unchanged stamp: one report for the tool
|
||||
// index, one for the validator index — not one per lookup.
|
||||
expect(reported).toHaveLength(2);
|
||||
expect(String(reported[0])).toMatch(/tools array/);
|
||||
}
|
||||
});
|
||||
|
||||
test('a NaN expiresAt is never fresh: the entry is not served at any clock value', async () => {
|
||||
const store: ResponseCacheStore = {
|
||||
get: () => ({ value: '{"tools":[]}', stamp: 1, expiresAt: NaN, scope: 'private' as const }),
|
||||
set: () => 1,
|
||||
delete: () => {},
|
||||
evict: () => {},
|
||||
clear: () => {}
|
||||
};
|
||||
const cache = new ClientResponseCache(store, true);
|
||||
expect(await cache.read('tools/list')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a fresh decodable-but-non-object document is reported, dropped, and read as a miss', async () => {
|
||||
for (const document of ['null', '"str"', '[]']) {
|
||||
const reported: unknown[] = [];
|
||||
const deletes: CacheKey[] = [];
|
||||
let entry: CacheEntry | undefined = { value: document, stamp: 1, expiresAt: Date.now() + 60_000, scope: 'private' };
|
||||
const store: ResponseCacheStore = {
|
||||
get: () => entry,
|
||||
set: () => 1,
|
||||
delete: key => {
|
||||
deletes.push(key);
|
||||
entry = undefined;
|
||||
},
|
||||
evict: () => {},
|
||||
clear: () => {}
|
||||
};
|
||||
const cache = new ClientResponseCache(store, true, error => reported.push(error));
|
||||
expect(await cache.read('tools/list')).toBeUndefined();
|
||||
expect(reported).toHaveLength(1);
|
||||
expect(String(reported[0])).toMatch(/not an object/);
|
||||
expect(deletes.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('a tools/list document with non-object elements is reported once per stamp, not thrown per lookup', async () => {
|
||||
const reported: unknown[] = [];
|
||||
const store: ResponseCacheStore = {
|
||||
get: () => ({ value: '{"tools":[null]}', stamp: 9, scope: 'private' as const }),
|
||||
set: () => 1,
|
||||
delete: () => {},
|
||||
evict: () => {},
|
||||
clear: () => {}
|
||||
};
|
||||
const cache = new ClientResponseCache(store, true, error => reported.push(error));
|
||||
await expect(cache.toolDefinition('a')).resolves.toBeUndefined();
|
||||
await expect(cache.toolDefinition('a')).resolves.toBeUndefined();
|
||||
await expect(cache.outputValidator('a', () => undefined)).resolves.toBeUndefined();
|
||||
expect(reported).toHaveLength(2);
|
||||
expect(String(reported[0])).toMatch(/malformed tools array/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue