Skip to content
Merged
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
9 changes: 8 additions & 1 deletion .github/workflows/storybook-check.yml
Comment thread
ciampo marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
name: Storybook build and Smoke Tests

on: pull_request
on:
pull_request:
push:
branches:
- trunk

# Cancels all previous workflow runs for pull requests that have not completed.
concurrency:
Expand Down Expand Up @@ -37,6 +41,9 @@ jobs:
NODE_ENV: test
run: npm run storybook:build

- name: Validate manifest matches design-system-mcp contract
run: node packages/design-system-mcp/scripts/validate-manifest.mjs

- name: Build E2E Storybook
env:
NODE_ENV: test
Expand Down
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions packages/design-system-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@

## Unreleased

### 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))

## 0.3.0 (2026-05-14)

## 0.2.0 (2026-04-29)

- Initial release.

## 0.2.0 (2026-04-29)

- Initial release.
3 changes: 2 additions & 1 deletion packages/design-system-mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@
"zod": "4.3.6"
},
"devDependencies": {
"@types/jest": "^29.5.14"
"@types/jest": "^29.5.14",
"storybook": "^10.2.8"
},
"publishConfig": {
"access": "public"
Expand Down
23 changes: 23 additions & 0 deletions packages/design-system-mcp/scripts/validate-manifest.mjs
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.

This file is currently published in the npm release. We could move it to scripts/ or change the files glob to exclude it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This file is currently published in the npm release. We could move it to scripts/ or change the files glob to exclude it?

Moved to scripts/ in c2ff825.

Separately, I noticed that we don't have any type-checking on these files, both bin/ and now scripts/. We'll probably want a separate config similar to what we do for the theme package, because just adding it to include causes it to produce compiled files in that folder.

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.

Yeah agreed

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env node
import { readFile } from 'node:fs/promises';
import assert from 'node:assert';
import {
parseComponents,
parseComponentDetail,
} from '@wordpress/design-system-mcp';

const path = process.argv[ 2 ] ?? 'storybook/build/manifests/components.json';
const { components } = JSON.parse( await readFile( path, 'utf8' ) );
const names = parseComponents( components ).map( ( { name } ) => name );

assert(
names.length > 0,
`No components parsed from ${ path }. Manifest shape may have changed.`
);

assert(
names.some(
( name ) => parseComponentDetail( components, name )?.props.length > 0
),
`No components have parsed props. Manifest shape may have changed.`
);
2 changes: 2 additions & 0 deletions packages/design-system-mcp/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { McpServer } from '@modelcontextprotocol/server';
import { registerTools } from './tools/index';

export { parseComponents, parseComponentDetail } from './parse-components';

export function createServer() {
const server = new McpServer( {
name: 'WordPress Design System',
Expand Down
2 changes: 2 additions & 0 deletions packages/design-system-mcp/src/test/parse-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ function createComponents(
path:
value.path ??
`../packages/ui/src/${ key }/stories/index.story.tsx`,
stories: [],
jsDocTags: {},
...value,
};
}
Expand Down
86 changes: 57 additions & 29 deletions packages/design-system-mcp/src/tools/get-component-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,60 @@ import { z } from 'zod';
import { getComponentDetail } from '../data';
import { formatComponentDetail } from '../format';

const inputSchema = z.object( {
name: z
.union( [
z.string().min( 1 ),
z.array( z.string().min( 1 ) ).min( 1 ).max( 10 ),
] )
.describe(
'A component name, or an array of component names to fetch in a single call (e.g. "Button" or ["Button", "Tabs"]).'
),
} );

export async function handler( { name }: z.infer< typeof inputSchema > ) {
const names = Array.isArray( name ) ? name : [ name ];
const sections: string[] = [];
Copy link
Copy Markdown
Contributor

@ciampo ciampo May 15, 2026

Choose a reason for hiding this comment

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

Apologies in case this comment doesn't make much sense, but — seeing that sections is an array, could it ever include duplicate entries (eg [ 'Button;, 'Button' ]) ? Would it make sense to make it a Set instead?

Also, separately — how do we differentiate between components with the same name from different packages? Button, for example, exists in @wordpress/ui and @wordpress/components. Is parseComponentDetail sylently dropping cross-package duplicates?

Copy link
Copy Markdown
Contributor

@ciampo ciampo May 15, 2026

Choose a reason for hiding this comment

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

In other words, what I believe may be happening as of now:

  • calling get_component_details("Button") returns only one package's Button (the other Button is lost)
  • calling get_component_details(["Button", "Button"]) returns the same package's Button twice

Am I understanding correctly?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I had designed it as an intentional constraint that there's at most one component of any given name in the design system (i.e. one "Button"), forcing us to choose between @wordpress/components and @wordpress/ui as the one that should be used today. In other words, it's an explicit goal of the MCP to provide clear guidance. That "choosing" happens manually ahead-of-time by us applying tags: ['manifest'] to the Storybook story, and rigor to make sure that we don't apply it to more than one component of the same name. In the future hopefully we can drop this manual tagging in favor of just deferring to whatever's in the canonical set of design system packages (@wordpress/ui, excluding @wordpress/components altogether).

That being said, I've had it in my mind that the MCP could be more context-aware in ways that would reveal this kind of duplication:

  • Since many components are blocked by styling incompatibilities when used alongside @wordpress/components, it would be nice if the client could be aware and express when knowing that that constraint doesn't apply (e.g. a greenfield project or self-contained screen, like the AI experiment and Connectors screen are already doing).
  • I think it could be good to provide some context to the agent about which components are "coming soon" or explicitly deprecated, so that it can weigh that information and relay to the user accordingly (e.g. "here's what to use today, but heads-up about XYZ coming in the future"). I might imagine this would end up being surfaced as a separate section(s) of components in the listing, and then maybe combined with additional parameters on the tool, or entirely separate tools for getting the detail.

As far as duplication, I think it's technically possible, but seems both unlikely and unproblematic to preemptively address. If we start seeing agents issuing tool calls with duplicate names, I feel like that suggests a bigger problem for why it chose to do that that should be addressed at the source rather than deduplicating the responses.

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.

Thank you for the extended context 👌 I agree with everything you said.

Also, none of the above should be a blocker for this PR, anyway.

const missing: string[] = [];

for ( const componentName of names ) {
const detail = await getComponentDetail( componentName );
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.

Small nit (feel free to ignore):

the await in this for loop is effectively async only for the first iteration, since from the second iteration onwards the content is cached (in the getComponentDetail implementation).

Conceptually, it could make sense to rewrite using Promise.all/map for added robustness if caching ever changes?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think I see what you're saying, that this is fine, but only because we're piercing an abstraction to know how getComponentDetail would cache, but in a closed-box situation we would want to parallelize this.

I think it makes sense to change, but after starting to dig into it, it reveals a separate problem knock-on requirement to fetchComponents and getDesignTokens: Because we only cache the value once we finish fetching it, parallelized calls could trigger multiple simultaneous fetches. We can clean this up by caching the promise and not the value, but that's a bit of a bigger refactor that might be worth doing separate from this pull request as a quick follow-on. Especially since this should work well enough as-is.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We can clean this up by caching the promise and not the value, but that's a bit of a bigger refactor that might be worth doing separate from this pull request as a quick follow-on. Especially since this should work well enough as-is.

I created a separate pull request at #78311 with this refactor. We could either merge it into this branch or separately after this one lands.

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.

Definitely good as a follow-up after this PR gets merged, IMO

if ( detail ) {
sections.push( formatComponentDetail( detail ) );
} else {
missing.push( componentName );
}
}

if ( sections.length === 0 ) {
const list = missing.map( ( n ) => `"${ n }"` ).join( ', ' );
return {
content: [
{
type: 'text' as const,
text: `No components were found for: ${ list }.`,
},
],
isError: true,
};
}

let text = sections.join( '\n\n---\n\n' );
if ( missing.length > 0 ) {
const list = missing.map( ( n ) => `"${ n }"` ).join( ', ' );
text += `\n\n---\n\n_No components were found for: ${ list }._`;
}

return {
content: [
{
type: 'text' as const,
text,
},
],
};
}

/**
* Register the get_component_details tool.
*
Expand All @@ -14,38 +68,12 @@ export function register( server: McpServer ): void {
{
title: 'Get Component Details',
description:
'Get detailed documentation for a WordPress Design System component including props, usage examples, and import statements.',
inputSchema: z.object( {
name: z
.string()
.min( 1 )
.describe( 'The component name (e.g. "Button", "Tabs")' ),
} ),
'Get detailed documentation for one or more WordPress Design System components including props, usage examples, and import statements. Pass multiple names to fetch several components in a single call instead of making repeated calls.',
inputSchema,
annotations: {
readOnlyHint: true,
},
},
async ( { name } ) => {
const detail = await getComponentDetail( name );
if ( ! detail ) {
return {
content: [
{
type: 'text',
text: `No component named "${ name }" was found.`,
},
],
isError: true,
};
}
return {
content: [
{
type: 'text',
text: formatComponentDetail( detail ),
},
],
};
}
handler
);
}
126 changes: 126 additions & 0 deletions packages/design-system-mcp/src/tools/test/get-component-details.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { handler } from '../get-component-details';
import { getComponentDetail } from '../../data';
import { formatComponentDetail } from '../../format';
import type { ComponentDetail } from '../../types';

jest.mock( '../../data' );

const mockGetComponentDetail = getComponentDetail as jest.MockedFunction<
typeof getComponentDetail
>;

function fakeDetail( name: string ): ComponentDetail {
return {
name,
description: `${ name } description.`,
packageName: '@wordpress/ui',
importStatement: `import { ${ name } } from '@wordpress/ui';`,
props: [],
stories: [],
};
}
Comment on lines +12 to +21
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.

Is there a chance that, since we're mocking this data, any real API response breakage won't be flagged by tests?

Maybe we could generate mock data by running a real API call?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Is there a chance that, since we're mocking this data, any real API response breakage won't be flagged by tests?

Maybe we could generate mock data by running a real API call?

Yeah, I waffled quite a bit on just how much integration testing made sense here to give us confidence. With how this is set up currently, what you're describing probably falls more in the domain of the tests for data.ts, or a separate end-to-end / integration test setup. Personally I'd be concerned about the time cost and chance for flakiness through HTTP availability.

Some other ideas we could consider to improve our confidence around the type of issue you describe:

  • If Storybook published their own TypeScript types for manifest shape, we could have TypeScript-based indicators if the object shape ever changes.
  • Similarly, we could create our own expected schema and validate that the actual manifest output matches our expected shape.
  • We could have a low-fidelity test that runs after npm run storybook:build to make sure that storybook/build/components/manifest.json exists and contains a particular property we're expecting to access.

Since the manifest we're loading is managed in the same project, this isn't really an external change outside our realm of control. The scenario you're describing would be a process failure where someone updates the manifest without updating dependencies in other parts of the same project.

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.

Could we generate the manifest locally and validate its schema?

Alternatively, boosting our confidence via type checks (using Storybook types), that would be a nice improvement.

As a last resort, we could also consider adding some runtime validation — ie, when we receive the data payload, we analyze its shape and throw a warning / error. It won't prevent breakages, but at least it makes them obvious and allows us to fix them quickly. We could even instruct the agent to take that report and open a GH issue directly in the MCP response

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

To address this, I did a bit of both:

  • In 4c026d4, I updated to use Storybook's internal TypeScript types for the manifest object shape. This isn't complete, however, because it doesn't include the react-docgen types. And through some debugging I discovered that our current version of Storybook doesn't properly respect our use of react-docgen-typescript when generating the manifest. So it is very likely that this will in-fact break in future version upgrades (reactDocgen becoming reactDocgenTypeScript).
  • For that reason, I also added an extra check in 8f53c41 to ensure that the actual generated manifest can be parsed by the design-system-mcp package logic. This way, if the manifest shape changes (e.g. in future update) in a way that would prevent the MCP from being able to access component data, the CI build will fail.

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.

Looks like the manifest validation check is failing in CI, likely because the experimentalComponentsManifest option is only enabled in production envinronment, while the CI check runs in the test environment.

Maybe we can change the check from NODE_ENV === 'production' to NODE_ENV !== 'development' ?

Copy link
Copy Markdown
Member Author

@aduth aduth May 14, 2026

Choose a reason for hiding this comment

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

Yeah I think you're right. I was a bit confused because this was working locally for me, which would somehow imply that npm run storybook:build on my computer is NODE_ENV=production. I guess that's expected when the environment variable is not set. And because it's set in CI, we'll need to target both 'production' and 'test'. At that point 'development' is basically just npm run storybook:dev, which is the one place I think it'd be more important to not generate the manifests.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Although I think in newer versions Storybook now defaults componentsManifest: true (no longer experimental, default to true). We could probably remove the option when we upgrade if we're not as concerned about preventing it being generated.


describe( 'handler', () => {
beforeEach( () => {
mockGetComponentDetail.mockReset();
} );

it( 'returns a single formatted section for a string name', async () => {
const button = fakeDetail( 'Button' );
mockGetComponentDetail.mockResolvedValueOnce( button );

const result = await handler( { name: 'Button' } );

expect( mockGetComponentDetail ).toHaveBeenCalledTimes( 1 );
expect( mockGetComponentDetail ).toHaveBeenCalledWith( 'Button' );
expect( result ).toEqual( {
content: [
{ type: 'text', text: formatComponentDetail( button ) },
],
} );
} );

it( 'joins multiple components with the section separator', async () => {
const button = fakeDetail( 'Button' );
const tabs = fakeDetail( 'Tabs' );
mockGetComponentDetail
.mockResolvedValueOnce( button )
.mockResolvedValueOnce( tabs );

const result = await handler( { name: [ 'Button', 'Tabs' ] } );

expect( mockGetComponentDetail.mock.calls ).toEqual( [
[ 'Button' ],
[ 'Tabs' ],
] );
expect( result ).toEqual( {
content: [
{
type: 'text',
text: `${ formatComponentDetail(
button
) }\n\n---\n\n${ formatComponentDetail( tabs ) }`,
},
],
} );
} );

it( 'appends a missing footer when some names are not found', async () => {
const button = fakeDetail( 'Button' );
mockGetComponentDetail
.mockResolvedValueOnce( button )
.mockResolvedValueOnce( null );

const result = await handler( { name: [ 'Button', 'Nope' ] } );

expect( result ).toEqual( {
content: [
{
type: 'text',
text: `${ formatComponentDetail(
button
) }\n\n---\n\n_No components were found for: "Nope"._`,
},
],
} );
} );

it( 'quotes and comma-joins multiple missing names in the footer', async () => {
const button = fakeDetail( 'Button' );
mockGetComponentDetail
.mockResolvedValueOnce( button )
.mockResolvedValueOnce( null )
.mockResolvedValueOnce( null );

const result = await handler( {
name: [ 'Button', 'Nope', 'AlsoNope' ],
} );

expect( result ).toEqual( {
content: [
{
type: 'text',
text: `${ formatComponentDetail(
button
) }\n\n---\n\n_No components were found for: "Nope", "AlsoNope"._`,
},
],
} );
} );

it( 'returns isError when no components are found', async () => {
mockGetComponentDetail.mockResolvedValue( null );

const result = await handler( { name: [ 'Foo', 'Bar' ] } );

expect( result ).toEqual( {
content: [
{
type: 'text',
text: 'No components were found for: "Foo", "Bar".',
},
],
isError: true,
} );
} );
} );
13 changes: 3 additions & 10 deletions packages/design-system-mcp/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
export interface ManifestComponent {
id: string;
name: string;
path: string;
description?: string;
stories?: Array< {
name: string;
snippet?: string;
description?: string;
} >;
import type { ComponentManifest } from 'storybook/internal/types';

export interface ManifestComponent extends ComponentManifest {
reactDocgen?: {
description?: string;
displayName?: string;
Expand Down
2 changes: 1 addition & 1 deletion storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const config: StorybookConfig = {
],
framework: '@storybook/react-vite',
features: {
experimentalComponentsManifest: NODE_ENV === 'production',
experimentalComponentsManifest: NODE_ENV !== 'development',
},
typescript: {
reactDocgen: 'react-docgen-typescript',
Expand Down
Loading