{{DOCS_NOTICE}}
diff --git a/packages/addon-mcp/src/tools/is-manifest-available.test.ts b/packages/addon-mcp/src/tools/is-manifest-available.test.ts
index 17718897..da6c1eb8 100644
--- a/packages/addon-mcp/src/tools/is-manifest-available.test.ts
+++ b/packages/addon-mcp/src/tools/is-manifest-available.test.ts
@@ -4,11 +4,11 @@ import type { Options } from 'storybook/internal/types';
function createMockOptions({
featureFlag = false,
- hasGenerator = false,
+ hasManifests = false,
hasFeaturesObject = true,
}: {
featureFlag?: boolean;
- hasGenerator?: boolean;
+ hasManifests?: boolean;
hasFeaturesObject?: boolean;
} = {}): Options {
return {
@@ -19,8 +19,8 @@ function createMockOptions({
? { experimentalComponentsManifest: featureFlag }
: {};
}
- if (key === 'experimental_componentManifestGenerator') {
- return hasGenerator ? vi.fn() : undefined;
+ if (key === 'experimental_manifests') {
+ return hasManifests ? vi.fn() : undefined;
}
return undefined;
}),
@@ -32,32 +32,32 @@ describe('getManifestStatus', () => {
it.each([
{
description: 'both feature flag and generator are present',
- options: { featureFlag: true, hasGenerator: true },
- expected: { available: true, hasGenerator: true, hasFeatureFlag: true },
+ options: { featureFlag: true, hasManifests: true },
+ expected: { available: true, hasManifests: true, hasFeatureFlag: true },
},
{
description: 'missing generator (unsupported framework)',
- options: { featureFlag: true, hasGenerator: false },
- expected: { available: false, hasGenerator: false, hasFeatureFlag: true },
+ options: { featureFlag: true, hasManifests: false },
+ expected: { available: false, hasManifests: false, hasFeatureFlag: true },
},
{
description: 'missing feature flag',
- options: { featureFlag: false, hasGenerator: true },
- expected: { available: false, hasGenerator: true, hasFeatureFlag: false },
+ options: { featureFlag: false, hasManifests: true },
+ expected: { available: false, hasManifests: true, hasFeatureFlag: false },
},
{
description: 'both are missing',
- options: { featureFlag: false, hasGenerator: false },
+ options: { featureFlag: false, hasManifests: false },
expected: {
available: false,
- hasGenerator: false,
+ hasManifests: false,
hasFeatureFlag: false,
},
},
{
description: 'features object is missing the flag',
- options: { hasGenerator: true, hasFeaturesObject: false },
- expected: { available: false, hasGenerator: true, hasFeatureFlag: false },
+ options: { hasManifests: true, hasFeaturesObject: false },
+ expected: { available: false, hasManifests: true, hasFeatureFlag: false },
},
])(
'should return correct status when $description',
diff --git a/packages/addon-mcp/src/tools/is-manifest-available.ts b/packages/addon-mcp/src/tools/is-manifest-available.ts
index b33b4e51..b7ad952c 100644
--- a/packages/addon-mcp/src/tools/is-manifest-available.ts
+++ b/packages/addon-mcp/src/tools/is-manifest-available.ts
@@ -2,24 +2,26 @@ import type { Options } from 'storybook/internal/types';
export type ManifestStatus = {
available: boolean;
- hasGenerator: boolean;
+ hasManifests: boolean;
hasFeatureFlag: boolean;
};
export const getManifestStatus = async (
options: Options,
): Promise => {
- const [features, componentManifestGenerator] = await Promise.all([
+ const [features, manifests] = await Promise.all([
options.presets.apply('features') as any,
- options.presets.apply('experimental_componentManifestGenerator'),
+ options.presets.apply('experimental_manifests', undefined, {
+ manifestEntries: [],
+ }),
]);
- const hasGenerator = !!componentManifestGenerator;
+ const hasManifests = !!manifests;
const hasFeatureFlag = !!features?.experimentalComponentsManifest;
return {
- available: hasFeatureFlag && hasGenerator,
- hasGenerator,
+ available: hasFeatureFlag && hasManifests,
+ hasManifests,
hasFeatureFlag,
};
};
diff --git a/packages/mcp/bin.test.ts b/packages/mcp/bin.test.ts
index 731bb1a9..043b9dce 100644
--- a/packages/mcp/bin.test.ts
+++ b/packages/mcp/bin.test.ts
@@ -70,12 +70,9 @@ describe('bin.ts stdio MCP server', () => {
beforeAll(() => {
const currentDir = dirname(fileURLToPath(import.meta.url));
const binPath = resolve(currentDir, './bin.ts');
- const fixturePath = resolve(
- currentDir,
- './fixtures/full-manifest.fixture.json',
- );
+ const fixturePath = resolve(currentDir, './fixtures/default');
- const proc = x('node', [binPath, '--manifestPath', fixturePath]);
+ const proc = x('node', [binPath, '--manifestsDir', fixturePath]);
child = proc.process as ChildProcess;
@@ -148,23 +145,23 @@ describe('bin.ts stdio MCP server', () => {
result: {
tools: expect.arrayContaining([
expect.objectContaining({
- name: 'list-all-components',
+ name: 'list-all-documentation',
}),
expect.objectContaining({
- name: 'get-component-documentation',
+ name: 'get-documentation',
}),
]),
},
});
}, 15000);
- it('should execute list-all-components tool', async () => {
+ it('should execute list-all-documentation tool', async () => {
const request = {
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: {
- name: 'list-all-components',
+ name: 'list-all-documentation',
arguments: {},
},
};
diff --git a/packages/mcp/bin.ts b/packages/mcp/bin.ts
index abfe829a..7fa31623 100644
--- a/packages/mcp/bin.ts
+++ b/packages/mcp/bin.ts
@@ -1,14 +1,14 @@
/**
* This is a way to start the @storybook/mcp server as a stdio MCP server, which is sometimes easier for testing.
* You can run it like this:
- * node bin.ts --manifestPath ./path/to/manifest.json --format markdown
+ * node bin.ts --manifestsDir ./path/to/manifests/dir/ --format markdown
*
* Or when configuring it as an MCP server:
* {
* "storybook-mcp": {
* "type": "stdio",
* "command": "node",
- * "args": ["bin.ts", "--manifestPath", "./path/to/manifest.json", "--format", "markdown"]
+ * "args": ["bin.ts", "--manifestsDir", "./path/to/manifests/dir/", "--format", "markdown"]
* }
* }
*/
@@ -16,11 +16,12 @@ import { McpServer } from 'tmcp';
import { ValibotJsonSchemaAdapter } from '@tmcp/adapter-valibot';
import { StdioTransport } from '@tmcp/transport-stdio';
import pkgJson from './package.json' with { type: 'json' };
-import { addListAllComponentsTool } from './src/tools/list-all-components.ts';
-import { addGetComponentDocumentationTool } from './src/tools/get-component-documentation.ts';
+import { addListAllDocumentationTool } from './src/tools/list-all-documentation.ts';
+import { addGetDocumentationTool } from './src/tools/get-documentation.ts';
import type { StorybookContext, OutputFormat } from './src/types.ts';
import { parseArgs } from 'node:util';
import * as fs from 'node:fs/promises';
+import { basename } from 'node:path';
const adapter = new ValibotJsonSchemaAdapter();
const server = new McpServer(
@@ -37,15 +38,15 @@ const server = new McpServer(
},
).withContext();
-await addListAllComponentsTool(server);
-await addGetComponentDocumentationTool(server);
+await addListAllDocumentationTool(server);
+await addGetDocumentationTool(server);
const transport = new StdioTransport(server);
const args = parseArgs({
options: {
- manifestPath: {
+ manifestsDir: {
type: 'string',
- default: './fixtures/full-manifest.fixture.json',
+ default: './fixtures/default',
},
format: {
type: 'string',
@@ -58,15 +59,17 @@ const format = args.values.format as OutputFormat;
transport.listen({
format,
- manifestProvider: async () => {
- const { manifestPath } = args.values;
+ manifestProvider: async (_request, path) => {
+ const { manifestsDir } = args.values;
+ const fullPath = `${manifestsDir}/${basename(path)}`;
+
if (
- manifestPath.startsWith('http://') ||
- manifestPath.startsWith('https://')
+ manifestsDir.startsWith('http://') ||
+ manifestsDir.startsWith('https://')
) {
- const res = await fetch(manifestPath);
+ const res = await fetch(fullPath);
return await res.text();
}
- return await fs.readFile(manifestPath, 'utf-8');
+ return await fs.readFile(fullPath, 'utf-8');
},
});
diff --git a/packages/mcp/fixtures/default/components.json b/packages/mcp/fixtures/default/components.json
new file mode 100644
index 00000000..b2e02ba6
--- /dev/null
+++ b/packages/mcp/fixtures/default/components.json
@@ -0,0 +1,524 @@
+{
+ "v": 1,
+ "components": {
+ "button": {
+ "id": "button",
+ "path": "src/components/Button.tsx",
+ "name": "Button",
+ "description": "A versatile button component that supports multiple variants, sizes, and states.\n\nThe Button component is a fundamental building block for user interactions. It can be styled as primary, secondary, or tertiary actions, and supports disabled and loading states.\n\n## Usage\n\nButtons should be used for actions that affect the current page or trigger operations. For navigation, consider using a Link component instead.",
+ "summary": "A versatile button component for user interactions",
+ "import": "import { Button } from '@storybook/design-system';",
+ "reactDocgen": {
+ "props": {
+ "variant": {
+ "description": "The visual style variant of the button",
+ "required": false,
+ "tsType": {
+ "name": "union",
+ "raw": "\"primary\" | \"secondary\" | \"tertiary\" | \"danger\"",
+ "elements": [
+ { "name": "literal", "value": "\"primary\"" },
+ { "name": "literal", "value": "\"secondary\"" },
+ { "name": "literal", "value": "\"tertiary\"" },
+ { "name": "literal", "value": "\"danger\"" }
+ ]
+ },
+ "defaultValue": { "value": "\"primary\"", "computed": false }
+ },
+ "size": {
+ "description": "The size of the button",
+ "required": false,
+ "tsType": {
+ "name": "union",
+ "raw": "\"small\" | \"medium\" | \"large\"",
+ "elements": [
+ { "name": "literal", "value": "\"small\"" },
+ { "name": "literal", "value": "\"medium\"" },
+ { "name": "literal", "value": "\"large\"" }
+ ]
+ },
+ "defaultValue": { "value": "\"medium\"", "computed": false }
+ },
+ "disabled": {
+ "description": "Whether the button is disabled",
+ "required": false,
+ "tsType": { "name": "boolean" },
+ "defaultValue": { "value": "false", "computed": false }
+ },
+ "loading": {
+ "description": "Whether the button is in a loading state",
+ "required": false,
+ "tsType": { "name": "boolean" },
+ "defaultValue": { "value": "false", "computed": false }
+ },
+ "fullWidth": {
+ "description": "Whether the button should take up the full width of its container",
+ "required": false,
+ "tsType": { "name": "boolean" },
+ "defaultValue": { "value": "false", "computed": false }
+ },
+ "onClick": {
+ "description": "Callback function when the button is clicked",
+ "required": false,
+ "tsType": {
+ "name": "signature",
+ "type": "function",
+ "signature": {
+ "arguments": [
+ { "name": "event", "type": { "name": "MouseEvent" } }
+ ],
+ "return": { "name": "void" }
+ }
+ }
+ },
+ "children": {
+ "description": "The content of the button",
+ "required": true,
+ "tsType": { "name": "ReactNode" }
+ }
+ }
+ },
+ "stories": [
+ {
+ "id": "button--primary",
+ "name": "Primary",
+ "description": "The primary button variant is used for the main call-to-action on a page. It has the highest visual prominence and should be used sparingly to guide users toward the most important action.\n\n## Best Practices\n\n- Use only one primary button per section\n- Keep button text concise and action-oriented\n- Ensure sufficient contrast for accessibility",
+ "summary": "Primary button with high visual prominence",
+ "import": "import { Button } from '@storybook/design-system';",
+ "jsDocTag": [
+ {
+ "key": "example",
+ "value": "Primary Button"
+ }
+ ],
+ "snippet": "const Primary = () => "
+ },
+ {
+ "id": "button--secondary",
+ "name": "Secondary",
+ "description": "The secondary button variant is used for secondary actions that are still important but not the primary focus of the page.\n\nSecondary buttons have less visual weight than primary buttons and can be used multiple times on a page.",
+ "summary": "Secondary button for supporting actions",
+ "import": "import { Button } from '@storybook/design-system';",
+ "jsDocTag": [
+ {
+ "key": "example",
+ "value": "Secondary Button"
+ }
+ ],
+ "snippet": "const Secondary = () => "
+ },
+ {
+ "id": "button--with-sizes",
+ "name": "WithSizes",
+ "description": "Buttons are available in three sizes: small, medium (default), and large.\n\nChoose the appropriate size based on the context and hierarchy of actions. Larger buttons are more prominent and easier to tap on mobile devices.",
+ "summary": "Button size variations",
+ "import": "import { Button } from '@storybook/design-system';",
+ "jsDocTag": [
+ {
+ "key": "example",
+ "value": "Button Sizes"
+ }
+ ],
+ "snippet": "const WithSizes = () => (\n <>\n \n \n \n >\n)"
+ },
+ {
+ "id": "button--loading",
+ "name": "Loading",
+ "description": "The loading state provides visual feedback when an async operation is in progress.\n\nWhen loading is true, the button displays a spinner and is automatically disabled to prevent multiple submissions. The button text remains visible to maintain layout stability.",
+ "summary": "Button in loading state during async operations",
+ "import": "import { Button } from '@storybook/design-system';",
+ "jsDocTag": [
+ {
+ "key": "example",
+ "value": "Loading Button"
+ }
+ ],
+ "snippet": "const Loading = () => "
+ },
+ {
+ "id": "button--danger",
+ "name": "Danger",
+ "description": "The danger variant is used for destructive actions that cannot be easily undone, such as deleting data or canceling subscriptions.\n\nUse this variant to draw attention to the serious nature of the action. Consider adding a confirmation dialog for critical operations.",
+ "summary": "Danger button for destructive actions",
+ "import": "import { Button } from '@storybook/design-system';",
+ "jsDocTag": [
+ {
+ "key": "example",
+ "value": "Danger Button"
+ },
+ {
+ "key": "warning",
+ "value": "Use with caution for destructive actions"
+ }
+ ],
+ "snippet": "const Danger = () => "
+ }
+ ],
+ "jsDocTag": [
+ {
+ "key": "summary",
+ "value": "A versatile button component for user interactions"
+ },
+ {
+ "key": "since",
+ "value": "1.0.0"
+ },
+ {
+ "key": "component",
+ "value": "Button"
+ }
+ ]
+ },
+ "card": {
+ "id": "card",
+ "path": "src/components/Card.tsx",
+ "name": "Card",
+ "description": "A flexible container component for grouping related content with optional header, footer, and action areas.\n\nThe Card component provides a consistent way to present information in a contained, elevated surface. It's commonly used for displaying articles, products, user profiles, or any grouped content that benefits from visual separation.\n\n## Design Principles\n\n- Cards should contain a single subject or action\n- Maintain consistent padding and spacing\n- Use elevation to indicate interactive vs static cards\n- Keep content hierarchy clear with proper use of typography",
+ "summary": "A flexible container component for grouping related content",
+ "import": "import { Card } from '@storybook/design-system';",
+ "reactDocgen": {
+ "props": {
+ "variant": {
+ "description": "The visual style variant of the card",
+ "required": false,
+ "tsType": {
+ "name": "union",
+ "raw": "\"elevated\" | \"outlined\" | \"flat\"",
+ "elements": [
+ { "name": "literal", "value": "\"elevated\"" },
+ { "name": "literal", "value": "\"outlined\"" },
+ { "name": "literal", "value": "\"flat\"" }
+ ]
+ },
+ "defaultValue": { "value": "\"elevated\"", "computed": false }
+ },
+ "padding": {
+ "description": "The amount of internal padding",
+ "required": false,
+ "tsType": {
+ "name": "union",
+ "raw": "\"none\" | \"small\" | \"medium\" | \"large\"",
+ "elements": [
+ { "name": "literal", "value": "\"none\"" },
+ { "name": "literal", "value": "\"small\"" },
+ { "name": "literal", "value": "\"medium\"" },
+ { "name": "literal", "value": "\"large\"" }
+ ]
+ },
+ "defaultValue": { "value": "\"medium\"", "computed": false }
+ },
+ "clickable": {
+ "description": "Whether the entire card is clickable/interactive",
+ "required": false,
+ "tsType": { "name": "boolean" },
+ "defaultValue": { "value": "false", "computed": false }
+ },
+ "header": {
+ "description": "Content to display in the card header",
+ "required": false,
+ "tsType": { "name": "ReactNode" }
+ },
+ "footer": {
+ "description": "Content to display in the card footer",
+ "required": false,
+ "tsType": { "name": "ReactNode" }
+ },
+ "children": {
+ "description": "The main content of the card",
+ "required": true,
+ "tsType": { "name": "ReactNode" }
+ },
+ "onClick": {
+ "description": "Callback function when the card is clicked (requires clickable=true)",
+ "required": false,
+ "tsType": {
+ "name": "signature",
+ "type": "function",
+ "signature": {
+ "arguments": [
+ { "name": "event", "type": { "name": "MouseEvent" } }
+ ],
+ "return": { "name": "void" }
+ }
+ }
+ }
+ }
+ },
+ "stories": [
+ {
+ "id": "card--basic",
+ "name": "Basic",
+ "description": "A basic card with just content.\n\nThe default elevated variant provides subtle depth through shadow, making the card appear to float above the page. This is ideal for creating visual hierarchy and grouping related information.",
+ "summary": "Basic card with content only",
+ "import": "import { Card } from '@storybook/design-system';",
+ "jsDocTag": [
+ {
+ "key": "example",
+ "value": "Basic Card"
+ }
+ ],
+ "snippet": "const Basic = () => (\n \n
Card Title
\n
This is some card content that provides information to the user.
\n \n)"
+ },
+ {
+ "id": "card--with-header-and-footer",
+ "name": "WithHeaderAndFooter",
+ "description": "A card with distinct header and footer sections.\n\nHeaders typically contain titles, subtitles, or avatars. Footers often contain actions like buttons or metadata like timestamps. The header and footer are visually separated from the main content area.",
+ "summary": "Card with header and footer sections",
+ "import": "import { Card } from '@storybook/design-system';",
+ "jsDocTag": [
+ {
+ "key": "example",
+ "value": "Card with Header and Footer"
+ }
+ ],
+ "snippet": "const WithHeaderAndFooter = () => (\n Article Title}\n footer={}\n >\n
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
\n \n)"
+ },
+ {
+ "id": "card--clickable",
+ "name": "Clickable",
+ "description": "An interactive card that responds to clicks.\n\nClickable cards add hover effects and cursor changes to indicate interactivity. This pattern is useful for navigation cards, product cards, or any scenario where the entire card acts as a single interactive element.\n\n## Accessibility\n\nClickable cards are rendered as buttons with proper keyboard support and ARIA attributes.",
+ "summary": "Interactive clickable card",
+ "import": "import { Card } from '@storybook/design-system';",
+ "jsDocTag": [
+ {
+ "key": "example",
+ "value": "Clickable Card"
+ }
+ ],
+ "snippet": "const Clickable = () => (\n alert('Card clicked!')}>\n
Product Name
\n
Click anywhere on this card to view details.
\n \n)"
+ },
+ {
+ "id": "card--variants",
+ "name": "Variants",
+ "description": "Different visual variants of the card component.\n\n- **Elevated**: Default variant with shadow for depth\n- **Outlined**: Border-only variant without shadow\n- **Flat**: No border or shadow, minimal visual separation\n\nChoose variants based on your design system and the level of emphasis needed.",
+ "summary": "Card visual variants (elevated, outlined, flat)",
+ "import": "import { Card } from '@storybook/design-system';",
+ "jsDocTag": [
+ {
+ "key": "example",
+ "value": "Card Variants"
+ }
+ ],
+ "snippet": "const Variants = () => (\n <>\n \n
Elevated card with shadow
\n \n \n
Outlined card with border
\n \n \n
Flat card without border or shadow
\n \n >\n)"
+ },
+ {
+ "id": "card--user-profile",
+ "name": "UserProfile",
+ "description": "A real-world example of a user profile card.\n\nThis example demonstrates how to compose the Card component with other design system components to create a complete, functional UI element. It includes an avatar, user information, stats, and action buttons.",
+ "summary": "Complete user profile card example",
+ "import": "import { Card, Avatar, Button } from '@storybook/design-system';",
+ "jsDocTag": [
+ {
+ "key": "example",
+ "value": "User Profile Card"
+ },
+ {
+ "key": "composition",
+ "value": "Uses Avatar and Button components"
+ }
+ ],
+ "snippet": "const UserProfile = () => (\n \n \n
\n
Jane Doe
\n
Senior Developer
\n
\n \n }\n footer={\n
\n \n \n
\n }\n >\n
\n
1.2K Followers
\n
342 Following
\n
89 Posts
\n
\n \n)"
+ }
+ ],
+ "jsDocTag": [
+ {
+ "key": "summary",
+ "value": "A flexible container component for grouping related content"
+ },
+ {
+ "key": "since",
+ "value": "1.0.0"
+ },
+ {
+ "key": "component",
+ "value": "Card"
+ },
+ {
+ "key": "pattern",
+ "value": "Container"
+ }
+ ]
+ },
+ "input": {
+ "id": "input",
+ "path": "src/components/Input.tsx",
+ "name": "Input",
+ "description": "A flexible text input component that supports various input types, validation states, and accessibility features.\n\nThe Input component is a foundational form element that wraps the native HTML input with consistent styling and behavior. It includes support for labels, error messages, helper text, and different visual states.\n\n## Accessibility\n\nThe Input component automatically manages ARIA attributes for labels, descriptions, and error messages to ensure screen reader compatibility.",
+ "summary": "A flexible text input component with validation support",
+ "import": "import { Input } from '@storybook/design-system';",
+ "reactDocgen": {
+ "props": {
+ "type": {
+ "description": "The type of input field",
+ "required": false,
+ "tsType": {
+ "name": "union",
+ "raw": "\"text\" | \"email\" | \"password\" | \"number\" | \"tel\" | \"url\"",
+ "elements": [
+ { "name": "literal", "value": "\"text\"" },
+ { "name": "literal", "value": "\"email\"" },
+ { "name": "literal", "value": "\"password\"" },
+ { "name": "literal", "value": "\"number\"" },
+ { "name": "literal", "value": "\"tel\"" },
+ { "name": "literal", "value": "\"url\"" }
+ ]
+ },
+ "defaultValue": { "value": "\"text\"", "computed": false }
+ },
+ "label": {
+ "description": "The label text for the input",
+ "required": false,
+ "tsType": { "name": "string" }
+ },
+ "placeholder": {
+ "description": "Placeholder text shown when the input is empty",
+ "required": false,
+ "tsType": { "name": "string" }
+ },
+ "value": {
+ "description": "The controlled value of the input",
+ "required": false,
+ "tsType": { "name": "string" }
+ },
+ "defaultValue": {
+ "description": "The initial value for an uncontrolled input",
+ "required": false,
+ "tsType": { "name": "string" }
+ },
+ "disabled": {
+ "description": "Whether the input is disabled",
+ "required": false,
+ "tsType": { "name": "boolean" },
+ "defaultValue": { "value": "false", "computed": false }
+ },
+ "required": {
+ "description": "Whether the input is required",
+ "required": false,
+ "tsType": { "name": "boolean" },
+ "defaultValue": { "value": "false", "computed": false }
+ },
+ "error": {
+ "description": "Error message to display below the input",
+ "required": false,
+ "tsType": { "name": "string" }
+ },
+ "helperText": {
+ "description": "Helper text to display below the input",
+ "required": false,
+ "tsType": { "name": "string" }
+ },
+ "onChange": {
+ "description": "Callback function when the input value changes",
+ "required": false,
+ "tsType": {
+ "name": "signature",
+ "type": "function",
+ "signature": {
+ "arguments": [
+ {
+ "name": "event",
+ "type": {
+ "name": "ChangeEvent",
+ "elements": [{ "name": "HTMLInputElement" }]
+ }
+ }
+ ],
+ "return": { "name": "void" }
+ }
+ }
+ }
+ }
+ },
+ "stories": [
+ {
+ "id": "input--basic",
+ "name": "Basic",
+ "description": "A basic text input with a label.\n\nThis is the most common use case for the Input component. Always include a label for accessibility, even if it's visually hidden in your design.",
+ "summary": "Basic text input with label",
+ "import": "import { Input } from '@storybook/design-system';",
+ "jsDocTag": [
+ {
+ "key": "example",
+ "value": "Basic Input"
+ }
+ ],
+ "snippet": "const Basic = () => "
+ },
+ {
+ "id": "input--with-error",
+ "name": "WithError",
+ "description": "An input displaying an error state with an error message.\n\nError messages should be clear, concise, and provide actionable guidance to help users fix the issue. The input border and message text are styled in red to indicate the error state.",
+ "summary": "Input with validation error",
+ "import": "import { Input } from '@storybook/design-system';",
+ "jsDocTag": [
+ {
+ "key": "example",
+ "value": "Input with Error"
+ }
+ ],
+ "snippet": "const WithError = () => "
+ },
+ {
+ "id": "input--with-helper-text",
+ "name": "WithHelperText",
+ "description": "An input with helper text providing additional context or instructions.\n\nHelper text appears below the input and provides guidance without being an error. Use it to clarify format expectations, character limits, or provide helpful hints.",
+ "summary": "Input with helper text for guidance",
+ "import": "import { Input } from '@storybook/design-system';",
+ "jsDocTag": [
+ {
+ "key": "example",
+ "value": "Input with Helper Text"
+ }
+ ],
+ "snippet": "const WithHelperText = () => "
+ },
+ {
+ "id": "input--types",
+ "name": "Types",
+ "description": "Different input types for various data formats.\n\nUsing the correct input type improves the user experience by showing appropriate mobile keyboards and enabling browser validation features.",
+ "summary": "Various input types (email, tel, url, number)",
+ "import": "import { Input } from '@storybook/design-system';",
+ "jsDocTag": [
+ {
+ "key": "example",
+ "value": "Input Types"
+ }
+ ],
+ "snippet": "const Types = () => (\n <>\n \n \n \n \n >\n)"
+ },
+ {
+ "id": "input--disabled",
+ "name": "Disabled",
+ "description": "A disabled input that cannot be interacted with.\n\nDisabled inputs are useful for displaying non-editable data in forms or for inputs that become available only after certain conditions are met.",
+ "summary": "Disabled input state",
+ "import": "import { Input } from '@storybook/design-system';",
+ "jsDocTag": [
+ {
+ "key": "example",
+ "value": "Disabled Input"
+ }
+ ],
+ "snippet": "const Disabled = () => "
+ }
+ ],
+ "jsDocTag": [
+ {
+ "key": "summary",
+ "value": "A flexible text input component with validation support"
+ },
+ {
+ "key": "since",
+ "value": "1.0.0"
+ },
+ {
+ "key": "component",
+ "value": "Input"
+ },
+ {
+ "key": "accessibility",
+ "value": "WCAG 2.1 Level AA compliant"
+ }
+ ]
+ }
+ }
+}
diff --git a/packages/mcp/fixtures/default/docs.json b/packages/mcp/fixtures/default/docs.json
new file mode 100644
index 00000000..5242d31e
--- /dev/null
+++ b/packages/mcp/fixtures/default/docs.json
@@ -0,0 +1,19 @@
+{
+ "v": 1,
+ "docs": {
+ "getting-started": {
+ "id": "getting-started",
+ "name": "Getting Started",
+ "title": "Getting Started Guide",
+ "path": "docs/getting-started.mdx",
+ "content": "# Getting Started\n\nWelcome to the component library. This guide will help you get up and running.\n\n## Installation\n\n```bash\nnpm install my-component-library\n```\n\n## Usage\n\nImport components and use them in your application."
+ },
+ "theming": {
+ "id": "theming",
+ "name": "Theming",
+ "title": "Theming and Customization",
+ "path": "docs/theming.mdx",
+ "content": "# Theming\n\nLearn how to customize the look and feel of components using our theming system.\n\n## Theme Provider\n\nWrap your app with the ThemeProvider."
+ }
+ }
+}
diff --git a/packages/mcp/fixtures/small-docs-manifest.fixture.json b/packages/mcp/fixtures/small-docs-manifest.fixture.json
new file mode 100644
index 00000000..5242d31e
--- /dev/null
+++ b/packages/mcp/fixtures/small-docs-manifest.fixture.json
@@ -0,0 +1,19 @@
+{
+ "v": 1,
+ "docs": {
+ "getting-started": {
+ "id": "getting-started",
+ "name": "Getting Started",
+ "title": "Getting Started Guide",
+ "path": "docs/getting-started.mdx",
+ "content": "# Getting Started\n\nWelcome to the component library. This guide will help you get up and running.\n\n## Installation\n\n```bash\nnpm install my-component-library\n```\n\n## Usage\n\nImport components and use them in your application."
+ },
+ "theming": {
+ "id": "theming",
+ "name": "Theming",
+ "title": "Theming and Customization",
+ "path": "docs/theming.mdx",
+ "content": "# Theming\n\nLearn how to customize the look and feel of components using our theming system.\n\n## Theme Provider\n\nWrap your app with the ThemeProvider."
+ }
+ }
+}
diff --git a/packages/mcp/serve.ts b/packages/mcp/serve.ts
index d00bee37..2f6f793b 100644
--- a/packages/mcp/serve.ts
+++ b/packages/mcp/serve.ts
@@ -3,24 +3,25 @@ import { serve } from 'srvx';
import fs from 'node:fs/promises';
import { parseArgs } from 'node:util';
import type { OutputFormat } from './src/types.ts';
+import { basename } from 'node:path';
async function serveMcp(
port: number,
- manifestPath: string,
+ manifestsDir: string,
format: OutputFormat,
) {
const storybookMcpHandler = await createStorybookMcpHandler({
format,
// Use the local fixture file via manifestProvider
- manifestProvider: async () => {
+ manifestProvider: async (_request, path) => {
if (
- manifestPath.startsWith('http://') ||
- manifestPath.startsWith('https://')
+ manifestsDir.startsWith('http://') ||
+ manifestsDir.startsWith('https://')
) {
- const res = await fetch(manifestPath);
+ const res = await fetch(`${manifestsDir}/${basename(path)}`);
return await res.text();
}
- return await fs.readFile(manifestPath, 'utf-8');
+ return await fs.readFile(`${manifestsDir}/${basename(path)}`, 'utf-8');
},
});
@@ -46,9 +47,9 @@ if (import.meta.main) {
type: 'string',
default: '13316',
},
- manifestPath: {
+ manifestsDir: {
type: 'string',
- default: './fixtures/full-manifest.fixture.json',
+ default: './fixtures/default',
},
format: {
type: 'string',
@@ -58,7 +59,7 @@ if (import.meta.main) {
});
await serveMcp(
Number(args.values.port),
- args.values.manifestPath,
+ args.values.manifestsDir,
args.values.format as OutputFormat,
);
}
diff --git a/packages/mcp/src/index.test.ts b/packages/mcp/src/index.test.ts
index c85b093d..b2d61463 100644
--- a/packages/mcp/src/index.test.ts
+++ b/packages/mcp/src/index.test.ts
@@ -3,6 +3,36 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { createStorybookMcpHandler } from './index.ts';
import smallManifestFixture from '../fixtures/small-manifest.fixture.json' with { type: 'json' };
+import smallDocsManifestFixture from '../fixtures/small-docs-manifest.fixture.json' with { type: 'json' };
+
+/**
+ * Creates a manifestProvider mock that returns component manifest for components.json
+ * and throws an error for docs.json (simulating no docs manifest available)
+ */
+function createManifestProviderMock() {
+ return vi.fn().mockImplementation((_request: Request, path: string) => {
+ if (path.includes('components.json')) {
+ return Promise.resolve(JSON.stringify(smallManifestFixture));
+ }
+ // Simulate docs.json not found
+ return Promise.reject(new Error('Not found'));
+ });
+}
+
+/**
+ * Creates a manifestProvider mock that returns both component and docs manifests
+ */
+function createManifestProviderMockWithDocs() {
+ return vi.fn().mockImplementation((_request: Request, path: string) => {
+ if (path.includes('components.json')) {
+ return Promise.resolve(JSON.stringify(smallManifestFixture));
+ }
+ if (path.includes('docs.json')) {
+ return Promise.resolve(JSON.stringify(smallDocsManifestFixture));
+ }
+ return Promise.reject(new Error('Not found'));
+ });
+}
describe('createStorybookMcpHandler', () => {
let client: Client;
@@ -59,12 +89,12 @@ describe('createStorybookMcpHandler', () => {
expect(tools.tools).toEqual(
expect.arrayContaining([
expect.objectContaining({
- name: 'list-all-components',
- title: 'List All Components',
+ name: 'list-all-documentation',
+ title: 'List All Documentation',
}),
expect.objectContaining({
- name: 'get-component-documentation',
- title: 'Get Documentation for Component',
+ name: 'get-documentation',
+ title: 'Get Documentation',
}),
]),
);
@@ -89,10 +119,8 @@ describe('createStorybookMcpHandler', () => {
);
});
- it('should use manifestProvider when calling list-all-components', async () => {
- const manifestProvider = vi
- .fn()
- .mockResolvedValue(JSON.stringify(smallManifestFixture));
+ it('should use manifestProvider when calling list-all-documentation', async () => {
+ const manifestProvider = createManifestProviderMock();
const handler = await createStorybookMcpHandler({
manifestProvider,
@@ -100,7 +128,7 @@ describe('createStorybookMcpHandler', () => {
await setupClient(handler);
const result = await client.callTool({
- name: 'list-all-components',
+ name: 'list-all-documentation',
arguments: {},
});
@@ -115,58 +143,54 @@ describe('createStorybookMcpHandler', () => {
});
});
- it('should call onListAllComponents handler when tool is invoked', async () => {
- const onListAllComponents = vi.fn();
- const manifestProvider = vi
- .fn()
- .mockResolvedValue(JSON.stringify(smallManifestFixture));
+ it('should call onListAllDocumentation handler when tool is invoked', async () => {
+ const onListAllDocumentation = vi.fn();
+ const manifestProvider = createManifestProviderMock();
const handler = await createStorybookMcpHandler({
manifestProvider,
- onListAllComponents,
+ onListAllDocumentation,
});
await setupClient(handler);
await client.callTool({
- name: 'list-all-components',
+ name: 'list-all-documentation',
arguments: {},
});
- expect(onListAllComponents).toHaveBeenCalledTimes(1);
- expect(onListAllComponents).toHaveBeenCalledWith({
+ expect(onListAllDocumentation).toHaveBeenCalledTimes(1);
+ expect(onListAllDocumentation).toHaveBeenCalledWith({
context: expect.objectContaining({
request: expect.any(Request),
}),
- manifest: smallManifestFixture,
+ manifests: { componentManifest: smallManifestFixture },
});
});
- it('should call onGetComponentDocumentation handler when tool is invoked', async () => {
- const onGetComponentDocumentation = vi.fn();
- const manifestProvider = vi
- .fn()
- .mockResolvedValue(JSON.stringify(smallManifestFixture));
+ it('should call onGetDocumentation handler when tool is invoked', async () => {
+ const onGetDocumentation = vi.fn();
+ const manifestProvider = createManifestProviderMock();
const handler = await createStorybookMcpHandler({
manifestProvider,
- onGetComponentDocumentation,
+ onGetDocumentation,
});
await setupClient(handler);
const result = await client.callTool({
- name: 'get-component-documentation',
+ name: 'get-documentation',
arguments: {
- componentId: 'button',
+ id: 'button',
},
});
- expect(onGetComponentDocumentation).toHaveBeenCalledTimes(1);
- expect(onGetComponentDocumentation).toHaveBeenCalledWith({
+ expect(onGetDocumentation).toHaveBeenCalledTimes(1);
+ expect(onGetDocumentation).toHaveBeenCalledWith({
context: expect.objectContaining({
request: expect.any(Request),
}),
- input: { componentId: 'button' },
- foundComponent: expect.objectContaining({
+ input: { id: 'button' },
+ foundDocumentation: expect.objectContaining({
id: 'button',
name: 'Button',
}),
@@ -184,7 +208,7 @@ describe('createStorybookMcpHandler', () => {
await setupClient(handler);
const result = await client.callTool({
- name: 'list-all-components',
+ name: 'list-all-documentation',
arguments: {},
});
@@ -195,38 +219,130 @@ describe('createStorybookMcpHandler', () => {
});
});
- it('should handle non-existent component ID in get-component-documentation', async () => {
- const onGetComponentDocumentation = vi.fn();
- const manifestProvider = vi
- .fn()
- .mockResolvedValue(JSON.stringify(smallManifestFixture));
+ it('should handle non-existent component ID in get-documentation', async () => {
+ const onGetDocumentation = vi.fn();
+ const manifestProvider = createManifestProviderMock();
const handler = await createStorybookMcpHandler({
manifestProvider,
- onGetComponentDocumentation,
+ onGetDocumentation,
});
await setupClient(handler);
const result = await client.callTool({
- name: 'get-component-documentation',
+ name: 'get-documentation',
arguments: {
- componentId: 'non-existent',
+ id: 'non-existent',
},
});
// Should still call the handler
- expect(onGetComponentDocumentation).toHaveBeenCalledTimes(1);
- expect(onGetComponentDocumentation).toHaveBeenCalledWith({
+ expect(onGetDocumentation).toHaveBeenCalledTimes(1);
+ expect(onGetDocumentation).toHaveBeenCalledWith({
context: expect.objectContaining({
request: expect.any(Request),
}),
- input: { componentId: 'non-existent' },
+ input: { id: 'non-existent' },
});
expect(result.content).toHaveLength(1);
expect((result.content as any)[0]).toMatchObject({
type: 'text',
- text: expect.stringContaining('Component not found'),
+ text: expect.stringContaining('not found'),
+ });
+ });
+
+ describe('with docs manifest', () => {
+ it('should return docs entries in list-all-documentation when docs manifest is available', async () => {
+ const manifestProvider = createManifestProviderMockWithDocs();
+
+ const handler = await createStorybookMcpHandler({
+ manifestProvider,
+ });
+ await setupClient(handler);
+
+ const result = await client.callTool({
+ name: 'list-all-documentation',
+ arguments: {},
+ });
+
+ expect(manifestProvider).toHaveBeenCalledWith(
+ expect.any(Request),
+ './manifests/components.json',
+ );
+ expect(manifestProvider).toHaveBeenCalledWith(
+ expect.any(Request),
+ './manifests/docs.json',
+ );
+
+ expect(result.content).toHaveLength(1);
+ const text = (result.content as any)[0].text;
+ expect(text).toContain('# Components');
+ expect(text).toContain('# Docs');
+ expect(text).toContain('Getting Started');
+ });
+
+ it('should include docs manifest in onListAllDocumentation handler', async () => {
+ const onListAllDocumentation = vi.fn();
+ const manifestProvider = createManifestProviderMockWithDocs();
+
+ const handler = await createStorybookMcpHandler({
+ manifestProvider,
+ onListAllDocumentation,
+ });
+ await setupClient(handler);
+
+ await client.callTool({
+ name: 'list-all-documentation',
+ arguments: {},
+ });
+
+ expect(onListAllDocumentation).toHaveBeenCalledTimes(1);
+ expect(onListAllDocumentation).toHaveBeenCalledWith({
+ context: expect.objectContaining({
+ request: expect.any(Request),
+ }),
+ manifests: {
+ componentManifest: smallManifestFixture,
+ docsManifest: smallDocsManifestFixture,
+ },
+ });
+ });
+
+ it('should return documentation for a docs entry', async () => {
+ const onGetDocumentation = vi.fn();
+ const manifestProvider = createManifestProviderMockWithDocs();
+
+ const handler = await createStorybookMcpHandler({
+ manifestProvider,
+ onGetDocumentation,
+ });
+ await setupClient(handler);
+
+ const result = await client.callTool({
+ name: 'get-documentation',
+ arguments: {
+ id: 'getting-started',
+ },
+ });
+
+ expect(onGetDocumentation).toHaveBeenCalledTimes(1);
+ expect(onGetDocumentation).toHaveBeenCalledWith({
+ context: expect.objectContaining({
+ request: expect.any(Request),
+ }),
+ input: { id: 'getting-started' },
+ foundDocumentation: expect.objectContaining({
+ id: 'getting-started',
+ name: 'Getting Started',
+ }),
+ });
+
+ expect(result.content).toHaveLength(1);
+ expect((result.content as any)[0]).toMatchObject({
+ type: 'text',
+ text: expect.stringContaining('Getting Started'),
+ });
});
});
});
diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts
index c81a4280..786de842 100644
--- a/packages/mcp/src/index.ts
+++ b/packages/mcp/src/index.ts
@@ -2,22 +2,25 @@ import { McpServer } from 'tmcp';
import { ValibotJsonSchemaAdapter } from '@tmcp/adapter-valibot';
import { HttpTransport } from '@tmcp/transport-http';
import pkgJson from '../package.json' with { type: 'json' };
-import { addListAllComponentsTool } from './tools/list-all-components.ts';
-import { addGetComponentDocumentationTool } from './tools/get-component-documentation.ts';
+import { addListAllDocumentationTool } from './tools/list-all-documentation.ts';
+import { addGetDocumentationTool } from './tools/get-documentation.ts';
import type { StorybookContext } from './types.ts';
// Export tools for reuse by addon-mcp
export {
- addListAllComponentsTool,
+ addListAllDocumentationTool,
LIST_TOOL_NAME,
-} from './tools/list-all-components.ts';
+} from './tools/list-all-documentation.ts';
export {
- addGetComponentDocumentationTool,
+ addGetDocumentationTool,
GET_TOOL_NAME,
-} from './tools/get-component-documentation.ts';
+} from './tools/get-documentation.ts';
// Export manifest constants
-export { MANIFEST_PATH } from './utils/get-manifest.ts';
+export {
+ COMPONENT_MANIFEST_PATH,
+ DOCS_MANIFEST_PATH,
+} from './utils/get-manifest.ts';
// Export types for reuse
export type { StorybookContext } from './types.ts';
@@ -90,8 +93,8 @@ export const createStorybookMcpHandler = async (
server.on('initialize', options.onSessionInitialize);
}
- await addListAllComponentsTool(server);
- await addGetComponentDocumentationTool(server);
+ await addListAllDocumentationTool(server);
+ await addGetDocumentationTool(server);
const transport = new HttpTransport(server, { path: null });
@@ -100,11 +103,10 @@ export const createStorybookMcpHandler = async (
request: req,
format: context?.format ?? options.format ?? 'markdown',
manifestProvider: context?.manifestProvider ?? options.manifestProvider,
- onListAllComponents:
- context?.onListAllComponents ?? options.onListAllComponents,
- onGetComponentDocumentation:
- context?.onGetComponentDocumentation ??
- options.onGetComponentDocumentation,
+ onListAllDocumentation:
+ context?.onListAllDocumentation ?? options.onListAllDocumentation,
+ onGetDocumentation:
+ context?.onGetDocumentation ?? options.onGetDocumentation,
});
}) as Handler;
};
diff --git a/packages/mcp/src/tools/get-component-documentation.test.ts b/packages/mcp/src/tools/get-component-documentation.test.ts
index c221393f..3e93e740 100644
--- a/packages/mcp/src/tools/get-component-documentation.test.ts
+++ b/packages/mcp/src/tools/get-component-documentation.test.ts
@@ -1,17 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpServer } from 'tmcp';
import { ValibotJsonSchemaAdapter } from '@tmcp/adapter-valibot';
-import {
- addGetComponentDocumentationTool,
- GET_TOOL_NAME,
-} from './get-component-documentation.ts';
+import { addGetDocumentationTool, GET_TOOL_NAME } from './get-documentation.ts';
import type { StorybookContext } from '../types.ts';
import smallManifestFixture from '../../fixtures/small-manifest.fixture.json' with { type: 'json' };
import * as getManifest from '../utils/get-manifest.ts';
-describe('getComponentDocumentationTool', () => {
+describe('getDocumentationTool', () => {
let server: McpServer;
- let getManifestSpy: any;
+ let getManifestsSpy: any;
beforeEach(async () => {
const adapter = new ValibotJsonSchemaAdapter();
@@ -43,11 +40,13 @@ describe('getComponentDocumentationTool', () => {
},
{ sessionId: 'test-session' },
);
- await addGetComponentDocumentationTool(server);
+ await addGetDocumentationTool(server);
- // Mock getManifest to return the fixture
- getManifestSpy = vi.spyOn(getManifest, 'getManifest');
- getManifestSpy.mockResolvedValue(smallManifestFixture);
+ // Mock getManifests to return the fixture
+ getManifestsSpy = vi.spyOn(getManifest, 'getManifests');
+ getManifestsSpy.mockResolvedValue({
+ componentManifest: smallManifestFixture,
+ });
});
it('should return formatted documentation for a single component', async () => {
@@ -58,7 +57,7 @@ describe('getComponentDocumentationTool', () => {
params: {
name: GET_TOOL_NAME,
arguments: {
- componentId: 'button',
+ id: 'button',
},
},
};
@@ -100,7 +99,7 @@ describe('getComponentDocumentationTool', () => {
params: {
name: GET_TOOL_NAME,
arguments: {
- componentId: 'nonexistent',
+ id: 'nonexistent',
},
},
};
@@ -114,7 +113,7 @@ describe('getComponentDocumentationTool', () => {
{
"content": [
{
- "text": "Component not found: "nonexistent". Use the list-all-components tool to see available components.",
+ "text": "Component or Docs Entry not found: "nonexistent". Use the list-all-documentation tool to see available components and documentation entries.",
"type": "text",
},
],
@@ -124,7 +123,7 @@ describe('getComponentDocumentationTool', () => {
});
it('should handle fetch errors gracefully', async () => {
- getManifestSpy.mockRejectedValue(
+ getManifestsSpy.mockRejectedValue(
new getManifest.ManifestGetError(
'Failed to fetch manifest: 404 Not Found',
'https://example.com/manifest.json',
@@ -138,7 +137,7 @@ describe('getComponentDocumentationTool', () => {
params: {
name: GET_TOOL_NAME,
arguments: {
- componentId: 'button',
+ id: 'button',
},
},
};
@@ -161,7 +160,7 @@ describe('getComponentDocumentationTool', () => {
`);
});
- it('should call onGetComponentDocumentation handler when provided', async () => {
+ it('should call onGetDocumentation handler when provided', async () => {
const handler = vi.fn();
const request = {
@@ -171,7 +170,7 @@ describe('getComponentDocumentationTool', () => {
params: {
name: GET_TOOL_NAME,
arguments: {
- componentId: 'button',
+ id: 'button',
},
},
};
@@ -181,7 +180,7 @@ describe('getComponentDocumentationTool', () => {
await server.receive(request, {
custom: {
request: mockHttpRequest,
- onGetComponentDocumentation: handler,
+ onGetDocumentation: handler,
},
});
@@ -189,10 +188,13 @@ describe('getComponentDocumentationTool', () => {
expect(handler).toHaveBeenCalledWith({
context: expect.objectContaining({
request: mockHttpRequest,
- onGetComponentDocumentation: handler,
+ onGetDocumentation: handler,
+ }),
+ input: { id: 'button' },
+ foundDocumentation: expect.objectContaining({
+ id: 'button',
+ name: 'Button',
}),
- input: { componentId: 'button' },
- foundComponent: expect.objectContaining({ id: 'button', name: 'Button' }),
});
});
@@ -232,7 +234,9 @@ describe('getComponentDocumentationTool', () => {
},
};
- getManifestSpy.mockResolvedValue(manifestWithReactDocgen);
+ getManifestsSpy.mockResolvedValue({
+ componentManifest: manifestWithReactDocgen,
+ });
const request = {
jsonrpc: '2.0' as const,
@@ -241,7 +245,7 @@ describe('getComponentDocumentationTool', () => {
params: {
name: GET_TOOL_NAME,
arguments: {
- componentId: 'button',
+ id: 'button',
},
},
};
@@ -290,7 +294,7 @@ describe('getComponentDocumentationTool', () => {
params: {
name: GET_TOOL_NAME,
arguments: {
- componentId: 'button',
+ id: 'button',
},
},
};
diff --git a/packages/mcp/src/tools/get-component-documentation.ts b/packages/mcp/src/tools/get-component-documentation.ts
deleted file mode 100644
index 198c5769..00000000
--- a/packages/mcp/src/tools/get-component-documentation.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import * as v from 'valibot';
-import type { McpServer } from 'tmcp';
-import type { StorybookContext } from '../types.ts';
-import { getManifest, errorToMCPContent } from '../utils/get-manifest.ts';
-import { formatComponentManifest } from '../utils/format-manifest.ts';
-
-export const GET_TOOL_NAME = 'get-component-documentation';
-
-const GetComponentDocumentationInput = v.object({
- componentId: v.string(),
-});
-
-type GetComponentDocumentationInput = v.InferOutput<
- typeof GetComponentDocumentationInput
->;
-
-export async function addGetComponentDocumentationTool(
- server: McpServer,
- enabled?: Parameters['tool']>[0]['enabled'],
-) {
- server.tool(
- {
- name: GET_TOOL_NAME,
- title: 'Get Documentation for Component',
- description: 'Get detailed documentation for a specific UI component',
- schema: GetComponentDocumentationInput,
- enabled,
- },
- async (input: GetComponentDocumentationInput) => {
- try {
- const manifest = await getManifest(
- server.ctx.custom?.request,
- server.ctx.custom?.manifestProvider,
- );
-
- const component = manifest.components[input.componentId];
-
- if (!component) {
- await server.ctx.custom?.onGetComponentDocumentation?.({
- context: server.ctx.custom,
- input: { componentId: input.componentId },
- });
-
- return {
- content: [
- {
- type: 'text' as const,
- text: `Component not found: "${input.componentId}". Use the list-all-components tool to see available components.`,
- },
- ],
- isError: true,
- };
- }
-
- await server.ctx.custom?.onGetComponentDocumentation?.({
- context: server.ctx.custom,
- input: { componentId: input.componentId },
- foundComponent: component,
- });
-
- const format = server.ctx.custom?.format ?? 'markdown';
- return {
- content: [
- {
- type: 'text' as const,
- text: formatComponentManifest(component, format),
- },
- ],
- };
- } catch (error) {
- return errorToMCPContent(error);
- }
- },
- );
-}
diff --git a/packages/mcp/src/tools/get-documentation.test.ts b/packages/mcp/src/tools/get-documentation.test.ts
new file mode 100644
index 00000000..313bd16f
--- /dev/null
+++ b/packages/mcp/src/tools/get-documentation.test.ts
@@ -0,0 +1,508 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { McpServer } from 'tmcp';
+import { ValibotJsonSchemaAdapter } from '@tmcp/adapter-valibot';
+import { addGetDocumentationTool, GET_TOOL_NAME } from './get-documentation.ts';
+import type { StorybookContext } from '../types.ts';
+import smallManifestFixture from '../../fixtures/small-manifest.fixture.json' with { type: 'json' };
+import smallDocsManifestFixture from '../../fixtures/small-docs-manifest.fixture.json' with { type: 'json' };
+import * as getManifest from '../utils/get-manifest.ts';
+
+describe('getDocumentationTool', () => {
+ let server: McpServer;
+ let getManifestsSpy: any;
+
+ beforeEach(async () => {
+ const adapter = new ValibotJsonSchemaAdapter();
+ server = new McpServer(
+ {
+ name: 'test-server',
+ version: '1.0.0',
+ description: 'Test server for get tool',
+ },
+ {
+ adapter,
+ capabilities: {
+ tools: { listChanged: true },
+ },
+ },
+ ).withContext();
+
+ // initialize test session
+ await server.receive(
+ {
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'initialize',
+ params: {
+ protocolVersion: '2025-06-18',
+ capabilities: {},
+ clientInfo: { name: 'test', version: '1.0.0' },
+ },
+ },
+ { sessionId: 'test-session' },
+ );
+ await addGetDocumentationTool(server);
+
+ // Mock getManifests to return the fixture
+ getManifestsSpy = vi.spyOn(getManifest, 'getManifests');
+ getManifestsSpy.mockResolvedValue({
+ componentManifest: smallManifestFixture,
+ });
+ });
+
+ it('should return formatted documentation for a single component', async () => {
+ const request = {
+ jsonrpc: '2.0' as const,
+ id: 1,
+ method: 'tools/call',
+ params: {
+ name: GET_TOOL_NAME,
+ arguments: {
+ id: 'button',
+ },
+ },
+ };
+
+ const mockHttpRequest = new Request('https://example.com/mcp');
+ const response = await server.receive(request, {
+ custom: { request: mockHttpRequest },
+ });
+
+ expect(response.result).toMatchInlineSnapshot(`
+ {
+ "content": [
+ {
+ "text": "# Button
+
+ ID: button
+
+ ## Stories
+
+ ### Primary
+
+ The primary button variant.
+
+ \`\`\`
+ const Primary = () =>
+ \`\`\`",
+ "type": "text",
+ },
+ ],
+ }
+ `);
+ });
+
+ it('should return an error when a component is not found', async () => {
+ const request = {
+ jsonrpc: '2.0' as const,
+ id: 1,
+ method: 'tools/call',
+ params: {
+ name: GET_TOOL_NAME,
+ arguments: {
+ id: 'nonexistent',
+ },
+ },
+ };
+
+ const mockHttpRequest = new Request('https://example.com/mcp');
+ const response = await server.receive(request, {
+ custom: { request: mockHttpRequest },
+ });
+
+ expect(response.result).toMatchInlineSnapshot(`
+ {
+ "content": [
+ {
+ "text": "Component or Docs Entry not found: "nonexistent". Use the list-all-documentation tool to see available components and documentation entries.",
+ "type": "text",
+ },
+ ],
+ "isError": true,
+ }
+ `);
+ });
+
+ it('should handle fetch errors gracefully', async () => {
+ getManifestsSpy.mockRejectedValue(
+ new getManifest.ManifestGetError(
+ 'Failed to fetch manifest: 404 Not Found',
+ 'https://example.com/manifest.json',
+ ),
+ );
+
+ const request = {
+ jsonrpc: '2.0' as const,
+ id: 1,
+ method: 'tools/call',
+ params: {
+ name: GET_TOOL_NAME,
+ arguments: {
+ id: 'button',
+ },
+ },
+ };
+
+ const mockHttpRequest = new Request('https://example.com/mcp');
+ const response = await server.receive(request, {
+ custom: { request: mockHttpRequest },
+ });
+
+ expect(response.result).toMatchInlineSnapshot(`
+ {
+ "content": [
+ {
+ "text": "Error getting manifest: Failed to fetch manifest: 404 Not Found",
+ "type": "text",
+ },
+ ],
+ "isError": true,
+ }
+ `);
+ });
+
+ it('should call onGetDocumentation handler when provided', async () => {
+ const handler = vi.fn();
+
+ const request = {
+ jsonrpc: '2.0' as const,
+ id: 2,
+ method: 'tools/call',
+ params: {
+ name: GET_TOOL_NAME,
+ arguments: {
+ id: 'button',
+ },
+ },
+ };
+
+ const mockHttpRequest = new Request('https://example.com/mcp');
+ // Pass the handler and request in the context for this specific request
+ await server.receive(request, {
+ custom: {
+ request: mockHttpRequest,
+ onGetDocumentation: handler,
+ },
+ });
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith({
+ context: expect.objectContaining({
+ request: mockHttpRequest,
+ onGetDocumentation: handler,
+ }),
+ input: { id: 'button' },
+ foundDocumentation: expect.objectContaining({
+ id: 'button',
+ name: 'Button',
+ }),
+ });
+ });
+
+ it('should include props section when reactDocgen is present', async () => {
+ const manifestWithReactDocgen = {
+ v: 1,
+ components: {
+ button: {
+ id: 'button',
+ name: 'Button',
+ description: 'A button component',
+ reactDocgen: {
+ props: {
+ variant: {
+ description: 'Button style variant',
+ required: false,
+ defaultValue: { value: '"primary"', computed: false },
+ tsType: {
+ name: 'union',
+ raw: '"primary" | "secondary"',
+ elements: [
+ { name: 'literal', value: '"primary"' },
+ { name: 'literal', value: '"secondary"' },
+ ],
+ },
+ },
+ disabled: {
+ description: 'Disable the button',
+ required: false,
+ tsType: {
+ name: 'boolean',
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+
+ getManifestsSpy.mockResolvedValue({
+ componentManifest: manifestWithReactDocgen,
+ });
+
+ const request = {
+ jsonrpc: '2.0' as const,
+ id: 1,
+ method: 'tools/call',
+ params: {
+ name: GET_TOOL_NAME,
+ arguments: {
+ id: 'button',
+ },
+ },
+ };
+
+ const mockHttpRequest = new Request('https://example.com/mcp');
+ const response = await server.receive(request, {
+ custom: { request: mockHttpRequest },
+ });
+
+ expect(response.result).toMatchInlineSnapshot(`
+ {
+ "content": [
+ {
+ "text": "# Button
+
+ ID: button
+
+ A button component
+
+ ## Props
+
+ \`\`\`
+ export type Props = {
+ /**
+ Button style variant
+ */
+ variant?: "primary" | "secondary" = "primary";
+ /**
+ Disable the button
+ */
+ disabled?: boolean;
+ }
+ \`\`\`",
+ "type": "text",
+ },
+ ],
+ }
+ `);
+ });
+
+ it('should format component as XML when format is "xml"', async () => {
+ const request = {
+ jsonrpc: '2.0' as const,
+ id: 1,
+ method: 'tools/call',
+ params: {
+ name: GET_TOOL_NAME,
+ arguments: {
+ id: 'button',
+ },
+ },
+ };
+
+ const mockHttpRequest = new Request('https://example.com/mcp');
+ const response = await server.receive(request, {
+ custom: { request: mockHttpRequest, format: 'xml' as const },
+ });
+
+ expect(response.result).toMatchInlineSnapshot(`
+ {
+ "content": [
+ {
+ "text": "
+ button
+ Button
+
+ Primary
+
+ The primary button variant.
+
+
+ const Primary = () =>
+
+
+ ",
+ "type": "text",
+ },
+ ],
+ }
+ `);
+ });
+
+ describe('docs manifest entries', () => {
+ beforeEach(() => {
+ getManifestsSpy.mockResolvedValue({
+ componentManifest: smallManifestFixture,
+ docsManifest: smallDocsManifestFixture,
+ });
+ });
+
+ it('should return formatted documentation for a docs entry', async () => {
+ const request = {
+ jsonrpc: '2.0' as const,
+ id: 1,
+ method: 'tools/call',
+ params: {
+ name: GET_TOOL_NAME,
+ arguments: {
+ id: 'getting-started',
+ },
+ },
+ };
+
+ const mockHttpRequest = new Request('https://example.com/mcp');
+ const response = await server.receive(request, {
+ custom: { request: mockHttpRequest },
+ });
+
+ expect(response.result).toMatchInlineSnapshot(`
+ {
+ "content": [
+ {
+ "text": "# Getting Started Guide
+
+ # Getting Started
+
+ Welcome to the component library. This guide will help you get up and running.
+
+ ## Installation
+
+ \`\`\`bash
+ npm install my-component-library
+ \`\`\`
+
+ ## Usage
+
+ Import components and use them in your application.",
+ "type": "text",
+ },
+ ],
+ }
+ `);
+ });
+
+ it('should return component documentation when id matches both component and docs entry', async () => {
+ // When an ID exists in both manifests, prefer component documentation
+ getManifestsSpy.mockResolvedValue({
+ componentManifest: smallManifestFixture,
+ docsManifest: {
+ v: 1,
+ docs: {
+ button: {
+ id: 'button',
+ name: 'Button Docs',
+ title: 'Button Documentation',
+ path: 'docs/button.mdx',
+ content: 'This is the button docs entry',
+ },
+ },
+ },
+ });
+
+ const request = {
+ jsonrpc: '2.0' as const,
+ id: 1,
+ method: 'tools/call',
+ params: {
+ name: GET_TOOL_NAME,
+ arguments: {
+ id: 'button',
+ },
+ },
+ };
+
+ const mockHttpRequest = new Request('https://example.com/mcp');
+ const response = await server.receive(request, {
+ custom: { request: mockHttpRequest },
+ });
+
+ // Should return the component, not the docs entry
+ expect((response.result as any).content[0].text).toContain('## Stories');
+ expect((response.result as any).content[0].text).toContain('Primary');
+ });
+
+ it('should format docs entry as XML when format is "xml"', async () => {
+ const request = {
+ jsonrpc: '2.0' as const,
+ id: 1,
+ method: 'tools/call',
+ params: {
+ name: GET_TOOL_NAME,
+ arguments: {
+ id: 'getting-started',
+ },
+ },
+ };
+
+ const mockHttpRequest = new Request('https://example.com/mcp');
+ const response = await server.receive(request, {
+ custom: { request: mockHttpRequest, format: 'xml' as const },
+ });
+
+ expect(response.result).toMatchInlineSnapshot(`
+ {
+ "content": [
+ {
+ "text": "
+ Getting Started Guide
+
+ # Getting Started
+
+ Welcome to the component library. This guide will help you get up and running.
+
+ ## Installation
+
+ \`\`\`bash
+ npm install my-component-library
+ \`\`\`
+
+ ## Usage
+
+ Import components and use them in your application.
+
+ ",
+ "type": "text",
+ },
+ ],
+ }
+ `);
+ });
+
+ it('should call onGetDocumentation handler with docs entry when found', async () => {
+ const handler = vi.fn();
+
+ const request = {
+ jsonrpc: '2.0' as const,
+ id: 2,
+ method: 'tools/call',
+ params: {
+ name: GET_TOOL_NAME,
+ arguments: {
+ id: 'getting-started',
+ },
+ },
+ };
+
+ const mockHttpRequest = new Request('https://example.com/mcp');
+ await server.receive(request, {
+ custom: {
+ request: mockHttpRequest,
+ onGetDocumentation: handler,
+ },
+ });
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith({
+ context: expect.objectContaining({
+ request: mockHttpRequest,
+ onGetDocumentation: handler,
+ }),
+ input: { id: 'getting-started' },
+ foundDocumentation: expect.objectContaining({
+ id: 'getting-started',
+ name: 'Getting Started',
+ }),
+ });
+ });
+ });
+});
diff --git a/packages/mcp/src/tools/get-documentation.ts b/packages/mcp/src/tools/get-documentation.ts
new file mode 100644
index 00000000..88be5267
--- /dev/null
+++ b/packages/mcp/src/tools/get-documentation.ts
@@ -0,0 +1,84 @@
+import * as v from 'valibot';
+import type { McpServer } from 'tmcp';
+import type { ComponentManifest, Doc, StorybookContext } from '../types.ts';
+import { getManifests, errorToMCPContent } from '../utils/get-manifest.ts';
+import {
+ formatComponentManifest,
+ formatDocsManifest,
+} from '../utils/format-manifest.ts';
+import { LIST_TOOL_NAME } from './list-all-documentation.ts';
+
+export const GET_TOOL_NAME = 'get-documentation';
+
+const GetDocumentationInput = v.object({
+ id: v.string(),
+});
+
+export async function addGetDocumentationTool(
+ server: McpServer,
+ enabled?: Parameters['tool']>[0]['enabled'],
+) {
+ server.tool(
+ {
+ name: GET_TOOL_NAME,
+ title: 'Get Documentation',
+ description:
+ 'Get detailed documentation for a specific UI component or docs entry',
+ schema: GetDocumentationInput,
+ enabled,
+ },
+ async (input: v.InferOutput) => {
+ try {
+ const { componentManifest, docsManifest } = await getManifests(
+ server.ctx.custom?.request,
+ server.ctx.custom?.manifestProvider,
+ );
+
+ const component = componentManifest.components[input.id];
+ const docsEntry = docsManifest?.docs[input.id];
+
+ if (!component && !docsEntry) {
+ await server.ctx.custom?.onGetDocumentation?.({
+ context: server.ctx.custom,
+ input,
+ });
+
+ return {
+ content: [
+ {
+ type: 'text' as const,
+ text: `Component or Docs Entry not found: "${input.id}". Use the ${LIST_TOOL_NAME} tool to see available components and documentation entries.`,
+ },
+ ],
+ isError: true,
+ };
+ }
+
+ const documentation = component ?? docsEntry;
+
+ await server.ctx.custom?.onGetDocumentation?.({
+ context: server.ctx.custom,
+ input,
+ foundDocumentation: documentation,
+ });
+
+ const format = server.ctx.custom?.format ?? 'markdown';
+ return {
+ content: [
+ {
+ type: 'text' as const,
+ text: component
+ ? formatComponentManifest(
+ documentation as ComponentManifest,
+ format,
+ )
+ : formatDocsManifest(documentation as Doc, format),
+ },
+ ],
+ };
+ } catch (error) {
+ return errorToMCPContent(error);
+ }
+ },
+ );
+}
diff --git a/packages/mcp/src/tools/list-all-components.test.ts b/packages/mcp/src/tools/list-all-documentation.test.ts
similarity index 52%
rename from packages/mcp/src/tools/list-all-components.test.ts
rename to packages/mcp/src/tools/list-all-documentation.test.ts
index 22ab0e48..ab80f9c8 100644
--- a/packages/mcp/src/tools/list-all-components.test.ts
+++ b/packages/mcp/src/tools/list-all-documentation.test.ts
@@ -2,16 +2,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpServer } from 'tmcp';
import { ValibotJsonSchemaAdapter } from '@tmcp/adapter-valibot';
import {
- addListAllComponentsTool,
+ addListAllDocumentationTool,
LIST_TOOL_NAME,
-} from './list-all-components.ts';
+} from './list-all-documentation.ts';
import type { StorybookContext } from '../types.ts';
import smallManifestFixture from '../../fixtures/small-manifest.fixture.json' with { type: 'json' };
+import smallDocsManifestFixture from '../../fixtures/small-docs-manifest.fixture.json' with { type: 'json' };
import * as getManifest from '../utils/get-manifest.ts';
-describe('listAllComponentsTool', () => {
+describe('listAllDocumentationTool', () => {
let server: McpServer;
- let getManifestSpy: any;
+ let getManifestsSpy: any;
beforeEach(async () => {
const adapter = new ValibotJsonSchemaAdapter();
@@ -43,11 +44,13 @@ describe('listAllComponentsTool', () => {
},
{ sessionId: 'test-session' },
);
- await addListAllComponentsTool(server);
+ await addListAllDocumentationTool(server);
- // Mock getManifest to return the fixture
- getManifestSpy = vi.spyOn(getManifest, 'getManifest');
- getManifestSpy.mockResolvedValue(smallManifestFixture);
+ // Mock getManifests to return the fixture
+ getManifestsSpy = vi.spyOn(getManifest, 'getManifests');
+ getManifestsSpy.mockResolvedValue({
+ componentManifest: smallManifestFixture,
+ });
});
it('should return a list of all components', async () => {
@@ -83,7 +86,7 @@ describe('listAllComponentsTool', () => {
});
it('should handle fetch errors gracefully', async () => {
- getManifestSpy.mockRejectedValue(
+ getManifestsSpy.mockRejectedValue(
new getManifest.ManifestGetError(
'Failed to fetch manifest: 404 Not Found',
'https://example.com/manifest.json',
@@ -119,7 +122,7 @@ describe('listAllComponentsTool', () => {
});
it('should handle unexpected errors gracefully', async () => {
- getManifestSpy.mockRejectedValue(new Error('Network timeout'));
+ getManifestsSpy.mockRejectedValue(new Error('Network timeout'));
const request = {
jsonrpc: '2.0' as const,
@@ -149,7 +152,7 @@ describe('listAllComponentsTool', () => {
`);
});
- it('should call onListAllComponents handler when provided', async () => {
+ it('should call onListAllDocumentation handler when provided', async () => {
const handler = vi.fn();
const request = {
@@ -165,16 +168,18 @@ describe('listAllComponentsTool', () => {
const mockHttpRequest = new Request('https://example.com/mcp');
// Pass the handler and request in the context for this specific request
await server.receive(request, {
- custom: { request: mockHttpRequest, onListAllComponents: handler },
+ custom: { request: mockHttpRequest, onListAllDocumentation: handler },
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({
context: expect.objectContaining({
request: mockHttpRequest,
- onListAllComponents: handler,
+ onListAllDocumentation: handler,
}),
- manifest: smallManifestFixture,
+ manifests: {
+ componentManifest: smallManifestFixture,
+ },
});
});
@@ -227,4 +232,147 @@ describe('listAllComponentsTool', () => {
}
`);
});
+
+ describe('with docs manifest', () => {
+ beforeEach(() => {
+ getManifestsSpy.mockResolvedValue({
+ componentManifest: smallManifestFixture,
+ docsManifest: smallDocsManifestFixture,
+ });
+ });
+
+ it('should return both components and docs entries', async () => {
+ const request = {
+ jsonrpc: '2.0' as const,
+ id: 1,
+ method: 'tools/call',
+ params: {
+ name: LIST_TOOL_NAME,
+ arguments: {},
+ },
+ };
+
+ const mockHttpRequest = new Request('https://example.com/mcp');
+ const response = await server.receive(request, {
+ custom: { request: mockHttpRequest },
+ });
+
+ expect(response.result).toMatchInlineSnapshot(`
+ {
+ "content": [
+ {
+ "text": "# Components
+
+ - Button (button): A simple button component
+ - Card (card): A container component for grouping related content.
+ - Input (input): A text input component with validation support.
+
+ # Docs
+
+ - Getting Started Guide (getting-started): # Getting Started Welcome to the component library. This guide will help you get up and ru...
+ - Theming and Customization (theming): # Theming Learn how to customize the look and feel of components using our theming system....",
+ "type": "text",
+ },
+ ],
+ }
+ `);
+ });
+
+ it('should format both components and docs entries as XML when format is "xml"', async () => {
+ const request = {
+ jsonrpc: '2.0' as const,
+ id: 1,
+ method: 'tools/call',
+ params: {
+ name: LIST_TOOL_NAME,
+ arguments: {},
+ },
+ };
+
+ const mockHttpRequest = new Request('https://example.com/mcp');
+ const response = await server.receive(request, {
+ custom: { request: mockHttpRequest, format: 'xml' as const },
+ });
+
+ expect(response.result).toMatchInlineSnapshot(`
+ {
+ "content": [
+ {
+ "text": "
+
+ button
+ Button
+
+ A simple button component
+
+
+
+ card
+ Card
+
+ A container component for grouping related content.
+
+
+
+ input
+ Input
+
+ A text input component with validation support.
+
+
+
+
+
+ getting-started
+ Getting Started Guide
+
+ # Getting Started Welcome to the component library. This guide will help you get up and ru...
+
+
+
+ theming
+ Theming and Customization
+
+ # Theming Learn how to customize the look and feel of components using our theming system....
+
+
+ ",
+ "type": "text",
+ },
+ ],
+ }
+ `);
+ });
+
+ it('should include docs manifest in onListAllDocumentation handler call', async () => {
+ const handler = vi.fn();
+
+ const request = {
+ jsonrpc: '2.0' as const,
+ id: 2,
+ method: 'tools/call',
+ params: {
+ name: LIST_TOOL_NAME,
+ arguments: {},
+ },
+ };
+
+ const mockHttpRequest = new Request('https://example.com/mcp');
+ await server.receive(request, {
+ custom: { request: mockHttpRequest, onListAllDocumentation: handler },
+ });
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith({
+ context: expect.objectContaining({
+ request: mockHttpRequest,
+ onListAllDocumentation: handler,
+ }),
+ manifests: {
+ componentManifest: smallManifestFixture,
+ docsManifest: smallDocsManifestFixture,
+ },
+ });
+ });
+ });
});
diff --git a/packages/mcp/src/tools/list-all-components.ts b/packages/mcp/src/tools/list-all-documentation.ts
similarity index 53%
rename from packages/mcp/src/tools/list-all-components.ts
rename to packages/mcp/src/tools/list-all-documentation.ts
index faa48d85..c2c613a9 100644
--- a/packages/mcp/src/tools/list-all-components.ts
+++ b/packages/mcp/src/tools/list-all-documentation.ts
@@ -1,45 +1,42 @@
import type { McpServer } from 'tmcp';
import type { StorybookContext } from '../types.ts';
-import { getManifest, errorToMCPContent } from '../utils/get-manifest.ts';
-import { formatComponentManifestMapToList } from '../utils/format-manifest.ts';
+import { getManifests, errorToMCPContent } from '../utils/get-manifest.ts';
+import { formatManifestsToLists } from '../utils/format-manifest.ts';
-export const LIST_TOOL_NAME = 'list-all-components';
+export const LIST_TOOL_NAME = 'list-all-documentation';
-export async function addListAllComponentsTool(
+export async function addListAllDocumentationTool(
server: McpServer,
enabled?: Parameters['tool']>[0]['enabled'],
) {
server.tool(
{
name: LIST_TOOL_NAME,
- title: 'List All Components',
+ title: 'List All Documentation',
description:
- 'List all available UI components from the component library',
+ 'List all available UI components and documentation entries from the Storybook',
enabled,
},
async () => {
try {
- const manifest = await getManifest(
+ const manifests = await getManifests(
server.ctx.custom?.request,
server.ctx.custom?.manifestProvider,
);
const format = server.ctx.custom?.format ?? 'markdown';
- const componentList = formatComponentManifestMapToList(
- manifest,
- format,
- );
+ const lists = formatManifestsToLists(manifests, format);
- await server.ctx.custom?.onListAllComponents?.({
+ await server.ctx.custom?.onListAllDocumentation?.({
context: server.ctx.custom,
- manifest,
+ manifests,
});
return {
content: [
{
type: 'text',
- text: componentList,
+ text: lists,
},
],
};
diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts
index 5895e3ab..d214b06e 100644
--- a/packages/mcp/src/types.ts
+++ b/packages/mcp/src/types.ts
@@ -34,36 +34,36 @@ export type StorybookContext = {
path: string,
) => Promise;
/**
- * Optional handler called when list-all-components tool is invoked.
+ * Optional handler called when list-all-documentation tool is invoked.
* Receives the context and the component manifest.
*/
- onListAllComponents?: (params: {
+ onListAllDocumentation?: (params: {
context: StorybookContext;
- manifest: ComponentManifestMap;
+ manifests: AllManifests;
}) => void | Promise;
/**
* Optional handler called when get-component-documentation tool is invoked.
* Receives the context, input parameters, and the found component (if any).
*/
- onGetComponentDocumentation?: (params: {
+ onGetDocumentation?: (params: {
context: StorybookContext;
- input: { componentId: string };
- foundComponent?: ComponentManifest;
+ input: { id: string };
+ foundDocumentation?: ComponentManifest | Doc;
}) => void | Promise;
};
const JSDocTag = v.record(v.string(), v.array(v.string()));
+const Error = v.object({
+ name: v.string(),
+ message: v.string(),
+});
+
const BaseManifest = v.object({
name: v.string(),
description: v.optional(v.string()),
jsDocTags: v.optional(JSDocTag),
- error: v.optional(
- v.object({
- name: v.string(),
- message: v.string(),
- }),
- ),
+ error: v.optional(Error),
});
const Story = v.object({
@@ -71,6 +71,21 @@ const Story = v.object({
snippet: v.optional(v.string()),
});
+/**
+ * A docs entry represents MDX documentation that can be attached to a component
+ * or standalone (unattached).
+ */
+const Doc = v.object({
+ id: v.string(),
+ name: v.string(),
+ title: v.string(),
+ path: v.string(),
+ content: v.string(),
+ summary: v.optional(v.string()),
+ error: v.optional(Error),
+});
+export type Doc = v.InferOutput;
+
export const ComponentManifest = v.object({
...BaseManifest.entries,
id: v.string(),
@@ -80,6 +95,7 @@ export const ComponentManifest = v.object({
stories: v.optional(v.array(Story)),
// loose schema for react-docgen types, as they are pretty complex
reactDocgen: v.optional(v.custom(() => true)),
+ docs: v.optional(v.record(v.string(), Doc)),
});
export type ComponentManifest = v.InferOutput;
@@ -88,3 +104,18 @@ export const ComponentManifestMap = v.object({
components: v.record(v.string(), ComponentManifest),
});
export type ComponentManifestMap = v.InferOutput;
+
+/**
+ * Manifest for unattached/standalone documentation entries.
+ * Served at /manifests/docs.json
+ */
+export const DocsManifestMap = v.object({
+ v: v.number(),
+ docs: v.record(v.string(), Doc),
+});
+export type DocsManifestMap = v.InferOutput;
+
+export type AllManifests = {
+ componentManifest: ComponentManifestMap;
+ docsManifest?: DocsManifestMap;
+};
diff --git a/packages/mcp/src/utils/format-manifest.test.ts b/packages/mcp/src/utils/format-manifest.test.ts
index df983e19..cd9bf713 100644
--- a/packages/mcp/src/utils/format-manifest.test.ts
+++ b/packages/mcp/src/utils/format-manifest.test.ts
@@ -1,9 +1,9 @@
import { describe, it, expect } from 'vitest';
import {
formatComponentManifest,
- formatComponentManifestMapToList,
+ formatManifestsToLists,
} from './format-manifest';
-import type { ComponentManifest, ComponentManifestMap } from '../types';
+import type { AllManifests, ComponentManifest } from '../types';
describe('formatComponentManifest', () => {
const manifest: ComponentManifest = {
@@ -32,32 +32,34 @@ describe('formatComponentManifest', () => {
});
});
-describe('formatComponentManifestMapToList', () => {
- const manifest: ComponentManifestMap = {
- v: 1,
- components: {
- button: {
- id: 'button',
- name: 'Button',
- path: 'src/components/Button.tsx',
+describe('formatManifestsToLists', () => {
+ const manifests: AllManifests = {
+ componentManifest: {
+ v: 1,
+ components: {
+ button: {
+ id: 'button',
+ name: 'Button',
+ path: 'src/components/Button.tsx',
+ },
},
},
};
it('should use markdown formatter by default', () => {
- const result = formatComponentManifestMapToList(manifest);
+ const result = formatManifestsToLists(manifests);
expect(result).toContain('# Components');
expect(result).toContain('- Button (button)');
});
it('should use markdown formatter when format is "markdown"', () => {
- const result = formatComponentManifestMapToList(manifest, 'markdown');
+ const result = formatManifestsToLists(manifests, 'markdown');
expect(result).toContain('# Components');
expect(result).toContain('- Button (button)');
});
it('should use xml formatter when format is "xml"', () => {
- const result = formatComponentManifestMapToList(manifest, 'xml');
+ const result = formatManifestsToLists(manifests, 'xml');
expect(result).toContain('');
expect(result).toContain('Button');
expect(result).toContain('button');
diff --git a/packages/mcp/src/utils/format-manifest.ts b/packages/mcp/src/utils/format-manifest.ts
index 4f60ce84..09bca1de 100644
--- a/packages/mcp/src/utils/format-manifest.ts
+++ b/packages/mcp/src/utils/format-manifest.ts
@@ -1,6 +1,7 @@
import type {
+ AllManifests,
ComponentManifest,
- ComponentManifestMap,
+ Doc,
OutputFormat,
} from '../types.ts';
import type { ManifestFormatter } from './manifest-formatter/types.ts';
@@ -14,9 +15,6 @@ const formatters: Record = {
/**
* Format a single component manifest.
- * @param componentManifest - The component manifest to format
- * @param format - The desired output format (defaults to 'markdown')
- * @returns Formatted string representation of the component
*/
export function formatComponentManifest(
componentManifest: ComponentManifest,
@@ -26,14 +24,21 @@ export function formatComponentManifest(
}
/**
- * Format a component manifest map into a list.
- * @param manifest - The component manifest map to format
- * @param format - The desired output format (defaults to 'markdown')
- * @returns Formatted string representation of the component list
+ * Format a single docs manifest.
*/
-export function formatComponentManifestMapToList(
- manifest: ComponentManifestMap,
+export function formatDocsManifest(
+ doc: Doc,
format: OutputFormat = 'markdown',
): string {
- return formatters[format].formatComponentManifestMapToList(manifest);
+ return formatters[format].formatDocsManifest(doc);
+}
+
+/**
+ * Format a component manifest and optionally a docs manifest into lists.
+ */
+export function formatManifestsToLists(
+ manifests: AllManifests,
+ format: OutputFormat = 'markdown',
+): string {
+ return formatters[format].formatManifestsToLists(manifests);
}
diff --git a/packages/mcp/src/utils/get-manifest.test.ts b/packages/mcp/src/utils/get-manifest.test.ts
index c13b5bfa..b74cada3 100644
--- a/packages/mcp/src/utils/get-manifest.test.ts
+++ b/packages/mcp/src/utils/get-manifest.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { getManifest, ManifestGetError } from './get-manifest';
-import type { ComponentManifestMap } from '../types';
+import { getManifests, ManifestGetError } from './get-manifest.ts';
+import type { ComponentManifestMap, DocsManifestMap } from '../types.ts';
global.fetch = vi.fn();
@@ -13,6 +13,92 @@ function createMockRequest(url: string): Request {
});
}
+/**
+ * Helper to create a successful JSON fetch response
+ */
+function createJsonResponse(data: unknown) {
+ return {
+ ok: true,
+ headers: {
+ get: vi.fn().mockReturnValue('application/json'),
+ },
+ text: vi.fn().mockResolvedValue(JSON.stringify(data)),
+ };
+}
+
+/**
+ * Helper to create a 404 response
+ */
+function create404Response() {
+ return {
+ ok: false,
+ status: 404,
+ statusText: 'Not Found',
+ };
+}
+
+/**
+ * Helper to create a fetch mock that returns different responses based on URL
+ */
+function createFetchMock(responses: {
+ components?: unknown | Error;
+ docs?: unknown | Error;
+}) {
+ return vi.fn().mockImplementation((url: string) => {
+ if (url.includes('components.json')) {
+ if (responses.components instanceof Error) {
+ return Promise.reject(responses.components);
+ }
+ return Promise.resolve(
+ responses.components !== undefined
+ ? createJsonResponse(responses.components)
+ : create404Response(),
+ );
+ }
+ if (url.includes('docs.json')) {
+ if (responses.docs instanceof Error) {
+ return Promise.reject(responses.docs);
+ }
+ return Promise.resolve(
+ responses.docs !== undefined
+ ? createJsonResponse(responses.docs)
+ : create404Response(),
+ );
+ }
+ return Promise.resolve(create404Response());
+ });
+}
+
+/**
+ * Helper to create a manifestProvider mock that returns different responses based on path
+ */
+function createManifestProviderMock(responses: {
+ components?: string | Error;
+ docs?: string | Error;
+}) {
+ return vi
+ .fn()
+ .mockImplementation((_request: Request | undefined, path: string) => {
+ if (path.includes('components.json')) {
+ if (responses.components instanceof Error) {
+ return Promise.reject(responses.components);
+ }
+ return responses.components !== undefined
+ ? Promise.resolve(responses.components)
+ : Promise.reject(new Error('Components not found'));
+ }
+ if (path.includes('docs.json')) {
+ if (responses.docs instanceof Error) {
+ return Promise.reject(responses.docs);
+ }
+ return responses.docs !== undefined
+ ? Promise.resolve(responses.docs)
+ : Promise.reject(new Error('Docs not found'));
+ }
+ return Promise.reject(new Error('Unknown path'));
+ });
+}
+
describe('getManifest', () => {
beforeEach(() => {
// Reset the fetch mock between tests since we're checking call counts
@@ -21,14 +107,14 @@ describe('getManifest', () => {
describe('error cases', () => {
it('should throw ManifestGetError when request is not provided and using default provider', async () => {
- await expect(getManifest()).rejects.toThrow(ManifestGetError);
- await expect(getManifest()).rejects.toThrow(
+ await expect(getManifests()).rejects.toThrow(ManifestGetError);
+ await expect(getManifests()).rejects.toThrow(
"You must either pass the original request forward to the server context, or set a custom manifestProvider that doesn't need the request",
);
});
it('should throw ManifestGetError when request is undefined and using default provider', async () => {
- await expect(getManifest(undefined)).rejects.toThrow(ManifestGetError);
- await expect(getManifest(undefined)).rejects.toThrow(
+ await expect(getManifests(undefined)).rejects.toThrow(ManifestGetError);
+ await expect(getManifests(undefined)).rejects.toThrow(
"You must either pass the original request forward to the server context, or set a custom manifestProvider that doesn't need the request",
);
});
@@ -40,8 +126,8 @@ describe('getManifest', () => {
});
const request = createMockRequest('https://example.com/mcp');
- await expect(getManifest(request)).rejects.toThrow(ManifestGetError);
- await expect(getManifest(request)).rejects.toThrow(
+ await expect(getManifests(request)).rejects.toThrow(ManifestGetError);
+ await expect(getManifests(request)).rejects.toThrow(
'Failed to fetch manifest: 404 Not Found',
);
});
@@ -54,7 +140,7 @@ describe('getManifest', () => {
});
const request = createMockRequest('https://example.com/mcp');
- await expect(getManifest(request)).rejects.toThrow(
+ await expect(getManifests(request)).rejects.toThrow(
'Failed to fetch manifest: 500 Internal Server Error',
);
});
@@ -68,25 +154,30 @@ describe('getManifest', () => {
});
const request = createMockRequest('https://example.com/mcp');
- await expect(getManifest(request)).rejects.toThrow(ManifestGetError);
- await expect(getManifest(request)).rejects.toThrow(
+ await expect(getManifests(request)).rejects.toThrow(ManifestGetError);
+ await expect(getManifests(request)).rejects.toThrow(
'Invalid content type: expected application/json, got text/html',
);
});
it('should throw ManifestGetError when response is not valid JSON', async () => {
- global.fetch = vi.fn().mockResolvedValue({
- ok: true,
- headers: {
- get: vi.fn().mockReturnValue('application/json'),
- },
- text: vi.fn().mockResolvedValue('not valid json{'),
+ global.fetch = vi.fn().mockImplementation((url: string) => {
+ if (url.includes('components.json')) {
+ return Promise.resolve({
+ ok: true,
+ headers: {
+ get: vi.fn().mockReturnValue('application/json'),
+ },
+ text: vi.fn().mockResolvedValue('not valid json{'),
+ });
+ }
+ return Promise.resolve(create404Response());
});
const request = createMockRequest('https://example.com/mcp');
- await expect(getManifest(request)).rejects.toThrow(ManifestGetError);
- await expect(getManifest(request)).rejects.toThrow(
- 'Failed to get manifest:',
+ await expect(getManifests(request)).rejects.toThrow(ManifestGetError);
+ await expect(getManifests(request)).rejects.toThrow(
+ 'Failed to parse component manifest:',
);
});
@@ -105,11 +196,9 @@ describe('getManifest', () => {
});
const request = createMockRequest('https://example.com/mcp');
- await expect(
- getManifest(request),
- ).rejects.toThrowErrorMatchingInlineSnapshot(
- `[ManifestGetError: Failed to get manifest: Invalid key: Expected "v" but received undefined]`,
- );
+ await expect(getManifests(request)).rejects
+ .toThrowErrorMatchingInlineSnapshot(`[ManifestGetError: Failed to parse component manifest:
+Invalid key: Expected "v" but received undefined]`);
});
it('should throw ManifestGetError when components object is empty', async () => {
@@ -127,8 +216,8 @@ describe('getManifest', () => {
});
const request = createMockRequest('https://example.com/mcp');
- await expect(getManifest(request)).rejects.toThrow(ManifestGetError);
- await expect(getManifest(request)).rejects.toThrow(
+ await expect(getManifests(request)).rejects.toThrow(ManifestGetError);
+ await expect(getManifests(request)).rejects.toThrow(
'No components found in the manifest',
);
});
@@ -139,8 +228,8 @@ describe('getManifest', () => {
.mockRejectedValue(new Error('Network connection failed'));
const request = createMockRequest('https://example.com/mcp');
- await expect(getManifest(request)).rejects.toThrow(ManifestGetError);
- await expect(getManifest(request)).rejects.toThrow(
+ await expect(getManifests(request)).rejects.toThrow(ManifestGetError);
+ await expect(getManifests(request)).rejects.toThrow(
'Network connection failed',
);
});
@@ -154,7 +243,7 @@ describe('getManifest', () => {
const request = createMockRequest('https://example.com/mcp');
try {
- await getManifest(request);
+ await getManifests(request);
} catch (error) {
expect(error).toBeInstanceOf(ManifestGetError);
expect((error as ManifestGetError).url).toBe(
@@ -178,21 +267,60 @@ describe('getManifest', () => {
},
};
- global.fetch = vi.fn().mockResolvedValue({
- ok: true,
- headers: {
- get: vi.fn().mockReturnValue('application/json'),
- },
- text: vi.fn().mockResolvedValue(JSON.stringify(validManifest)),
- });
+ global.fetch = createFetchMock({ components: validManifest });
const request = createMockRequest('https://example.com/mcp');
- const result = await getManifest(request);
+ const result = await getManifests(request);
- expect(result).toEqual(validManifest);
- expect(global.fetch).toHaveBeenCalledExactlyOnceWith(
+ expect(result).toEqual({ componentManifest: validManifest });
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ expect(global.fetch).toHaveBeenCalledWith(
'https://example.com/manifests/components.json',
);
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'https://example.com/manifests/docs.json',
+ );
+ });
+
+ it('should successfully fetch and parse both component and docs manifests', async () => {
+ const validComponentManifest: ComponentManifestMap = {
+ v: 1,
+ components: {
+ button: {
+ id: 'button',
+ path: 'src/components/Button.tsx',
+ name: 'Button',
+ description: 'A button component',
+ },
+ },
+ };
+
+ const validDocsManifest: DocsManifestMap = {
+ v: 1,
+ docs: {
+ 'getting-started': {
+ id: 'getting-started',
+ name: 'Getting Started',
+ title: 'Getting Started Guide',
+ path: 'docs/getting-started.mdx',
+ content: '# Getting Started\n\nWelcome to our component library.',
+ },
+ },
+ };
+
+ global.fetch = createFetchMock({
+ components: validComponentManifest,
+ docs: validDocsManifest,
+ });
+
+ const request = createMockRequest('https://example.com/mcp');
+ const result = await getManifests(request);
+
+ expect(result).toEqual({
+ componentManifest: validComponentManifest,
+ docsManifest: validDocsManifest,
+ });
+ expect(global.fetch).toHaveBeenCalledTimes(2);
});
});
@@ -211,17 +339,22 @@ describe('getManifest', () => {
};
const request = createMockRequest('https://example.com/mcp');
- const manifestProvider = vi
- .fn()
- .mockResolvedValue(JSON.stringify(validManifest));
+ const manifestProvider = createManifestProviderMock({
+ components: JSON.stringify(validManifest),
+ });
- const result = await getManifest(request, manifestProvider);
+ const result = await getManifests(request, manifestProvider);
- expect(result).toEqual(validManifest);
- expect(manifestProvider).toHaveBeenCalledExactlyOnceWith(
+ expect(result).toEqual({ componentManifest: validManifest });
+ expect(manifestProvider).toHaveBeenCalledTimes(2);
+ expect(manifestProvider).toHaveBeenCalledWith(
request,
'./manifests/components.json',
);
+ expect(manifestProvider).toHaveBeenCalledWith(
+ request,
+ './manifests/docs.json',
+ );
// fetch should not be called when manifestProvider is used
expect(global.fetch).not.toHaveBeenCalled();
});
@@ -239,15 +372,15 @@ describe('getManifest', () => {
},
};
- // Custom provider that doesn't need the request
- const manifestProvider = vi
- .fn()
- .mockResolvedValue(JSON.stringify(validManifest));
+ const manifestProvider = createManifestProviderMock({
+ components: JSON.stringify(validManifest),
+ });
- const result = await getManifest(undefined, manifestProvider);
+ const result = await getManifests(undefined, manifestProvider);
- expect(result).toEqual(validManifest);
- expect(manifestProvider).toHaveBeenCalledExactlyOnceWith(
+ expect(result).toEqual({ componentManifest: validManifest });
+ expect(manifestProvider).toHaveBeenCalledTimes(2);
+ expect(manifestProvider).toHaveBeenCalledWith(
undefined,
'./manifests/components.json',
);
@@ -268,42 +401,39 @@ describe('getManifest', () => {
},
};
- global.fetch = vi.fn().mockResolvedValue({
- ok: true,
- headers: {
- get: vi.fn().mockReturnValue('application/json'),
- },
- text: vi.fn().mockResolvedValue(JSON.stringify(validManifest)),
- });
+ global.fetch = createFetchMock({ components: validManifest });
const request = createMockRequest('https://example.com/mcp');
- const result = await getManifest(request);
+ const result = await getManifests(request);
- expect(result).toEqual(validManifest);
- expect(global.fetch).toHaveBeenCalledExactlyOnceWith(
+ expect(result).toEqual({ componentManifest: validManifest });
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ expect(global.fetch).toHaveBeenCalledWith(
'https://example.com/manifests/components.json',
);
});
it('should handle errors from manifestProvider', async () => {
const request = createMockRequest('https://example.com/mcp');
- const manifestProvider = vi
- .fn()
- .mockRejectedValue(new Error('File not found'));
+ const manifestProvider = createManifestProviderMock({
+ components: new Error('File not found'),
+ });
- await expect(getManifest(request, manifestProvider)).rejects.toThrow(
+ await expect(getManifests(request, manifestProvider)).rejects.toThrow(
ManifestGetError,
);
- await expect(getManifest(request, manifestProvider)).rejects.toThrow(
- 'Failed to get manifest: File not found',
+ await expect(getManifests(request, manifestProvider)).rejects.toThrow(
+ 'Failed to get component manifest: File not found',
);
});
it('should handle invalid JSON from manifestProvider', async () => {
const request = createMockRequest('https://example.com/mcp');
- const manifestProvider = vi.fn().mockResolvedValue('not valid json{');
+ const manifestProvider = createManifestProviderMock({
+ components: 'not valid json{',
+ });
- await expect(getManifest(request, manifestProvider)).rejects.toThrow(
+ await expect(getManifests(request, manifestProvider)).rejects.toThrow(
ManifestGetError,
);
});
diff --git a/packages/mcp/src/utils/get-manifest.ts b/packages/mcp/src/utils/get-manifest.ts
index c5354107..aae45de6 100644
--- a/packages/mcp/src/utils/get-manifest.ts
+++ b/packages/mcp/src/utils/get-manifest.ts
@@ -1,10 +1,15 @@
-import { ComponentManifestMap } from '../types.ts';
+import {
+ ComponentManifestMap,
+ DocsManifestMap,
+ type AllManifests,
+} from '../types.ts';
import * as v from 'valibot';
/**
- * The path to the component manifest file relative to the Storybook build
+ * The paths to the manifest files relative to the Storybook build
*/
-export const MANIFEST_PATH = './manifests/components.json';
+export const COMPONENT_MANIFEST_PATH = './manifests/components.json';
+export const DOCS_MANIFEST_PATH = './manifests/docs.json';
/**
* Error thrown when getting or parsing a manifest fails
@@ -62,53 +67,97 @@ export const errorToMCPContent = (error: unknown): MCPErrorResult => {
};
/**
- * Gets a component manifest from a request or using a custom provider
+ * Parses a JSON string and validates it against a Valibot schema
+ */
+function parseManifest<
+ T extends v.BaseSchema>,
+>({
+ jsonString,
+ schema,
+ name,
+ url,
+}: {
+ jsonString: string;
+ schema: T;
+ name: string;
+ url: string;
+}): v.InferOutput {
+ try {
+ return v.parse(v.pipe(v.string(), v.parseJson(), schema), jsonString);
+ } catch (error) {
+ throw new ManifestGetError(
+ `Failed to parse ${name} manifest:
+${error instanceof v.ValiError ? error.issues.map((i) => i.message).join('\n') : String(error)}`,
+ url,
+ );
+ }
+}
+
+/**
+ * Gets component and docs manifest from a request or using a custom provider
*
* @param request - The HTTP request to get the manifest for (optional when using custom manifestProvider)
* @param manifestProvider - Optional custom function to get the manifest
* @returns A promise that resolves to the parsed ComponentManifestMap
* @throws {ManifestGetError} If getting the manifest fails or the response is invalid
*/
-export async function getManifest(
+export async function getManifests(
request?: Request,
manifestProvider?: (
request: Request | undefined,
path: string,
) => Promise,
-): Promise {
- try {
- // Use custom manifestProvider if provided, otherwise fallback to default
- const manifestString = await (manifestProvider ?? defaultManifestProvider)(
- request,
- MANIFEST_PATH,
- );
- const manifestData: unknown = JSON.parse(manifestString);
-
- const manifest = v.parse(ComponentManifestMap, manifestData);
+): Promise {
+ const provider = manifestProvider ?? defaultManifestProvider;
+
+ // Fetch both component and docs manifests in parallel
+ const [componentResult, docsResult] = await Promise.allSettled([
+ provider(request, COMPONENT_MANIFEST_PATH),
+ provider(request, DOCS_MANIFEST_PATH),
+ ]);
+
+ const getUrl = (path: string) =>
+ request
+ ? getManifestUrlFromRequest(request, path)
+ : 'Unknown manifest source';
- if (Object.keys(manifest.components).length === 0) {
- const url = request
- ? getManifestUrlFromRequest(request, MANIFEST_PATH)
- : 'Unknown manifest source';
- throw new ManifestGetError(`No components found in the manifest`, url);
- }
+ if (componentResult.status === 'rejected') {
+ throw new ManifestGetError(
+ `Failed to get component manifest: ${componentResult.reason instanceof Error ? componentResult.reason.message : String(componentResult.reason)}`,
+ getUrl(COMPONENT_MANIFEST_PATH),
+ componentResult.reason instanceof Error
+ ? componentResult.reason
+ : undefined,
+ );
+ }
- return manifest;
- } catch (error) {
- if (error instanceof ManifestGetError) {
- throw error;
- }
+ const componentManifest = parseManifest({
+ jsonString: componentResult.value,
+ schema: ComponentManifestMap,
+ name: 'component',
+ url: getUrl(COMPONENT_MANIFEST_PATH),
+ });
- // Wrap network errors and other unexpected errors
- const url = request
- ? getManifestUrlFromRequest(request, MANIFEST_PATH)
- : 'Unknown manifest source';
+ if (Object.keys(componentManifest.components).length === 0) {
throw new ManifestGetError(
- `Failed to get manifest: ${error instanceof Error ? error.message : String(error)}`,
- url,
- error instanceof Error ? error : undefined,
+ `No components found in the manifest`,
+ getUrl(COMPONENT_MANIFEST_PATH),
);
}
+
+ if (docsResult.status === 'rejected') {
+ return { componentManifest };
+ }
+
+ // Handle docs manifest result (optional - only exists when addon-docs is used)
+ const docsManifest = parseManifest({
+ jsonString: docsResult.value,
+ schema: DocsManifestMap,
+ name: 'docs',
+ url: getUrl(DOCS_MANIFEST_PATH),
+ });
+
+ return { componentManifest, docsManifest };
}
/**
diff --git a/packages/mcp/src/utils/manifest-formatter/__snapshots__/markdown.test.ts.snap b/packages/mcp/src/utils/manifest-formatter/__snapshots__/markdown.test.ts.snap
index eb3a41dd..fbdb979a 100644
--- a/packages/mcp/src/utils/manifest-formatter/__snapshots__/markdown.test.ts.snap
+++ b/packages/mcp/src/utils/manifest-formatter/__snapshots__/markdown.test.ts.snap
@@ -426,7 +426,7 @@ export type Props = {
\`\`\`"
`;
-exports[`MarkdownFormatter - formatComponentManifestMapToList > formats the full manifest fixture 1`] = `
+exports[`MarkdownFormatter - formatManifestsToLists > formats the full manifest fixture 1`] = `
"# Components
- Button (button): A versatile button component for user interactions
diff --git a/packages/mcp/src/utils/manifest-formatter/__snapshots__/xml.test.ts.snap b/packages/mcp/src/utils/manifest-formatter/__snapshots__/xml.test.ts.snap
index 76fcdc4f..2ea00b52 100644
--- a/packages/mcp/src/utils/manifest-formatter/__snapshots__/xml.test.ts.snap
+++ b/packages/mcp/src/utils/manifest-formatter/__snapshots__/xml.test.ts.snap
@@ -533,7 +533,7 @@ Callback function when the input value changes
"
`;
-exports[`XmlFormatter - formatComponentManifestMapToList > formats the full manifest fixture 1`] = `
+exports[`XmlFormatter - formatManifestsToLists > formats the full manifest fixture 1`] = `
"button
diff --git a/packages/mcp/src/utils/manifest-formatter/extract-docs-summary.test.ts b/packages/mcp/src/utils/manifest-formatter/extract-docs-summary.test.ts
new file mode 100644
index 00000000..dada8638
--- /dev/null
+++ b/packages/mcp/src/utils/manifest-formatter/extract-docs-summary.test.ts
@@ -0,0 +1,201 @@
+import { describe, it, expect } from 'vitest';
+import { extractDocsSummary } from './extract-docs-summary.ts';
+
+describe('extractDocsSummary', () => {
+ describe('import statement removal', () => {
+ it('should remove single import statements', () => {
+ const content = `import { Button } from './Button';
+
+This is the actual content.`;
+ expect(extractDocsSummary(content)).toBe('This is the actual content.');
+ });
+
+ it('should remove multiple import statements', () => {
+ const content = `import { Button } from './Button';
+import { Meta, Story } from '@storybook/blocks';
+import React from 'react';
+
+This is the content after imports.`;
+ expect(extractDocsSummary(content)).toBe(
+ 'This is the content after imports.',
+ );
+ });
+
+ it('should remove default imports', () => {
+ const content = `import Button from './Button';
+
+Button documentation here.`;
+ expect(extractDocsSummary(content)).toBe('Button documentation here.');
+ });
+
+ it('should remove namespace imports', () => {
+ const content = `import * as Icons from './icons';
+
+Icons documentation.`;
+ expect(extractDocsSummary(content)).toBe('Icons documentation.');
+ });
+
+ it('should remove side-effect imports', () => {
+ const content = `import './styles.css';
+
+Styled content.`;
+ expect(extractDocsSummary(content)).toBe('Styled content.');
+ });
+ });
+
+ describe('expression removal', () => {
+ it('should remove simple expressions', () => {
+ const content = `Some text {expression} more text.`;
+ expect(extractDocsSummary(content)).toBe('Some text more text.');
+ });
+
+ it('should remove JSX comments', () => {
+ const content = `Some text {/* comment */} more text.`;
+ expect(extractDocsSummary(content)).toBe('Some text more text.');
+ });
+
+ it('should remove nested expressions', () => {
+ const content = `Text {outer {inner} value} end.`;
+ expect(extractDocsSummary(content)).toBe('Text end.');
+ });
+
+ it('should remove complex expressions with function calls', () => {
+ const content = `Count: {items.length} items.`;
+ expect(extractDocsSummary(content)).toBe('Count: items.');
+ });
+
+ it('should remove expressions with template literals', () => {
+ const content = 'Text {`template ${value}`} end.';
+ expect(extractDocsSummary(content)).toBe('Text end.');
+ });
+ });
+
+ describe('JSX/HTML element text extraction', () => {
+ it('should extract text from simple elements', () => {
+ const content = `
This is a paragraph.
`;
+ expect(extractDocsSummary(content)).toBe('This is a paragraph.');
+ });
+
+ it('should extract text from nested elements', () => {
+ const content = `
Nested bold text.
`;
+ expect(extractDocsSummary(content)).toBe('Nested bold text.');
+ });
+
+ it('should remove self-closing tags', () => {
+ const content = `Text before text after end.`;
+ expect(extractDocsSummary(content)).toBe('Text before text after end.');
+ });
+
+ it('should handle PascalCase component names', () => {
+ const content = `Component content`;
+ expect(extractDocsSummary(content)).toBe('Component content');
+ });
+
+ it('should handle elements with attributes', () => {
+ const content = `