Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/design-system-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
101 changes: 68 additions & 33 deletions packages/design-system-mcp/src/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
} )();
Comment on lines +43 to +51

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as before re. the readability of an IIFE. Could be potentially rewritten to

async function load(): Promise< T > {
	try {
		return await fetcher();
	} catch ( error ) {
		cached = null;
		expiresAt = 0;
		throw error;
	}
}
cached = load();


expiresAt = Date.now() + ttlMs;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we start the timer before the fetch resolves? This comes mostly from a place of curiosity, rather than flagging a bug with the implementation.

Maybe worth adding a short code comment about it?

}

return cached;
},
{
reset: () => {
cached = null;
expiresAt = 0;
},
}
);
}
Comment on lines +33 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love this!

Although AI-assisted review flagged a potential (albeit rare) edge condition: when the TTL fires while a fetch is still in flght (a 1-hour TTL with a >1-hour-stuck fetch, or Date.now() skewing forward), the next caller falls into the cache-miss branch and overwrites cached with a new IIFE-promise. Both IIFEs are now closed over the same cached and expiresAt variables.

If the first IIFE then rejects (after the second has been assigned), its catch handler runs and nukes the second IIFE's cached entry, breaking dedup for anyone arriving between that moment and the second IIFE settling.

The same hazard exists with reset() racing an in-flight IIFE, though that one is "by design" (callers asked for a clean cache).

Claude suggests this as a potential fix

make the catch identity-check before clearing:

() => {
	if ( ! cached || Date.now() > expiresAt ) {
		const promise: Promise< T > = ( async () => {
			try {
				return await fetcher();
			} catch ( error ) {
				if ( cached === promise ) {
					cached = null;
					expiresAt = 0;
				}
				throw error;
			}
		} )();
		cached = promise;
		expiresAt = Date.now() + ttlMs;
	}
	return cached;
}

an equivalent shape using an outer let works too

Again, extremely unlikely but potentially possible, especially if the TTL will every become shorter


const fetchComponents = withTTL( async () => {
const response = await fetch( COMPONENTS_MANIFEST_URL );
if ( ! response.ok ) {
throw new Error(
Expand All @@ -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();
}

/**
Expand Down Expand Up @@ -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();
}
86 changes: 83 additions & 3 deletions packages/design-system-mcp/src/test/data.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add tests targeting the TTL logic? In particular

  • Re-fetches after TTL expires. We could drive Date.now() forward with jest.useFakeTimers().setSystemTime(...), call once, advance past HOUR_IN_MS, call again, expect two fetch calls.
  • Does not re-fetch within TTL. Same setup, advance by less than HOUR_IN_MS, expect one fetch call. (Partially covered by the existing "should cache" test, but it doesn't pin the boundary.)

Another potentially missing test is to check that wwo concurrent callers share the same rejection. Right now the "should evict failed fetches" test fires the failure, awaits its rejection, then makes a second call, but it doesn't check that two concurrent callers awaiting the same in-flight failure both see it.

Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 );
} );
} );
} );
25 changes: 15 additions & 10 deletions packages/design-system-mcp/src/tools/get-component-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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( ', ' );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function register( server: McpServer ): void {
content: [
{
type: 'text',
text: tokens.content,
text: tokens,
},
],
};
Expand Down
Loading