From 8f3f9c7eb1d40b3b1307b2d6a091a3edf31594af Mon Sep 17 00:00:00 2001 From: rachel cantor Date: Wed, 22 Jul 2026 18:08:10 -0400 Subject: [PATCH 1/3] test(mcp): add failing tests for @deprecated in get-documentation get-documentation drops component-level @deprecated JSDoc tags: the tag is extracted into the manifest but never rendered by formatComponentManifest. These tests assert the tag is surfaced as a callout for components and subcomponents (from both the docgen-server jsDocTags and the legacy react-docgen-typescript tags), and fail against current code (5 failing: 4 formatter, 1 parser). Ref: storybookjs/mcp#367 --- .../utils/manifest-formatter/markdown.test.ts | 107 ++++++++++++++++++ .../mcp/src/utils/parse-react-docgen.test.ts | 27 +++++ 2 files changed, 134 insertions(+) diff --git a/packages/mcp/src/utils/manifest-formatter/markdown.test.ts b/packages/mcp/src/utils/manifest-formatter/markdown.test.ts index 29e2a6f1..bed85560 100644 --- a/packages/mcp/src/utils/manifest-formatter/markdown.test.ts +++ b/packages/mcp/src/utils/manifest-formatter/markdown.test.ts @@ -70,6 +70,113 @@ describe('MarkdownFormatter - formatComponentManifest', () => { }); }); + describe('deprecation notice', () => { + it('renders @deprecated from top-level jsDocTags (docgen-server path), above the description', () => { + const manifest: ComponentManifest = { + id: 'button', + name: 'Button', + path: 'src/components/Button.tsx', + description: 'A basic button.', + jsDocTags: { deprecated: ['Use `NewButton` from `@acme/ui` instead.'] }, + }; + + const result = formatComponentManifest(manifest); + + expect(result).toContain('> **Deprecated:** Use `NewButton` from `@acme/ui` instead.'); + expect(result.indexOf('> **Deprecated:**')).toBeLessThan(result.indexOf('A basic button.')); + expect(result).toMatchInlineSnapshot(` + "# Button + + ID: button + + > **Deprecated:** Use \`NewButton\` from \`@acme/ui\` instead. + + A basic button." + `); + }); + + it('renders @deprecated from react-docgen-typescript tags (legacy path)', () => { + const manifest: ComponentManifest = { + id: 'button', + name: 'Button', + path: 'src/components/Button.tsx', + description: 'A basic button.', + reactDocgenTypescript: { + props: {}, + tags: { deprecated: 'Use `NewButton` from `@acme/ui` instead.' }, + }, + }; + + const result = formatComponentManifest(manifest); + + expect(result).toContain('> **Deprecated:** Use `NewButton` from `@acme/ui` instead.'); + }); + + it('renders a bare notice when the tag has no message', () => { + const manifest: ComponentManifest = { + id: 'button', + name: 'Button', + path: 'src/components/Button.tsx', + jsDocTags: { deprecated: [''] }, + }; + + const result = formatComponentManifest(manifest); + + expect(result).toContain('> **Deprecated**'); + expect(result).not.toContain('> **Deprecated:**'); + }); + + it('omits the notice entirely for non-deprecated components', () => { + const manifest: ComponentManifest = { + id: 'button', + name: 'Button', + path: 'src/components/Button.tsx', + description: 'A basic button.', + jsDocTags: { since: ['2.0.0'] }, + }; + + const result = formatComponentManifest(manifest); + + expect(result).not.toContain('Deprecated'); + }); + + it('omits the notice when the deprecated tag is present but empty', () => { + const manifest: ComponentManifest = { + id: 'button', + name: 'Button', + path: 'src/components/Button.tsx', + description: 'A basic button.', + jsDocTags: { deprecated: [] }, + }; + + const result = formatComponentManifest(manifest); + + expect(result).not.toContain('Deprecated'); + }); + + it('renders @deprecated for a deprecated subcomponent, under its heading', () => { + const manifest: ComponentManifest = { + id: 'combo-box', + name: 'ComboBox', + path: 'src/components/ComboBox.tsx', + subcomponents: { + Legacy: { + name: 'LegacyItem', + path: 'src/components/LegacyItem.tsx', + description: 'An old item.', + jsDocTags: { deprecated: ['Use `Item` instead.'] }, + }, + }, + }; + + const result = formatComponentManifest(manifest); + + expect(result).toContain('> **Deprecated:** Use `Item` instead.'); + expect(result.indexOf('### LegacyItem')).toBeLessThan(result.indexOf('> **Deprecated:**')); + expect(result.indexOf('> **Deprecated:**')).toBeLessThan(result.indexOf('An old item.')); + }); + }); + describe('subcomponents section', () => { it('should include subcomponent docs and props before stories', () => { const manifest: ComponentManifest = { diff --git a/packages/mcp/src/utils/parse-react-docgen.test.ts b/packages/mcp/src/utils/parse-react-docgen.test.ts index beed5f7e..40346c85 100644 --- a/packages/mcp/src/utils/parse-react-docgen.test.ts +++ b/packages/mcp/src/utils/parse-react-docgen.test.ts @@ -302,6 +302,7 @@ describe('parseReactDocgen', () => { } `); }); + }); describe('parseReactDocgenTypescript', () => { @@ -445,4 +446,30 @@ describe('parseReactDocgenTypescript', () => { } `); }); + + test('extracts component-level tags (deprecated as a string) into string arrays', () => { + const result = parseReactDocgenTypescript({ + displayName: 'OldButton', + filePath: 'src/OldButton.tsx', + description: 'A button', + methods: [], + props: {}, + tags: { deprecated: 'Use `NewButton` from `@acme/ui` instead.', since: '2.0.0' }, + }); + expect(result.tags).toEqual({ + deprecated: ['Use `NewButton` from `@acme/ui` instead.'], + since: ['2.0.0'], + }); + }); + + test('omits the tags field entirely when the engine reports none', () => { + const result = parseReactDocgenTypescript({ + displayName: 'Plain', + filePath: 'src/Plain.tsx', + description: '', + methods: [], + props: {}, + }); + expect('tags' in result).toBe(false); + }); }); From 107ca87867c3ebcdc344014bbf1634db85c7db48 Mon Sep 17 00:00:00 2001 From: rachel cantor Date: Wed, 22 Jul 2026 18:08:38 -0400 Subject: [PATCH 2/3] fix(mcp): surface component @deprecated JSDoc tags in get-documentation Render component @deprecated as a "> **Deprecated:** " callout under the component and subcomponent headings. The tag is resolved from the docgen-server manifest (top-level jsDocTags.deprecated) and recovered from react-docgen-typescript / reactComponentMeta output (the engine's tags.deprecated). An absent tag or an empty deprecated array renders nothing. Fixes storybookjs/mcp#367 --- ...recated-jsdoc-tags-in-get-documentation.md | 11 +++++ .../src/utils/manifest-formatter/markdown.ts | 40 +++++++++++++++++-- packages/mcp/src/utils/parse-react-docgen.ts | 39 +++++++++++++++++- 3 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 .changeset/deprecated-jsdoc-tags-in-get-documentation.md diff --git a/.changeset/deprecated-jsdoc-tags-in-get-documentation.md b/.changeset/deprecated-jsdoc-tags-in-get-documentation.md new file mode 100644 index 00000000..c697be66 --- /dev/null +++ b/.changeset/deprecated-jsdoc-tags-in-get-documentation.md @@ -0,0 +1,11 @@ +--- +"@storybook/mcp": patch +--- + +Surface component `@deprecated` JSDoc tags in `get-documentation` output + +Component-level JSDoc tags were extracted into the manifest but never rendered by the +markdown formatter, so `get-documentation` silently dropped `@deprecated`. It is now +shown as a `> **Deprecated:** ` callout under the component and subcomponent +headings, resolved from the docgen-server manifest (top-level `jsDocTags.deprecated`) and +from react-docgen-typescript / `reactComponentMeta` output (the engine's `tags.deprecated`). diff --git a/packages/mcp/src/utils/manifest-formatter/markdown.ts b/packages/mcp/src/utils/manifest-formatter/markdown.ts index 0fcb22f1..80ba6091 100644 --- a/packages/mcp/src/utils/manifest-formatter/markdown.ts +++ b/packages/mcp/src/utils/manifest-formatter/markdown.ts @@ -96,6 +96,31 @@ function getParsedDocgen( return undefined; } +/** + * Render the `@deprecated` notice for a component or subcomponent, or nothing when it + * is not deprecated. The tag reaches the formatter two ways: the docgen-server path on + * the manifest's top-level `jsDocTags.deprecated` (a `string[]`), and the legacy + * react-docgen-typescript path on the engine output, recovered via `parsedDocgen.tags`. + * An empty message (`@deprecated` with no text) renders a bare notice; an absent tag or + * an empty `[]` renders nothing. + */ +function formatDeprecationNotice( + manifest: Pick, + parsedDocgen: ParsedDocgen | undefined, +): string[] { + const raw = manifest.jsDocTags?.deprecated ?? parsedDocgen?.tags?.deprecated; + if (raw === undefined || raw.length === 0) { + return []; + } + // Flatten to a single line so a multi-line message can't break the blockquote. + const reason = raw + .filter(Boolean) + .join(' ') + .replace(/\s*[\r\n]+\s*/g, ' ') + .trim(); + return [reason ? `> **Deprecated:** ${reason}` : '> **Deprecated**', '']; +} + /** * Formats a story's content (description + code snippet) into markdown. * Reusable helper for both formatComponentManifest and formatStoryDocumentation. @@ -183,9 +208,14 @@ function formatSubcomponentsSection( parts.push(''); for (const [key, subcomponent] of Object.entries(subcomponents)) { + const parsedDocgen = getParsedDocgen(subcomponent); + parts.push(`### ${subcomponent.name || key}`); parts.push(''); + // Same deprecation notice as the top-level component, under the subcomponent heading. + parts.push(...formatDeprecationNotice(subcomponent, parsedDocgen)); + if (subcomponent.summary) { parts.push(subcomponent.summary); parts.push(''); @@ -213,7 +243,6 @@ function formatSubcomponentsSection( continue; } - const parsedDocgen = getParsedDocgen(subcomponent); const typeName = `${(subcomponent.name || key).replace(/\W+/g, '')}Props`; parts.push(...formatPropsSection(parsedDocgen, { title: '#### Props', typeName })); } @@ -227,12 +256,18 @@ function formatSubcomponentsSection( export function formatComponentManifest(componentManifest: ComponentManifest): string { const parts: string[] = []; + // Parse docgen data (from either engine) up front so the deprecation notice can use it. + const parsedDocgen = getParsedDocgen(componentManifest); + // Component header parts.push(`# ${componentManifest.name}`); parts.push(''); parts.push(`ID: ${componentManifest.id}`); parts.push(''); + // Deprecation notice, surfaced before the description so agents see it first. + parts.push(...formatDeprecationNotice(componentManifest, parsedDocgen)); + // Description section if (componentManifest.description) { parts.push(componentManifest.description); @@ -241,9 +276,6 @@ export function formatComponentManifest(componentManifest: ComponentManifest): s parts.push(...formatSubcomponentsSection(componentManifest.subcomponents)); - // Parse docgen data (from either engine) - const parsedDocgen = getParsedDocgen(componentManifest); - // Stories section const stories = Array.isArray(componentManifest.stories) ? componentManifest.stories : []; if (stories.length > 0) { diff --git a/packages/mcp/src/utils/parse-react-docgen.ts b/packages/mcp/src/utils/parse-react-docgen.ts index dce961f6..fe68c758 100644 --- a/packages/mcp/src/utils/parse-react-docgen.ts +++ b/packages/mcp/src/utils/parse-react-docgen.ts @@ -11,12 +11,42 @@ export type ParsedDocgen = { required?: boolean; } >; + /** + * Component-level JSDoc tags (e.g. `@deprecated`), normalized to string arrays. + * Populated from react-docgen-typescript's `ComponentDoc.tags` (and Storybook's + * `reactComponentMeta`), which is where the legacy path carries `@deprecated`. The + * docgen-server path carries these on the manifest's top-level `jsDocTags` instead. + */ + tags?: Record; }; // Storybook's `reactComponentMeta` payload is not the same full schema as // `react-docgen-typescript`'s `ComponentDoc`, but `props` has the same type shape. type ComponentDocLike = Pick; +/** + * Normalize a docgen engine's component-level `tags` bag into `Record`. + * react-docgen-typescript (and Storybook's `reactComponentMeta`) expose `tags` as a + * `Record` — `deprecated` is a single string, `''` when the tag has no + * message. Non-string values are ignored. Returns `undefined` when there are no string + * tags so callers can omit the field entirely (keeping `ParsedDocgen` byte-identical when + * nothing is tagged). + */ +function normalizeTags(raw: unknown): Record | undefined { + if (!raw || typeof raw !== 'object') { + return undefined; + } + + const out: Record = {}; + for (const [key, value] of Object.entries(raw as Record)) { + if (typeof value === 'string') { + out[key] = [value]; + } + } + + return Object.keys(out).length > 0 ? out : undefined; +} + // Serialize a react-docgen tsType into a TypeScript-like string when raw is not available function serializeTsType(tsType: PropDescriptor['tsType']): string | undefined { if (!tsType) return undefined; @@ -99,7 +129,7 @@ export const parseReactDocgen = (reactDocgen: Documentation): ParsedDocgen => { */ const parseComponentDocLike = (componentDoc: ComponentDocLike): ParsedDocgen => { const props = componentDoc.props ?? {}; - return { + const parsed: ParsedDocgen = { props: Object.fromEntries( Object.entries(props).map(([propName, prop]) => [ propName, @@ -114,6 +144,13 @@ const parseComponentDocLike = (componentDoc: ComponentDocLike): ParsedDocgen => ]), ), }; + // RDT (and Storybook's reactComponentMeta) expose component-level JSDoc tags on + // `.tags` — the only place the legacy path carries `@deprecated`. + const tags = normalizeTags((componentDoc as { tags?: unknown }).tags); + if (tags) { + parsed.tags = tags; + } + return parsed; }; export const parseReactDocgenTypescript = (reactDocgenTypescript: ComponentDoc): ParsedDocgen => From e387e10f6e66ad4d471cee1f9e83941a9dbceb6d Mon Sep 17 00:00:00 2001 From: rachel cantor Date: Fri, 24 Jul 2026 13:09:50 -0400 Subject: [PATCH 3/3] test(mcp): cover normalizeTags empty-bag and non-string tag guards Codecov flagged two partial branches in normalizeTags: the empty-`out` path (a tags object with no string values) and the branch that skips a non-string tag value. Both are documented defensive guards that no test exercised. Add cases for a present-but-empty tags bag and a bag mixing a string and a non-string value. Claude-Session: https://claude.ai/code/session_01LRFizy8RJNrVzYDfkxWGmV --- .../mcp/src/utils/parse-react-docgen.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/mcp/src/utils/parse-react-docgen.test.ts b/packages/mcp/src/utils/parse-react-docgen.test.ts index 40346c85..eb4ac904 100644 --- a/packages/mcp/src/utils/parse-react-docgen.test.ts +++ b/packages/mcp/src/utils/parse-react-docgen.test.ts @@ -472,4 +472,31 @@ describe('parseReactDocgenTypescript', () => { }); expect('tags' in result).toBe(false); }); + + test('omits the tags field when the tags bag is present but empty', () => { + const result = parseReactDocgenTypescript({ + displayName: 'Empty', + filePath: 'src/Empty.tsx', + description: '', + methods: [], + props: {}, + tags: {}, + }); + expect('tags' in result).toBe(false); + }); + + test('ignores non-string tag values and keeps the string ones', () => { + const result = parseReactDocgenTypescript({ + displayName: 'Mixed', + filePath: 'src/Mixed.tsx', + description: '', + methods: [], + props: {}, + // The engine's `tags` is typed `Record`; a non-string + // value can only arrive from malformed docgen output, which is why + // `normalizeTags` guards against it. Cast to simulate that. + tags: { deprecated: 'Gone.', count: 3 } as unknown as Record, + }); + expect(result.tags).toEqual({ deprecated: ['Gone.'] }); + }); });