Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/blue-eels-take.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@storybook/addon-mcp': patch
---

Add GET handler that serves HTML when visiting `/mcp`, and redirects to human-readable component manifest when applicable
5 changes: 5 additions & 0 deletions .changeset/tall-boxes-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@storybook/addon-mcp': patch
---

Update manifest format
36 changes: 0 additions & 36 deletions CHANGELOG.md

This file was deleted.

35 changes: 35 additions & 0 deletions packages/addon-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,38 @@ EDIT: The above is not true anymore, see version [0.1.1](#011) of this package.
### Patch Changes

- [#29](https://github.com/storybookjs/mcp/pull/29) [`4086e0d`](https://github.com/storybookjs/mcp/commit/4086e0d41d29a2e5c412a5cfd6bc65d97bf9ee76) Thanks [@JReinhold](https://github.com/JReinhold)! - Update documentation and repository links

## 0.0.5

### Patch Changes

- [#21](https://github.com/storybookjs/addon-mcp/pull/21) [`b91acac`](https://github.com/storybookjs/addon-mcp/commit/b91acac6fcb7d8e3556e07a499432c1779d59680) Thanks [@shilman](https://github.com/shilman)! - Embed demo image from storybook.js.org

- [#16](https://github.com/storybookjs/addon-mcp/pull/16) [`bf41737`](https://github.com/storybookjs/addon-mcp/commit/bf41737f3409ff25a023993bf1475bf9620c085d) Thanks [@shilman](https://github.com/shilman)! - Improve README

## 0.0.4

### Patch Changes

- [#12](https://github.com/storybookjs/addon-mcp/pull/12) [`b448cd4`](https://github.com/storybookjs/addon-mcp/commit/b448cd45093866556cfb1b3edba8e98c0db23a9a) Thanks [@JReinhold](https://github.com/JReinhold)! - Add instructions on when to write stories

## 0.0.3

### Patch Changes

- [#11](https://github.com/storybookjs/addon-mcp/pull/11) [`bba9b8c`](https://github.com/storybookjs/addon-mcp/commit/bba9b8c683acdd5dfa835d4dea848dce7355ee82) Thanks [@JReinhold](https://github.com/JReinhold)! - - Improved UI Building Instructions
- Improved output format of Get Story URLs tool

- [#9](https://github.com/storybookjs/addon-mcp/pull/9) [`e5e2adf`](https://github.com/storybookjs/addon-mcp/commit/e5e2adf7192d5e12f21229056b644e7aa32287ed) Thanks [@JReinhold](https://github.com/JReinhold)! - Add basic telemetry for sessions and tool calls

## 0.0.2

### Patch Changes

- [#8](https://github.com/storybookjs/addon-mcp/pull/8) [`77d0779`](https://github.com/storybookjs/addon-mcp/commit/77d0779f471537bd72eca42543a559e97d329f6f) Thanks [@JReinhold](https://github.com/JReinhold)! - Add initial readme content

## 0.0.1

### Patch Changes

- [#5](https://github.com/storybookjs/addon-mcp/pull/5) [`e4978f3`](https://github.com/storybookjs/addon-mcp/commit/e4978f3cc0f587f3fc51aa26f49b8183bfbbc966) Thanks [@JReinhold](https://github.com/JReinhold)! - Initial release with UI instruction and story link tools
8 changes: 2 additions & 6 deletions packages/addon-mcp/src/mcp-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { buffer } from 'node:stream/consumers';
import { collectTelemetry } from './telemetry.ts';
import type { AddonContext, AddonOptionsOutput } from './types.ts';
import { logger } from 'storybook/internal/node-logger';
import { isManifestAvailable } from './tools/is-manifest-available.ts';

let transport: HttpTransport<AddonContext> | undefined;
let origin: string | undefined;
Expand Down Expand Up @@ -50,12 +51,7 @@ const initializeMCPServer = async (options: Options) => {
await addGetUIBuildingInstructionsTool(server);

// Only register the additional tools if the component manifest feature is enabled
const [features, componentManifestGenerator] = await Promise.all([
options.presets.apply('features') as any,
options.presets.apply('experimental_componentManifestGenerator'),
]);

if (features.experimentalComponentsManifest && componentManifestGenerator) {
if (await isManifestAvailable(options)) {
logger.info(
'Experimental components manifest feature detected - registering component tools',
);
Expand Down
123 changes: 121 additions & 2 deletions packages/addon-mcp/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ import { mcpServerHandler } from './mcp-handler.ts';
import type { PresetProperty } from 'storybook/internal/types';
import { AddonOptions, type AddonOptionsInput } from './types.ts';
import * as v from 'valibot';
import { isManifestAvailable } from './tools/is-manifest-available.ts';

export const experimental_devServer: PresetProperty<
'experimental_devServer'
> = (app, options) => {
> = async (app, options) => {
const addonOptions = v.parse(AddonOptions, {
toolsets: (options as AddonOptionsInput).toolsets ?? {},
});

app!.use('/mcp', (req, res, next) =>
app!.post('/mcp', (req, res, next) =>
mcpServerHandler({
req,
res,
Expand All @@ -19,5 +20,123 @@ export const experimental_devServer: PresetProperty<
addonOptions,
}),
);

const shouldRedirect = await isManifestAvailable(options);

app!.get('/mcp', async (req, res) => {
const acceptHeader = req.headers['accept'] || '';

if (acceptHeader.includes('text/html')) {
// Browser request - send HTML with redirect
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html>
<head>
${shouldRedirect ? '<meta http-equiv="refresh" content="10;url=/manifests/components.html" />' : ''}
<style>
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./sb-common-assets/nunito-sans-regular.woff2') format('woff2');
}

* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

html, body {
height: 100%;
font-family: 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}

body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 2rem;
background-color: #ffffff;
color: rgb(46, 52, 56);
line-height: 1.6;
}

p {
margin-bottom: 1rem;
}

code {
font-family: 'Monaco', 'Courier New', monospace;
background: #f5f5f5;
padding: 0.2em 0.4em;
border-radius: 3px;
}

a {
color: #1ea7fd;
}

@media (prefers-color-scheme: dark) {
body {
background-color: rgb(34, 36, 37);
color: rgb(201, 205, 207);
}

code {
background: rgba(255, 255, 255, 0.1);
}
}
</style>
</head>
<body>
<div>
<p>
Storybook MCP server successfully running via
<code>@storybook/addon-mcp</code>.
</p>
<p>
See how to connect to it from your coding agent in <a target="_blank" href="https://github.com/storybookjs/mcp/tree/main/packages/addon-mcp#configuring-your-agent">the addon's README</a>.
</p>
${
shouldRedirect
? `
<p>
Automatically redirecting to
<a href="/manifests/components.html">component manifest</a>
in <span id="countdown">10</span> seconds...
</p>`
: ''
}
</div>
${
shouldRedirect
? `
<script>
let countdown = 10;
const countdownElement = document.getElementById('countdown');
setInterval(() => {
countdown -= 1;
countdownElement.textContent = countdown.toString();
}, 1000);
</script>
`
: ''
}
</body>
</html>
`);
} else {
// Non-browser request (API, curl, etc.) - send plain text
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(
'Storybook MCP server successfully running via @storybook/addon-mcp',
);
}
});
return app;
};
11 changes: 11 additions & 0 deletions packages/addon-mcp/src/tools/is-manifest-available.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Options } from 'storybook/internal/types';

export const isManifestAvailable = async (
options: Options,
): Promise<boolean> => {
const [features, componentManifestGenerator] = await Promise.all([
options.presets.apply('features') as any,
options.presets.apply('experimental_componentManifestGenerator'),
]);
return features.experimentalComponentsManifest && componentManifestGenerator;
};
1 change: 1 addition & 0 deletions packages/mcp/fixtures/button.fixture.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"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",
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/fixtures/card.fixture.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"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",
Expand Down
3 changes: 3 additions & 0 deletions packages/mcp/fixtures/full-manifest.fixture.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"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",
Expand Down Expand Up @@ -170,6 +171,7 @@
},
"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",
Expand Down Expand Up @@ -339,6 +341,7 @@
},
"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",
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/fixtures/input.fixture.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"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",
Expand Down
3 changes: 3 additions & 0 deletions packages/mcp/fixtures/small-manifest.fixture.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"components": {
"button": {
"id": "button",
"path": "src/components/Button.tsx",
"name": "Button",
"summary": "A simple button component",
"examples": [
Expand All @@ -15,6 +16,7 @@
},
"card": {
"id": "card",
"path": "src/components/Card.tsx",
"name": "Card",
"description": "A container component for grouping related content.",
"examples": [
Expand All @@ -27,6 +29,7 @@
},
"input": {
"id": "input",
"path": "src/components/Input.tsx",
"name": "Input",
"description": "A text input component with validation support.",
"examples": [
Expand Down
20 changes: 13 additions & 7 deletions packages/mcp/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,23 +40,29 @@ const JSDocTag = v.record(v.string(), v.array(v.string()));

const BaseManifest = v.object({
name: v.string(),
description: v.exactOptional(v.string()),
import: v.exactOptional(v.string()),
jsDocTags: v.exactOptional(JSDocTag),
description: v.optional(v.string()),
import: v.optional(v.string()),
jsDocTags: v.optional(JSDocTag),
error: v.optional(
v.object({
message: v.string(),
}),
),
});

const Example = v.object({
...BaseManifest.entries,
snippet: v.string(),
snippet: v.optional(v.string()),
});

export const ComponentManifest = v.object({
...BaseManifest.entries,
id: v.string(),
summary: v.exactOptional(v.string()),
examples: v.exactOptional(v.array(Example)),
path: v.string(),
summary: v.optional(v.string()),
examples: v.optional(v.array(Example)),
// loose schema for react-docgen types, as they are pretty complex
reactDocgen: v.exactOptional(v.custom<Documentation>(() => true)),
reactDocgen: v.optional(v.custom<Documentation>(() => true)),
});
export type ComponentManifest = v.InferOutput<typeof ComponentManifest>;

Expand Down
Loading
Loading