Skip to content
This repository was archived by the owner on Sep 7, 2026. It is now read-only.
Closed
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
11 changes: 11 additions & 0 deletions .changeset/deprecated-jsdoc-tags-in-get-documentation.md
Original file line number Diff line number Diff line change
@@ -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:** <reason>` 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`).
107 changes: 107 additions & 0 deletions packages/mcp/src/utils/manifest-formatter/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
40 changes: 36 additions & 4 deletions packages/mcp/src/utils/manifest-formatter/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComponentManifest, 'jsDocTags'>,
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.
Expand Down Expand Up @@ -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('');
Expand Down Expand Up @@ -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 }));
}
Expand All @@ -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);
Expand All @@ -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) {
Expand Down
54 changes: 54 additions & 0 deletions packages/mcp/src/utils/parse-react-docgen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ describe('parseReactDocgen', () => {
}
`);
});

});

describe('parseReactDocgenTypescript', () => {
Expand Down Expand Up @@ -445,4 +446,57 @@ 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);
});

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<string, string>`; 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<string, string>,
});
expect(result.tags).toEqual({ deprecated: ['Gone.'] });
});
});
39 changes: 38 additions & 1 deletion packages/mcp/src/utils/parse-react-docgen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>;
};

// 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<ComponentDoc, 'props'>;

/**
* Normalize a docgen engine's component-level `tags` bag into `Record<string, string[]>`.
* react-docgen-typescript (and Storybook's `reactComponentMeta`) expose `tags` as a
* `Record<string, string>` — `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<string, string[]> | undefined {
if (!raw || typeof raw !== 'object') {
return undefined;
}

const out: Record<string, string[]> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
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;
Expand Down Expand Up @@ -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,
Expand All @@ -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 =>
Expand Down