diff --git a/packages/design-system-mcp/CHANGELOG.md b/packages/design-system-mcp/CHANGELOG.md index eec5c5540d8b25..cefd427d160763 100644 --- a/packages/design-system-mcp/CHANGELOG.md +++ b/packages/design-system-mcp/CHANGELOG.md @@ -5,6 +5,7 @@ ### Enhancements - `get_component_details` now optionally accepts an array of component names so multiple components can be fetched in a single call. ([#78185](https://github.com/WordPress/gutenberg/pull/78185)) +- Data is now cached for at most one hour, rather than depending on agent session lifetime ([#78311](https://github.com/WordPress/gutenberg/pull/78311)). ## 0.3.0 (2026-05-14) diff --git a/packages/design-system-mcp/src/data.ts b/packages/design-system-mcp/src/data.ts index 20f03b587fb1de..75ed73681506d1 100644 --- a/packages/design-system-mcp/src/data.ts +++ b/packages/design-system-mcp/src/data.ts @@ -13,30 +13,58 @@ const DESIGN_TOKENS_URL = process.env.DESIGN_TOKENS_URL || 'https://raw.githubusercontent.com/WordPress/gutenberg/refs/heads/trunk/packages/theme/docs/tokens.md'; -let cachedComponents: Record< string, ManifestComponent > | null = null; -let cachedTokens: string | null = null; +const HOUR_IN_MS = 60 * 60 * 1000; -/** - * Clear cached data. Intended for testing. - */ -export function resetCache(): void { - cachedComponents = null; - cachedTokens = null; -} +type CachedFetcher< T > = ( () => Promise< T > ) & { reset: () => void }; /** - * Fetch and cache the components from the Storybook manifest, filtered to only - * components from allowed packages. + * Wrap an async fetcher so that successful results are cached for `ttlMs` + * milliseconds. Concurrent callers share a single in-flight promise, and + * failed fetches are evicted immediately so the next call can retry. * - * @return The filtered components record. + * The returned function exposes a `reset()` method that clears its own + * cache, allowing callers to compose a higher-level reset without the + * wrapper needing to know about a shared registry. + * + * @param fetcher - The async function whose result should be cached. + * @param ttlMs - Cache lifetime in milliseconds. Defaults to one hour. + * @return A function returning the cached (or freshly fetched) promise. */ -async function fetchComponents(): Promise< - Record< string, ManifestComponent > -> { - if ( cachedComponents ) { - return cachedComponents; - } +function withTTL< T >( + fetcher: () => Promise< T >, + ttlMs: number = HOUR_IN_MS +): CachedFetcher< T > { + let cached: Promise< T > | null = null; + let expiresAt = 0; + + return Object.assign( + () => { + if ( ! cached || Date.now() > expiresAt ) { + cached = ( async () => { + try { + return await fetcher(); + } catch ( error ) { + cached = null; + expiresAt = 0; + throw error; + } + } )(); + expiresAt = Date.now() + ttlMs; + } + + return cached; + }, + { + reset: () => { + cached = null; + expiresAt = 0; + }, + } + ); +} + +const fetchComponents = withTTL( async () => { const response = await fetch( COMPONENTS_MANIFEST_URL ); if ( ! response.ok ) { throw new Error( @@ -56,8 +84,26 @@ async function fetchComponents(): Promise< } } - cachedComponents = filtered; - return cachedComponents; + return filtered; +} ); + +const fetchTokens = withTTL( async () => { + const response = await fetch( DESIGN_TOKENS_URL ); + if ( ! response.ok ) { + throw new Error( + `Failed to fetch design tokens: ${ response.status } ${ response.statusText }` + ); + } + + return response.text(); +} ); + +/** + * Clear all cached data. Intended for testing. + */ +export function resetCache(): void { + fetchComponents.reset(); + fetchTokens.reset(); } /** @@ -88,17 +134,6 @@ export async function getComponentDetail( * * @return The tokens markdown content. */ -export async function getDesignTokens(): Promise< { content: string } > { - if ( ! cachedTokens ) { - const response = await fetch( DESIGN_TOKENS_URL ); - if ( ! response.ok ) { - throw new Error( - `Failed to fetch design tokens: ${ response.status } ${ response.statusText }` - ); - } - - cachedTokens = await response.text(); - } - - return { content: cachedTokens }; +export function getDesignTokens(): Promise< string > { + return fetchTokens(); } diff --git a/packages/design-system-mcp/src/test/data.ts b/packages/design-system-mcp/src/test/data.ts index 76d89a914365e4..41a7be0e9f2441 100644 --- a/packages/design-system-mcp/src/test/data.ts +++ b/packages/design-system-mcp/src/test/data.ts @@ -86,6 +86,49 @@ describe( 'data', () => { 'Failed to fetch components manifest' ); } ); + + it( 'should deduplicate concurrent in-flight requests', async () => { + mockFetchResponses( { + [ MANIFEST_URL ]: { ok: true, body: manifestFixture }, + } ); + + await Promise.all( [ + getComponents(), + getComponents(), + getComponentDetail( 'Button' ), + ] ); + + expect( globalThis.fetch ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should evict failed fetches so the next call can retry', async () => { + let callCount = 0; + globalThis.fetch = jest.fn( () => { + callCount += 1; + if ( callCount === 1 ) { + return Promise.resolve( { + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: () => Promise.resolve( null ), + } as Response ); + } + return Promise.resolve( { + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve( manifestFixture ), + } as Response ); + } ); + + await expect( getComponents() ).rejects.toThrow( + 'Failed to fetch components manifest' + ); + + const result = await getComponents(); + expect( result.length ).toBeGreaterThan( 0 ); + expect( globalThis.fetch ).toHaveBeenCalledTimes( 2 ); + } ); } ); describe( 'getComponentDetail', () => { @@ -146,9 +189,7 @@ describe( 'data', () => { const result = await getDesignTokens(); - expect( result ).toEqual( { - content: '# Tokens\n\n| token | value |', - } ); + expect( result ).toBe( '# Tokens\n\n| token | value |' ); } ); it( 'should cache tokens across calls', async () => { @@ -171,5 +212,44 @@ describe( 'data', () => { 'Failed to fetch design tokens' ); } ); + + it( 'should deduplicate concurrent in-flight requests', async () => { + mockFetchResponses( { + [ TOKENS_URL ]: { ok: true, body: '# Tokens' }, + } ); + + await Promise.all( [ getDesignTokens(), getDesignTokens() ] ); + + expect( globalThis.fetch ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should evict failed fetches so the next call can retry', async () => { + let callCount = 0; + globalThis.fetch = jest.fn( () => { + callCount += 1; + if ( callCount === 1 ) { + return Promise.resolve( { + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: () => Promise.resolve( '' ), + } as Response ); + } + return Promise.resolve( { + ok: true, + status: 200, + statusText: 'OK', + text: () => Promise.resolve( '# Tokens' ), + } as Response ); + } ); + + await expect( getDesignTokens() ).rejects.toThrow( + 'Failed to fetch design tokens' + ); + + const result = await getDesignTokens(); + expect( result ).toBe( '# Tokens' ); + expect( globalThis.fetch ).toHaveBeenCalledTimes( 2 ); + } ); } ); } ); diff --git a/packages/design-system-mcp/src/tools/get-component-details.ts b/packages/design-system-mcp/src/tools/get-component-details.ts index d0ca75ded10e0f..36b123788125f8 100644 --- a/packages/design-system-mcp/src/tools/get-component-details.ts +++ b/packages/design-system-mcp/src/tools/get-component-details.ts @@ -14,19 +14,24 @@ const inputSchema = z.object( { ), } ); -export async function handler( { name }: z.infer< typeof inputSchema > ) { - const names = Array.isArray( name ) ? name : [ name ]; +export async function handler( { + name: nameOrNames, +}: z.infer< typeof inputSchema > ) { + const names = Array.isArray( nameOrNames ) ? nameOrNames : [ nameOrNames ]; const sections: string[] = []; const missing: string[] = []; - for ( const componentName of names ) { - const detail = await getComponentDetail( componentName ); - if ( detail ) { - sections.push( formatComponentDetail( detail ) ); - } else { - missing.push( componentName ); - } - } + await Promise.all( + names.map( ( name ) => getComponentDetail( name ) ) + ).then( ( details ) => { + details.forEach( ( detail, index ) => { + if ( detail ) { + sections.push( formatComponentDetail( detail ) ); + } else { + missing.push( names[ index ] ); + } + } ); + } ); if ( sections.length === 0 ) { const list = missing.map( ( n ) => `"${ n }"` ).join( ', ' ); diff --git a/packages/design-system-mcp/src/tools/get-design-tokens.ts b/packages/design-system-mcp/src/tools/get-design-tokens.ts index 2fbf6f9cc7dca6..a4f5f861253c31 100644 --- a/packages/design-system-mcp/src/tools/get-design-tokens.ts +++ b/packages/design-system-mcp/src/tools/get-design-tokens.ts @@ -23,7 +23,7 @@ export function register( server: McpServer ): void { content: [ { type: 'text', - text: tokens.content, + text: tokens, }, ], };