Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 @@ -67,3 +67,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