Skip to content

Commit 70b5b23

Browse files
authored
MCP: Support reactComponentMeta manifests (#206)
1 parent 380081d commit 70b5b23

File tree

6 files changed

+264
-3
lines changed

6 files changed

+264
-3
lines changed

.changeset/quiet-lions-sing.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@storybook/mcp": patch
3+
---
4+
5+
Support Storybook component manifests that use `reactComponentMeta` for React prop extraction.
6+
7+
This keeps MCP documentation output working when Storybook is configured to emit the newer
8+
`reactComponentMeta` payload instead of `reactDocgen` or `reactDocgenTypescript`.

packages/mcp/src/tools/get-documentation.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,97 @@ describe('getDocumentationTool', () => {
290290
`);
291291
});
292292

293+
it('should include props section when reactComponentMeta is present', async () => {
294+
const manifestWithReactComponentMeta = {
295+
v: 1,
296+
components: {
297+
button: {
298+
id: 'button',
299+
name: 'Button',
300+
description: 'A button component',
301+
reactComponentMeta: {
302+
displayName: 'Button',
303+
filePath: 'src/components/Button.tsx',
304+
description: '',
305+
exportName: 'Button',
306+
props: {
307+
variant: {
308+
name: 'variant',
309+
description: 'Button style variant',
310+
required: false,
311+
defaultValue: { value: '"primary"' },
312+
type: {
313+
name: 'enum',
314+
raw: '"primary" | "secondary"',
315+
},
316+
},
317+
disabled: {
318+
name: 'disabled',
319+
description: 'Disable the button',
320+
required: false,
321+
defaultValue: null,
322+
type: {
323+
name: 'boolean',
324+
},
325+
},
326+
},
327+
},
328+
},
329+
},
330+
};
331+
332+
getManifestsSpy.mockResolvedValue({
333+
componentManifest: manifestWithReactComponentMeta,
334+
});
335+
336+
const request = {
337+
jsonrpc: '2.0' as const,
338+
id: 1,
339+
method: 'tools/call',
340+
params: {
341+
name: GET_TOOL_NAME,
342+
arguments: {
343+
id: 'button',
344+
},
345+
},
346+
};
347+
348+
const mockHttpRequest = new Request('https://example.com/mcp');
349+
const response = await server.receive(request, {
350+
custom: { request: mockHttpRequest },
351+
});
352+
353+
expect(response.result).toMatchInlineSnapshot(`
354+
{
355+
"content": [
356+
{
357+
"text": "# Button
358+
359+
ID: button
360+
361+
A button component
362+
363+
## Props
364+
365+
\`\`\`
366+
export type Props = {
367+
/**
368+
Button style variant
369+
*/
370+
variant?: "primary" | "secondary" = "primary";
371+
/**
372+
Disable the button
373+
*/
374+
disabled?: boolean;
375+
}
376+
\`\`\`",
377+
"type": "text",
378+
},
379+
],
380+
}
381+
`);
382+
});
383+
293384
describe('multi-source mode', () => {
294385
const sources = [
295386
{ id: 'local', title: 'Local' },

packages/mcp/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ export const ComponentManifest = v.object({
133133
reactDocgen: v.optional(v.any()),
134134
// loose schema for react-docgen-typescript types
135135
reactDocgenTypescript: v.optional(v.any()),
136+
// loose schema for react-component-meta types
137+
reactComponentMeta: v.optional(v.any()),
136138
docs: v.optional(v.record(v.string(), Doc)),
137139
});
138140
export type ComponentManifest = v.InferOutput<typeof ComponentManifest>;

packages/mcp/src/utils/manifest-formatter/markdown.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,59 @@ describe('MarkdownFormatter - formatComponentManifest', () => {
601601
`);
602602
});
603603

604+
it('should format props from reactComponentMeta', () => {
605+
const manifest: ComponentManifest = {
606+
id: 'button',
607+
name: 'Button',
608+
path: 'src/components/Button.tsx',
609+
reactComponentMeta: {
610+
displayName: 'Button',
611+
filePath: 'src/components/Button.tsx',
612+
description: '',
613+
exportName: 'Button',
614+
props: {
615+
variant: {
616+
name: 'variant',
617+
description: 'The visual style variant',
618+
type: { name: 'enum', raw: '"primary" | "secondary"' },
619+
defaultValue: { value: '"primary"' },
620+
required: false,
621+
},
622+
onClick: {
623+
name: 'onClick',
624+
description: 'Click handler',
625+
type: { name: '(event: MouseEvent) => void' },
626+
defaultValue: null,
627+
required: true,
628+
},
629+
},
630+
},
631+
};
632+
633+
const result = formatComponentManifest(manifest);
634+
635+
expect(result).toMatchInlineSnapshot(`
636+
"# Button
637+
638+
ID: button
639+
640+
## Props
641+
642+
\`\`\`
643+
export type Props = {
644+
/**
645+
The visual style variant
646+
*/
647+
variant?: "primary" | "secondary" = "primary";
648+
/**
649+
Click handler
650+
*/
651+
onClick: (event: MouseEvent) => void;
652+
}
653+
\`\`\`"
654+
`);
655+
});
656+
604657
it('should prefer reactDocgen over reactDocgenTypescript when both are present', () => {
605658
const manifest: ComponentManifest = {
606659
id: 'button',
@@ -638,6 +691,50 @@ describe('MarkdownFormatter - formatComponentManifest', () => {
638691
expect(result).not.toContain('From react-docgen-typescript');
639692
});
640693

694+
it('should prefer reactDocgenTypescript over reactComponentMeta when both are present', () => {
695+
const manifest: ComponentManifest = {
696+
id: 'button',
697+
name: 'Button',
698+
path: 'src/components/Button.tsx',
699+
reactDocgenTypescript: {
700+
displayName: 'Button',
701+
filePath: 'src/components/Button.tsx',
702+
description: '',
703+
exportName: 'Button',
704+
methods: [],
705+
props: {
706+
label: {
707+
name: 'label',
708+
description: 'From react-docgen-typescript',
709+
type: { name: 'string' },
710+
defaultValue: null,
711+
required: true,
712+
},
713+
},
714+
},
715+
reactComponentMeta: {
716+
displayName: 'Button',
717+
filePath: 'src/components/Button.tsx',
718+
description: '',
719+
exportName: 'Button',
720+
props: {
721+
label: {
722+
name: 'label',
723+
description: 'From react-component-meta',
724+
type: { name: 'string' },
725+
defaultValue: null,
726+
required: true,
727+
},
728+
},
729+
},
730+
};
731+
732+
const result = formatComponentManifest(manifest);
733+
734+
expect(result).toContain('From react-docgen-typescript');
735+
expect(result).not.toContain('From react-component-meta');
736+
});
737+
641738
it('should limit stories when reactDocgenTypescript has props', () => {
642739
const manifest: ComponentManifest = {
643740
id: 'button',
@@ -688,6 +785,54 @@ describe('MarkdownFormatter - formatComponentManifest', () => {
688785
expect(result).toContain('- WithIcon: Button with an icon');
689786
});
690787

788+
it('should limit stories when reactComponentMeta has props', () => {
789+
const manifest: ComponentManifest = {
790+
id: 'button',
791+
name: 'Button',
792+
path: 'src/components/Button.tsx',
793+
import: 'import { Button } from "@/components";',
794+
stories: [
795+
{ name: 'Default', snippet: '<Button>Default</Button>' },
796+
{ name: 'Primary', snippet: '<Button variant="primary">Primary</Button>' },
797+
{ name: 'Secondary', snippet: '<Button variant="secondary">Secondary</Button>' },
798+
{
799+
name: 'Disabled',
800+
summary: 'Button in disabled state',
801+
snippet: '<Button disabled>Disabled</Button>',
802+
},
803+
{
804+
name: 'WithIcon',
805+
description: 'Button with an icon',
806+
snippet: '<Button icon="check">With Icon</Button>',
807+
},
808+
],
809+
reactComponentMeta: {
810+
displayName: 'Button',
811+
filePath: 'src/components/Button.tsx',
812+
description: '',
813+
exportName: 'Button',
814+
props: {
815+
variant: {
816+
name: 'variant',
817+
description: '',
818+
type: { name: 'string' },
819+
defaultValue: null,
820+
required: false,
821+
},
822+
},
823+
},
824+
};
825+
826+
const result = formatComponentManifest(manifest);
827+
828+
expect(result).toContain('### Default');
829+
expect(result).toContain('### Primary');
830+
expect(result).toContain('### Secondary');
831+
expect(result).toContain('### Other Stories');
832+
expect(result).toContain('- Disabled: Button in disabled state');
833+
expect(result).toContain('- WithIcon: Button with an icon');
834+
});
835+
691836
it('should format props with only name and type as bullet list', () => {
692837
const manifest: ComponentManifest = {
693838
id: 'button',

packages/mcp/src/utils/manifest-formatter/markdown.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { AllManifests, ComponentManifest, Doc, SourceManifests, Story } from '../../types.ts';
22
import {
3+
parseReactComponentMeta,
34
parseReactDocgen,
45
parseReactDocgenTypescript,
56
type ParsedDocgen,
@@ -61,7 +62,8 @@ function extractSummary(
6162
}
6263

6364
/**
64-
* Extract parsed docgen from a component manifest, preferring reactDocgen over reactDocgenTypescript.
65+
* Extract parsed docgen from a component manifest, preferring reactDocgen over
66+
* reactDocgenTypescript over reactComponentMeta.
6567
*/
6668
function getParsedDocgen(componentManifest: ComponentManifest): ParsedDocgen | undefined {
6769
if (componentManifest.reactDocgen) {
@@ -70,6 +72,9 @@ function getParsedDocgen(componentManifest: ComponentManifest): ParsedDocgen | u
7072
if (componentManifest.reactDocgenTypescript) {
7173
return parseReactDocgenTypescript(componentManifest.reactDocgenTypescript);
7274
}
75+
if (componentManifest.reactComponentMeta) {
76+
return parseReactComponentMeta(componentManifest.reactComponentMeta);
77+
}
7378
return undefined;
7479
}
7580

packages/mcp/src/utils/parse-react-docgen.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ export type ParsedDocgen = {
1313
>;
1414
};
1515

16+
// Storybook's `reactComponentMeta` payload is not the same full schema as
17+
// `react-docgen-typescript`'s `ComponentDoc`, but `props` has the same type shape.
18+
type ComponentDocLike = Pick<ComponentDoc, 'props'>;
19+
1620
// Serialize a react-docgen tsType into a TypeScript-like string when raw is not available
1721
function serializeTsType(tsType: PropDescriptor['tsType']): string | undefined {
1822
if (!tsType) return undefined;
@@ -93,8 +97,8 @@ export const parseReactDocgen = (reactDocgen: Documentation): ParsedDocgen => {
9397
* RDT uses flat type strings (prop.type.name / prop.type.raw) instead of react-docgen's
9498
* nested tsType structure, so no serialization is needed.
9599
*/
96-
export const parseReactDocgenTypescript = (reactDocgenTypescript: ComponentDoc): ParsedDocgen => {
97-
const props = reactDocgenTypescript.props ?? {};
100+
const parseComponentDocLike = (componentDoc: ComponentDocLike): ParsedDocgen => {
101+
const props = componentDoc.props ?? {};
98102
return {
99103
props: Object.fromEntries(
100104
Object.entries(props).map(([propName, prop]) => [
@@ -111,3 +115,9 @@ export const parseReactDocgenTypescript = (reactDocgenTypescript: ComponentDoc):
111115
),
112116
};
113117
};
118+
119+
export const parseReactDocgenTypescript = (reactDocgenTypescript: ComponentDoc): ParsedDocgen =>
120+
parseComponentDocLike(reactDocgenTypescript);
121+
122+
export const parseReactComponentMeta = (reactComponentMeta: ComponentDocLike): ParsedDocgen =>
123+
parseComponentDocLike(reactComponentMeta);

0 commit comments

Comments
 (0)