@storybook/addon-mcp 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/dist/preset.js +593 -172
- package/dist/preview-stories-app-script.js +1 -1
- package/package.json +10 -7
package/README.md
CHANGED
|
@@ -156,9 +156,12 @@ The instructions ensure agents follow your project's conventions when creating o
|
|
|
156
156
|
|
|
157
157
|
Allows agents to retrieve direct URLs to specific stories in your Storybook. The agent can request URLs for multiple stories by providing:
|
|
158
158
|
|
|
159
|
-
-
|
|
160
|
-
- `
|
|
161
|
-
- `
|
|
159
|
+
- **Path-based input** (best when the agent is already editing a `.stories.*` file):
|
|
160
|
+
- `absoluteStoryPath`: Absolute path to the story file
|
|
161
|
+
- `exportName`: The export name of the story
|
|
162
|
+
- `explicitStoryName`: Optional explicit story name
|
|
163
|
+
- **ID-based input** (best when the agent discovered stories via docs tools):
|
|
164
|
+
- `storyId`: Full Storybook story ID (for example `example-button--primary`)
|
|
162
165
|
|
|
163
166
|
Example agent usage:
|
|
164
167
|
|
|
@@ -195,10 +198,14 @@ export default {
|
|
|
195
198
|
|
|
196
199
|
Returns a list of all available UI components as well as standalone docs in your component library. Useful for the LLM as discovery and understanding what components are available to use.
|
|
197
200
|
|
|
201
|
+
You can pass `withStoryIds: true` to include nested story entries (story name + story ID) under each component, which is useful before calling `preview-stories` or `run-story-tests` with `storyId`.
|
|
202
|
+
|
|
198
203
|
#### 4. Get Documentation (`get-documentation`)
|
|
199
204
|
|
|
200
205
|
Retrieves detailed documentation for a specific component or docs entry.
|
|
201
206
|
|
|
207
|
+
Component documentation includes component ID and story IDs for listed stories, so agents can directly feed those IDs into `preview-stories` and `run-story-tests`.
|
|
208
|
+
|
|
202
209
|
The agent provides a component/docs ID to retrieve its documentation. To get documentation for multiple entries, call this tool multiple times.
|
|
203
210
|
|
|
204
211
|
## Contributing
|
package/dist/preset.js
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { McpServer } from "tmcp";
|
|
2
2
|
import { ValibotJsonSchemaAdapter } from "@tmcp/adapter-valibot";
|
|
3
3
|
import { HttpTransport } from "@tmcp/transport-http";
|
|
4
|
-
import path from "node:path";
|
|
5
4
|
import url from "node:url";
|
|
6
|
-
import { normalizeStoryPath } from "storybook/internal/common";
|
|
7
|
-
import { storyNameFromExport } from "storybook/internal/csf";
|
|
8
|
-
import { logger } from "storybook/internal/node-logger";
|
|
9
5
|
import * as v from "valibot";
|
|
6
|
+
import { logger } from "storybook/internal/node-logger";
|
|
10
7
|
import { telemetry } from "storybook/internal/telemetry";
|
|
11
8
|
import { stringify } from "picoquery";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { normalizeStoryPath } from "storybook/internal/common";
|
|
11
|
+
import { storyNameFromExport } from "storybook/internal/csf";
|
|
12
|
+
import { ComponentManifestMap, DocsManifestMap, GET_TOOL_NAME, LIST_TOOL_NAME, addGetDocumentationTool, addGetStoryDocumentationTool, addListAllDocumentationTool } from "@storybook/mcp";
|
|
12
13
|
import fs from "node:fs/promises";
|
|
13
|
-
import { ComponentManifestMap, DocsManifestMap, addGetDocumentationTool, addListAllDocumentationTool } from "@storybook/mcp";
|
|
14
14
|
import { buffer } from "node:stream/consumers";
|
|
15
15
|
|
|
16
16
|
//#region package.json
|
|
17
17
|
var name = "@storybook/addon-mcp";
|
|
18
|
-
var version = "0.3.
|
|
18
|
+
var version = "0.3.3";
|
|
19
19
|
var description = "Help agents automatically write and test stories for your UI components";
|
|
20
20
|
|
|
21
21
|
//#endregion
|
|
@@ -98,8 +98,8 @@ function buildArgsParam(args) {
|
|
|
98
98
|
* @returns A promise that resolves to the StoryIndex
|
|
99
99
|
* @throws If the fetch fails or returns invalid data
|
|
100
100
|
*/
|
|
101
|
-
async function fetchStoryIndex(origin
|
|
102
|
-
const indexUrl = `${origin
|
|
101
|
+
async function fetchStoryIndex(origin) {
|
|
102
|
+
const indexUrl = `${origin}/index.json`;
|
|
103
103
|
logger.debug(`Fetching story index from: ${indexUrl}`);
|
|
104
104
|
const response = await fetch(indexUrl);
|
|
105
105
|
if (!response.ok) throw new Error(`Failed to fetch story index: ${response.status} ${response.statusText}`);
|
|
@@ -108,6 +108,83 @@ async function fetchStoryIndex(origin$1) {
|
|
|
108
108
|
return index;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region src/utils/slash.ts
|
|
113
|
+
/**
|
|
114
|
+
* Normalize paths to forward slashes for cross-platform compatibility
|
|
115
|
+
* Storybook import paths always use forward slashes
|
|
116
|
+
*/
|
|
117
|
+
function slash(path) {
|
|
118
|
+
return path.replace(/\\/g, "/");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/utils/find-story-ids.ts
|
|
123
|
+
function isStoryIdInput(input) {
|
|
124
|
+
return "storyId" in input;
|
|
125
|
+
}
|
|
126
|
+
function normalizeImportPath(importPath) {
|
|
127
|
+
return slash(normalizeStoryPath(path.posix.normalize(slash(importPath))));
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Finds story IDs in the story index that match the given story inputs.
|
|
131
|
+
*
|
|
132
|
+
* @param index - The Storybook story index
|
|
133
|
+
* @param stories - Array of story inputs to search for
|
|
134
|
+
* @returns Array of per-input lookup results in the exact same order as the input stories
|
|
135
|
+
*/
|
|
136
|
+
function findStoryIds(index, stories) {
|
|
137
|
+
const entriesList = Object.values(index.entries);
|
|
138
|
+
const result = [];
|
|
139
|
+
for (const storyInput of stories) {
|
|
140
|
+
if (isStoryIdInput(storyInput)) {
|
|
141
|
+
const foundEntry = index.entries[storyInput.storyId];
|
|
142
|
+
if (foundEntry) {
|
|
143
|
+
logger.debug(`Found story ID: ${foundEntry.id}`);
|
|
144
|
+
result.push({
|
|
145
|
+
id: foundEntry.id,
|
|
146
|
+
input: storyInput
|
|
147
|
+
});
|
|
148
|
+
} else {
|
|
149
|
+
logger.debug("No story found");
|
|
150
|
+
result.push({
|
|
151
|
+
input: storyInput,
|
|
152
|
+
errorMessage: `No story found for story ID "${storyInput.storyId}"`
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const { exportName, explicitStoryName, absoluteStoryPath } = storyInput;
|
|
158
|
+
const normalizedCwd = slash(process.cwd());
|
|
159
|
+
const normalizedAbsolutePath = slash(absoluteStoryPath);
|
|
160
|
+
const relativePath = normalizeImportPath(path.posix.relative(normalizedCwd, normalizedAbsolutePath));
|
|
161
|
+
logger.debug("Searching for:");
|
|
162
|
+
logger.debug({
|
|
163
|
+
exportName,
|
|
164
|
+
explicitStoryName,
|
|
165
|
+
absoluteStoryPath,
|
|
166
|
+
relativePath
|
|
167
|
+
});
|
|
168
|
+
const foundEntry = entriesList.find((entry) => normalizeImportPath(entry.importPath) === relativePath && [explicitStoryName, storyNameFromExport(exportName)].includes(entry.name));
|
|
169
|
+
if (foundEntry) {
|
|
170
|
+
logger.debug(`Found story ID: ${foundEntry.id}`);
|
|
171
|
+
result.push({
|
|
172
|
+
id: foundEntry.id,
|
|
173
|
+
input: storyInput
|
|
174
|
+
});
|
|
175
|
+
} else {
|
|
176
|
+
logger.debug("No story found");
|
|
177
|
+
let errorMessage = `No story found for export name "${exportName}" with absolute file path "${absoluteStoryPath}"`;
|
|
178
|
+
if (!explicitStoryName) errorMessage += ` (did you forget to pass the explicit story name?)`;
|
|
179
|
+
result.push({
|
|
180
|
+
input: storyInput,
|
|
181
|
+
errorMessage
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
|
|
111
188
|
//#endregion
|
|
112
189
|
//#region src/utils/errors.ts
|
|
113
190
|
/**
|
|
@@ -127,96 +204,49 @@ const errorToMCPContent = (error) => {
|
|
|
127
204
|
};
|
|
128
205
|
|
|
129
206
|
//#endregion
|
|
130
|
-
//#region src/
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
207
|
+
//#region src/tools/tool-names.ts
|
|
208
|
+
/**
|
|
209
|
+
* Tool name constants extracted to avoid circular dependencies.
|
|
210
|
+
*/
|
|
211
|
+
const PREVIEW_STORIES_TOOL_NAME = "preview-stories";
|
|
135
212
|
const GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME = "get-storybook-story-instructions";
|
|
136
|
-
|
|
137
|
-
server.tool({
|
|
138
|
-
name: GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME,
|
|
139
|
-
title: "Storybook Story Development Instructions",
|
|
140
|
-
description: `Get comprehensive instructions for writing and updating Storybook stories (.stories.tsx, .stories.ts, .stories.jsx, .stories.js, .stories.svelte, .stories.vue files).
|
|
141
|
-
|
|
142
|
-
CRITICAL: You MUST call this tool before:
|
|
143
|
-
- Creating new Storybook stories or story files
|
|
144
|
-
- Updating or modifying existing Storybook stories
|
|
145
|
-
- Adding new story variants or exports to story files
|
|
146
|
-
- Editing any file matching *.stories.* patterns
|
|
147
|
-
- Writing components that will need stories
|
|
148
|
-
|
|
149
|
-
This tool provides essential Storybook-specific guidance including:
|
|
150
|
-
- How to structure stories correctly for Storybook 9
|
|
151
|
-
- Required imports (Meta, StoryObj from framework package)
|
|
152
|
-
- Test utility imports (from 'storybook/test')
|
|
153
|
-
- Story naming conventions and best practices
|
|
154
|
-
- Play function patterns for interactive testing
|
|
155
|
-
- Mocking strategies for external dependencies
|
|
156
|
-
- Story variants and coverage requirements
|
|
157
|
-
|
|
158
|
-
Even if you're familiar with Storybook, call this tool to ensure you're following the correct patterns, import paths, and conventions for this specific Storybook setup.`,
|
|
159
|
-
enabled: () => server.ctx.custom?.toolsets?.dev ?? true
|
|
160
|
-
}, async () => {
|
|
161
|
-
try {
|
|
162
|
-
const { options, disableTelemetry: disableTelemetry$1 } = server.ctx.custom ?? {};
|
|
163
|
-
if (!options) throw new Error("Options are required in addon context");
|
|
164
|
-
if (!disableTelemetry$1) await collectTelemetry({
|
|
165
|
-
event: "tool:getUIBuildingInstructions",
|
|
166
|
-
server,
|
|
167
|
-
toolset: "dev"
|
|
168
|
-
});
|
|
169
|
-
const frameworkPreset = await options.presets.apply("framework");
|
|
170
|
-
const framework = typeof frameworkPreset === "string" ? frameworkPreset : frameworkPreset?.name;
|
|
171
|
-
const renderer = frameworkToRendererMap[framework];
|
|
172
|
-
return { content: [{
|
|
173
|
-
type: "text",
|
|
174
|
-
text: storybook_story_instructions_default.replace("{{FRAMEWORK}}", framework).replace("{{RENDERER}}", renderer ?? framework).replace("{{PREVIEW_STORIES_TOOL_NAME}}", PREVIEW_STORIES_TOOL_NAME)
|
|
175
|
-
}] };
|
|
176
|
-
} catch (error) {
|
|
177
|
-
return errorToMCPContent(error);
|
|
178
|
-
}
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
const frameworkToRendererMap = {
|
|
182
|
-
"@storybook/react-vite": "@storybook/react",
|
|
183
|
-
"@storybook/react-webpack5": "@storybook/react",
|
|
184
|
-
"@storybook/nextjs": "@storybook/react",
|
|
185
|
-
"@storybook/nextjs-vite": "@storybook/react",
|
|
186
|
-
"@storybook/react-native-web-vite": "@storybook/react",
|
|
187
|
-
"@storybook/vue3-vite": "@storybook/vue3",
|
|
188
|
-
"@nuxtjs/storybook": "@storybook/vue3",
|
|
189
|
-
"@storybook/angular": "@storybook/angular",
|
|
190
|
-
"@storybook/svelte-vite": "@storybook/svelte",
|
|
191
|
-
"@storybook/sveltekit": "@storybook/svelte",
|
|
192
|
-
"@storybook/preact-vite": "@storybook/preact",
|
|
193
|
-
"@storybook/web-components-vite": "@storybook/web-components",
|
|
194
|
-
"@storybook/html-vite": "@storybook/html"
|
|
195
|
-
};
|
|
213
|
+
const RUN_STORY_TESTS_TOOL_NAME = "run-story-tests";
|
|
196
214
|
|
|
197
215
|
//#endregion
|
|
198
216
|
//#region src/types.ts
|
|
199
217
|
const AddonOptions = v.object({ toolsets: v.optional(v.object({
|
|
200
218
|
dev: v.exactOptional(v.boolean(), true),
|
|
201
|
-
docs: v.exactOptional(v.boolean(), true)
|
|
219
|
+
docs: v.exactOptional(v.boolean(), true),
|
|
220
|
+
test: v.exactOptional(v.boolean(), true)
|
|
202
221
|
}), {
|
|
203
222
|
dev: true,
|
|
204
|
-
docs: true
|
|
223
|
+
docs: true,
|
|
224
|
+
test: true
|
|
205
225
|
}) });
|
|
206
|
-
|
|
207
|
-
* Schema for a single story input when requesting story URLs.
|
|
208
|
-
*/
|
|
209
|
-
const StoryInput = v.object({
|
|
210
|
-
exportName: v.string(),
|
|
211
|
-
explicitStoryName: v.pipe(v.optional(v.string()), v.description(`If the story has an explicit name set via the "name" propoerty, that is different from the export name, provide it here.
|
|
212
|
-
Otherwise don't set this.`)),
|
|
213
|
-
absoluteStoryPath: v.string(),
|
|
226
|
+
const StoryInputProps = {
|
|
214
227
|
props: v.pipe(v.optional(v.record(v.string(), v.any())), v.description(`Optional custom props to pass to the story for rendering. Use this when you don't want to render the default story,
|
|
215
228
|
but you want to customize some args or other props.
|
|
216
229
|
You can look up the component's documentation using the ${GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME} tool to see what props are available.`)),
|
|
217
230
|
globals: v.pipe(v.optional(v.record(v.string(), v.any())), v.description(`Optional Storybook globals to set for the story preview. Globals are used for things like theme, locale, viewport, and other cross-cutting concerns.
|
|
218
231
|
Common globals include 'theme' (e.g., 'dark', 'light'), 'locale' (e.g., 'en', 'fr'), and 'backgrounds' (e.g., { value: '#000' }).`))
|
|
219
|
-
}
|
|
232
|
+
};
|
|
233
|
+
/**
|
|
234
|
+
* Schema for a single story input when requesting story URLs.
|
|
235
|
+
*/
|
|
236
|
+
const StoryInput = v.union([v.object({
|
|
237
|
+
exportName: v.pipe(v.string(), v.description(`The export name of the story from the story file.
|
|
238
|
+
Use this path-based shape only when you're already editing a .stories.* file and know the export names in that file.
|
|
239
|
+
If you do not already have story file context, prefer the storyId shape instead of searching files.`)),
|
|
240
|
+
explicitStoryName: v.pipe(v.optional(v.string()), v.description(`If the story has an explicit name set via the "name" property, that is different from the export name, provide it here.
|
|
241
|
+
Otherwise don't set this.`)),
|
|
242
|
+
absoluteStoryPath: v.pipe(v.string(), v.description("Absolute path to the story file. Use together with exportName only when story file context is already available.")),
|
|
243
|
+
...StoryInputProps
|
|
244
|
+
}), v.object({
|
|
245
|
+
storyId: v.pipe(v.string(), v.description(`The full Storybook story ID (for example "button--primary").
|
|
246
|
+
Prefer this shape whenever you are not already working in a specific story file.
|
|
247
|
+
Use IDs discovered from ${LIST_TOOL_NAME} (withStoryIds=true) or ${GET_TOOL_NAME}.`)),
|
|
248
|
+
...StoryInputProps
|
|
249
|
+
})]);
|
|
220
250
|
/**
|
|
221
251
|
* Schema for the array of stories to fetch URLs for.
|
|
222
252
|
*/
|
|
@@ -226,25 +256,17 @@ const StoryInputArray = v.array(StoryInput);
|
|
|
226
256
|
//#region src/tools/preview-stories/preview-stories-app-template.html
|
|
227
257
|
var preview_stories_app_template_default = "<!doctype html>\n<html>\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <style>\n * {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n }\n html[data-theme='light'] {\n color-scheme: light;\n }\n html[data-theme='dark'] {\n color-scheme: dark;\n }\n html,\n body {\n width: 100%;\n margin: 0;\n padding: 0;\n background-color: var(--color-background-secondary);\n }\n body {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n }\n :root {\n /*\n These are fallback values, if the MCP client doesn't set them.\n See https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#theming\n */\n --color-text-primary: light-dark(black, white);\n --color-border-primary: light-dark(#ccc, #444);\n --color-background-secondary: light-dark(#f9f9f9, #1e1e1e);\n --font-sans:\n -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Oxygen-Sans', Ubuntu, Cantarell,\n 'Helvetica Neue', sans-serif;\n --font-heading-xs-size: 1rem;\n --font-heading-xs-line-height: 1.25;\n --border-width-regular: 1px;\n }\n .story-heading {\n font-family: var(--font-sans);\n font-size: var(--font-heading-xs-size);\n line-height: var(--font-heading-xs-line-height);\n color: var(--color-text-primary);\n padding: 0.5rem 0;\n }\n .story-iframe {\n background-color: white;\n border: var(--border-width-regular) solid var(--color-border-primary);\n }\n body:has(> article:only-child) h1 {\n /* if there is only one story rendered, hide its heading */\n display: none;\n }\n </style>\n <template id=\"preview-template\">\n <article>\n <h1 class=\"story-heading\"></h1>\n <iframe class=\"story-iframe\"></iframe>\n </article>\n </template>\n <script type=\"module\">\n // APP_SCRIPT_PLACEHOLDER\n <\/script>\n </head>\n <body></body>\n</html>\n";
|
|
228
258
|
|
|
229
|
-
//#endregion
|
|
230
|
-
//#region src/utils/slash.ts
|
|
231
|
-
/**
|
|
232
|
-
* Normalize paths to forward slashes for cross-platform compatibility
|
|
233
|
-
* Storybook import paths always use forward slashes
|
|
234
|
-
*/
|
|
235
|
-
function slash(path$1) {
|
|
236
|
-
return path$1.replace(/\\/g, "/");
|
|
237
|
-
}
|
|
238
|
-
|
|
239
259
|
//#endregion
|
|
240
260
|
//#region src/tools/preview-stories.ts
|
|
241
|
-
const PREVIEW_STORIES_TOOL_NAME = "preview-stories";
|
|
242
261
|
const PREVIEW_STORIES_RESOURCE_URI = `ui://${PREVIEW_STORIES_TOOL_NAME}/preview.html`;
|
|
243
|
-
const PreviewStoriesInput = v.object({ stories: StoryInputArray
|
|
262
|
+
const PreviewStoriesInput = v.object({ stories: v.pipe(StoryInputArray, v.description(`Stories to preview.
|
|
263
|
+
Prefer { storyId } when you don't already have story file context, since this avoids filesystem discovery.
|
|
264
|
+
Use { storyId } when IDs were discovered from documentation tools.
|
|
265
|
+
Use { absoluteStoryPath + exportName } only when you're already working in a specific .stories.* file and already have that context.`)) });
|
|
244
266
|
const PreviewStoriesOutput = v.object({ stories: v.array(v.union([v.object({
|
|
245
267
|
title: v.string(),
|
|
246
268
|
name: v.string(),
|
|
247
|
-
previewUrl: v.string()
|
|
269
|
+
previewUrl: v.pipe(v.string(), v.description("Direct URL to open the story preview. Always include this URL in the final user-facing response so users can open it directly."))
|
|
248
270
|
}), v.object({
|
|
249
271
|
input: StoryInput,
|
|
250
272
|
error: v.string()
|
|
@@ -252,86 +274,77 @@ const PreviewStoriesOutput = v.object({ stories: v.array(v.union([v.object({
|
|
|
252
274
|
async function addPreviewStoriesTool(server) {
|
|
253
275
|
const previewStoryAppScript = await fs.readFile(url.fileURLToPath(import.meta.resolve("@storybook/addon-mcp/internal/preview-stories-app-script")), "utf-8");
|
|
254
276
|
const appHtml = preview_stories_app_template_default.replace("// APP_SCRIPT_PLACEHOLDER", previewStoryAppScript);
|
|
255
|
-
const normalizeImportPath = (importPath) => {
|
|
256
|
-
return slash(normalizeStoryPath(path.posix.normalize(slash(importPath))));
|
|
257
|
-
};
|
|
258
277
|
server.resource({
|
|
259
278
|
name: PREVIEW_STORIES_RESOURCE_URI,
|
|
260
279
|
description: "App resource for the Preview Stories tool",
|
|
261
280
|
uri: PREVIEW_STORIES_RESOURCE_URI,
|
|
262
281
|
mimeType: "text/html;profile=mcp-app"
|
|
263
282
|
}, () => {
|
|
264
|
-
const origin
|
|
283
|
+
const origin = server.ctx.custom.origin;
|
|
265
284
|
return { contents: [{
|
|
266
285
|
uri: PREVIEW_STORIES_RESOURCE_URI,
|
|
267
286
|
mimeType: "text/html;profile=mcp-app",
|
|
268
287
|
text: appHtml,
|
|
269
288
|
_meta: { ui: {
|
|
270
289
|
prefersBorder: false,
|
|
271
|
-
domain: origin
|
|
290
|
+
domain: origin,
|
|
272
291
|
csp: {
|
|
273
|
-
connectDomains: [origin
|
|
274
|
-
resourceDomains: [origin
|
|
275
|
-
frameDomains: [origin
|
|
276
|
-
baseUriDomains: [origin
|
|
292
|
+
connectDomains: [origin],
|
|
293
|
+
resourceDomains: [origin],
|
|
294
|
+
frameDomains: [origin],
|
|
295
|
+
baseUriDomains: [origin]
|
|
277
296
|
}
|
|
278
297
|
} }
|
|
279
298
|
}] };
|
|
280
299
|
});
|
|
281
300
|
server.tool({
|
|
282
301
|
name: PREVIEW_STORIES_TOOL_NAME,
|
|
283
|
-
title: "
|
|
284
|
-
description: `Use this tool to
|
|
302
|
+
title: "Get story preview URLs",
|
|
303
|
+
description: `Use this tool to get one or more Storybook preview URLs.
|
|
304
|
+
Always include each returned preview URL in your final user-facing response so users can open them directly.`,
|
|
285
305
|
schema: PreviewStoriesInput,
|
|
286
306
|
outputSchema: PreviewStoriesOutput,
|
|
287
307
|
enabled: () => server.ctx.custom?.toolsets?.dev ?? true,
|
|
288
308
|
_meta: { ui: { resourceUri: PREVIEW_STORIES_RESOURCE_URI } }
|
|
289
309
|
}, async (input) => {
|
|
290
310
|
try {
|
|
291
|
-
const { origin
|
|
292
|
-
if (!origin
|
|
293
|
-
const index = await fetchStoryIndex(origin
|
|
294
|
-
const
|
|
311
|
+
const { origin, disableTelemetry } = server.ctx.custom ?? {};
|
|
312
|
+
if (!origin) throw new Error("Origin is required in addon context");
|
|
313
|
+
const index = await fetchStoryIndex(origin);
|
|
314
|
+
const resolvedStories = findStoryIds(index, input.stories);
|
|
295
315
|
const structuredResult = [];
|
|
296
316
|
const textResult = [];
|
|
297
|
-
for (const
|
|
298
|
-
|
|
299
|
-
const normalizedCwd = slash(process.cwd());
|
|
300
|
-
const normalizedAbsolutePath = slash(absoluteStoryPath);
|
|
301
|
-
const relativePath = normalizeImportPath(path.posix.relative(normalizedCwd, normalizedAbsolutePath));
|
|
302
|
-
logger.debug("Searching for:");
|
|
303
|
-
logger.debug({
|
|
304
|
-
exportName,
|
|
305
|
-
explicitStoryName,
|
|
306
|
-
absoluteStoryPath,
|
|
307
|
-
relativePath
|
|
308
|
-
});
|
|
309
|
-
const foundStory = entriesList.find((entry) => normalizeImportPath(entry.importPath) === relativePath && [explicitStoryName, storyNameFromExport(exportName)].includes(entry.name));
|
|
310
|
-
if (foundStory) {
|
|
311
|
-
logger.debug(`Found story ID: ${foundStory.id}`);
|
|
312
|
-
let previewUrl = `${origin$1}/?path=/story/${foundStory.id}`;
|
|
313
|
-
const argsParam = buildArgsParam(inputParams.props ?? {});
|
|
314
|
-
if (argsParam) previewUrl += `&args=${argsParam}`;
|
|
315
|
-
const globalsParam = buildArgsParam(inputParams.globals ?? {});
|
|
316
|
-
if (globalsParam) previewUrl += `&globals=${globalsParam}`;
|
|
317
|
+
for (const story of resolvedStories) {
|
|
318
|
+
if ("errorMessage" in story) {
|
|
317
319
|
structuredResult.push({
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
previewUrl
|
|
320
|
+
input: story.input,
|
|
321
|
+
error: story.errorMessage
|
|
321
322
|
});
|
|
322
|
-
textResult.push(
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
323
|
+
textResult.push(story.errorMessage);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const indexEntry = index.entries[story.id];
|
|
327
|
+
if (!indexEntry) {
|
|
327
328
|
structuredResult.push({
|
|
328
|
-
input:
|
|
329
|
-
error:
|
|
329
|
+
input: story.input,
|
|
330
|
+
error: `No story found for story ID "${story.id}"`
|
|
330
331
|
});
|
|
331
|
-
textResult.push(
|
|
332
|
+
textResult.push(`No story found for story ID "${story.id}"`);
|
|
333
|
+
continue;
|
|
332
334
|
}
|
|
335
|
+
let previewUrl = `${origin}/?path=/story/${story.id}`;
|
|
336
|
+
const argsParam = buildArgsParam(story.input.props ?? {});
|
|
337
|
+
if (argsParam) previewUrl += `&args=${argsParam}`;
|
|
338
|
+
const globalsParam = buildArgsParam(story.input.globals ?? {});
|
|
339
|
+
if (globalsParam) previewUrl += `&globals=${globalsParam}`;
|
|
340
|
+
structuredResult.push({
|
|
341
|
+
title: indexEntry.title,
|
|
342
|
+
name: indexEntry.name,
|
|
343
|
+
previewUrl
|
|
344
|
+
});
|
|
345
|
+
textResult.push(previewUrl);
|
|
333
346
|
}
|
|
334
|
-
if (!disableTelemetry
|
|
347
|
+
if (!disableTelemetry) await collectTelemetry({
|
|
335
348
|
event: "tool:previewStories",
|
|
336
349
|
server,
|
|
337
350
|
toolset: "dev",
|
|
@@ -351,6 +364,402 @@ async function addPreviewStoriesTool(server) {
|
|
|
351
364
|
});
|
|
352
365
|
}
|
|
353
366
|
|
|
367
|
+
//#endregion
|
|
368
|
+
//#region src/tools/run-story-tests.ts
|
|
369
|
+
/**
|
|
370
|
+
* Check if addon-vitest is available by trying to import its constants.
|
|
371
|
+
* Returns the constants if available, undefined otherwise.
|
|
372
|
+
*/
|
|
373
|
+
async function getAddonVitestConstants() {
|
|
374
|
+
try {
|
|
375
|
+
const mod = await import("@storybook/addon-vitest/constants");
|
|
376
|
+
return {
|
|
377
|
+
TRIGGER_TEST_RUN_REQUEST: mod.TRIGGER_TEST_RUN_REQUEST,
|
|
378
|
+
TRIGGER_TEST_RUN_RESPONSE: mod.TRIGGER_TEST_RUN_RESPONSE
|
|
379
|
+
};
|
|
380
|
+
} catch {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const RunStoryTestsInput = v.object({
|
|
385
|
+
stories: v.optional(v.pipe(StoryInputArray, v.description(`Stories to test for focused feedback. Omit this field to run tests for all available stories.
|
|
386
|
+
Prefer running tests for specific stories while developing to get faster feedback,
|
|
387
|
+
and only omit this when you explicitly need to run all tests for comprehensive verification.
|
|
388
|
+
Prefer { storyId } when you don't already have story file context, since this avoids filesystem discovery.
|
|
389
|
+
Use { storyId } when IDs were discovered from documentation tools.
|
|
390
|
+
Use { absoluteStoryPath + exportName } only when you're currently working in a story file and already know those values.`))),
|
|
391
|
+
a11y: v.optional(v.pipe(v.boolean(), v.description("Whether to run accessibility tests. Defaults to true. Disable if you only need component test results.")), true)
|
|
392
|
+
});
|
|
393
|
+
/**
|
|
394
|
+
* Creates a queue that ensures concurrent calls are executed in sequence.
|
|
395
|
+
* Call `wait()` to wait for your turn, then call the
|
|
396
|
+
* returned `done()` function when done to unblock the next caller.
|
|
397
|
+
*/
|
|
398
|
+
function createAsyncQueue() {
|
|
399
|
+
let tail = Promise.resolve();
|
|
400
|
+
/**
|
|
401
|
+
* Wait for all previously queued operations to complete, then return
|
|
402
|
+
* a `done` function that must be called when the current operation finishes.
|
|
403
|
+
*/
|
|
404
|
+
async function wait() {
|
|
405
|
+
let done;
|
|
406
|
+
const gate = new Promise((resolve) => {
|
|
407
|
+
done = resolve;
|
|
408
|
+
});
|
|
409
|
+
const previousTail = tail;
|
|
410
|
+
tail = previousTail.then(() => gate, () => gate);
|
|
411
|
+
await previousTail.catch(() => {});
|
|
412
|
+
return done;
|
|
413
|
+
}
|
|
414
|
+
return { wait };
|
|
415
|
+
}
|
|
416
|
+
async function addRunStoryTestsTool(server, { a11yEnabled }) {
|
|
417
|
+
const addonVitestConstants = await getAddonVitestConstants();
|
|
418
|
+
const testRunQueue = createAsyncQueue();
|
|
419
|
+
const description = `Run story tests.
|
|
420
|
+
Provide stories for focused runs (faster while iterating),
|
|
421
|
+
or omit stories to run all tests for full-project verification.
|
|
422
|
+
Use this continuously to monitor test results as you work on your UI components and stories.
|
|
423
|
+
Results will include passing/failing status` + (a11yEnabled ? `, and accessibility violation reports.
|
|
424
|
+
For visual/design accessibility violations (for example color contrast), ask the user before changing styles.` : ".");
|
|
425
|
+
server.tool({
|
|
426
|
+
name: RUN_STORY_TESTS_TOOL_NAME,
|
|
427
|
+
title: "Storybook Tests",
|
|
428
|
+
description,
|
|
429
|
+
schema: RunStoryTestsInput,
|
|
430
|
+
enabled: () => {
|
|
431
|
+
if (!addonVitestConstants) return false;
|
|
432
|
+
return server.ctx.custom?.toolsets?.test ?? true;
|
|
433
|
+
}
|
|
434
|
+
}, async (input) => {
|
|
435
|
+
let done;
|
|
436
|
+
try {
|
|
437
|
+
done = await testRunQueue.wait();
|
|
438
|
+
const runA11y = input.a11y ?? true;
|
|
439
|
+
const { origin, options, disableTelemetry } = server.ctx.custom ?? {};
|
|
440
|
+
if (!origin) throw new Error("Origin is required in addon context");
|
|
441
|
+
if (!options) throw new Error("Options are required in addon context");
|
|
442
|
+
const channel = options.channel;
|
|
443
|
+
if (!channel) throw new Error("Channel is not available");
|
|
444
|
+
let storyIds;
|
|
445
|
+
let inputStoryCount = 0;
|
|
446
|
+
if (input.stories) {
|
|
447
|
+
const resolvedStories = findStoryIds(await fetchStoryIndex(origin), input.stories);
|
|
448
|
+
storyIds = resolvedStories.filter((story) => "id" in story).map((story) => story.id);
|
|
449
|
+
inputStoryCount = input.stories.length;
|
|
450
|
+
if (storyIds.length === 0) {
|
|
451
|
+
const errorMessages = resolvedStories.filter((story) => "errorMessage" in story).map((story) => story.errorMessage).join("\n");
|
|
452
|
+
if (!disableTelemetry) await collectTelemetry({
|
|
453
|
+
event: "tool:runStoryTests",
|
|
454
|
+
server,
|
|
455
|
+
toolset: "test",
|
|
456
|
+
runA11y,
|
|
457
|
+
inputStoryCount,
|
|
458
|
+
matchedStoryCount: 0,
|
|
459
|
+
passingStoryCount: 0,
|
|
460
|
+
failingStoryCount: 0,
|
|
461
|
+
a11yViolationCount: 0,
|
|
462
|
+
unhandledErrorCount: 0
|
|
463
|
+
});
|
|
464
|
+
return { content: [{
|
|
465
|
+
type: "text",
|
|
466
|
+
text: `No stories found matching the provided input.
|
|
467
|
+
|
|
468
|
+
${errorMessages}`
|
|
469
|
+
}] };
|
|
470
|
+
}
|
|
471
|
+
logger.info(`Running focused tests for story IDs: ${storyIds.join(", ")}`);
|
|
472
|
+
} else logger.info("Running tests for all stories");
|
|
473
|
+
const testResults = (await triggerTestRun(channel, addonVitestConstants.TRIGGER_TEST_RUN_REQUEST, addonVitestConstants.TRIGGER_TEST_RUN_RESPONSE, storyIds, { a11y: runA11y })).result;
|
|
474
|
+
if (!testResults) throw new Error("Test run response missing result data");
|
|
475
|
+
const { text, summary } = formatRunStoryTestResults({
|
|
476
|
+
testResults,
|
|
477
|
+
runA11y,
|
|
478
|
+
origin
|
|
479
|
+
});
|
|
480
|
+
if (!disableTelemetry) await collectTelemetry({
|
|
481
|
+
event: "tool:runStoryTests",
|
|
482
|
+
server,
|
|
483
|
+
toolset: "test",
|
|
484
|
+
runA11y,
|
|
485
|
+
inputStoryCount,
|
|
486
|
+
matchedStoryCount: testResults.storyIds?.length ?? storyIds?.length ?? 0,
|
|
487
|
+
...summary
|
|
488
|
+
});
|
|
489
|
+
return { content: [{
|
|
490
|
+
type: "text",
|
|
491
|
+
text
|
|
492
|
+
}] };
|
|
493
|
+
} catch (error) {
|
|
494
|
+
return errorToMCPContent(error);
|
|
495
|
+
} finally {
|
|
496
|
+
try {
|
|
497
|
+
done?.();
|
|
498
|
+
} catch (error) {
|
|
499
|
+
logger.warn(`Failed to release test run queue: ${String(error)}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Trigger a test run via Storybook channel events.
|
|
506
|
+
* This is the channel-based API for triggering tests in addon-vitest.
|
|
507
|
+
*/
|
|
508
|
+
function triggerTestRun(channel, triggerTestRunRequestEventName, triggerTestRunResponseEventName, storyIds, config) {
|
|
509
|
+
return new Promise((resolve, reject) => {
|
|
510
|
+
const requestId = `mcp-${Date.now()}`;
|
|
511
|
+
let settled = false;
|
|
512
|
+
const cleanup = () => {
|
|
513
|
+
channel.off(triggerTestRunResponseEventName, handleResponse);
|
|
514
|
+
};
|
|
515
|
+
const settle = (callback) => {
|
|
516
|
+
if (settled) return;
|
|
517
|
+
settled = true;
|
|
518
|
+
cleanup();
|
|
519
|
+
callback();
|
|
520
|
+
};
|
|
521
|
+
const handleResponse = (payload) => {
|
|
522
|
+
if (payload.requestId !== requestId) return;
|
|
523
|
+
switch (payload.status) {
|
|
524
|
+
case "completed":
|
|
525
|
+
if (payload.result) settle(() => resolve(payload));
|
|
526
|
+
else settle(() => reject(/* @__PURE__ */ new Error("Test run completed but no result was returned")));
|
|
527
|
+
break;
|
|
528
|
+
case "error":
|
|
529
|
+
settle(() => reject(new Error(payload.error?.message ?? "Test run failed with unknown error")));
|
|
530
|
+
break;
|
|
531
|
+
case "cancelled":
|
|
532
|
+
settle(() => reject(/* @__PURE__ */ new Error("Test run was cancelled")));
|
|
533
|
+
break;
|
|
534
|
+
default: settle(() => reject(/* @__PURE__ */ new Error("Unexpected test run response")));
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
channel.on(triggerTestRunResponseEventName, handleResponse);
|
|
538
|
+
const request = {
|
|
539
|
+
requestId,
|
|
540
|
+
actor: "addon-mcp",
|
|
541
|
+
storyIds,
|
|
542
|
+
config
|
|
543
|
+
};
|
|
544
|
+
try {
|
|
545
|
+
channel.emit(triggerTestRunRequestEventName, request);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
function formatRunStoryTestResults({ testResults, runA11y, origin }) {
|
|
552
|
+
const sections = [];
|
|
553
|
+
const componentTestStatuses = testResults.componentTestStatuses;
|
|
554
|
+
const passingStories = componentTestStatuses.filter((status) => status.value === "status-value:success");
|
|
555
|
+
const failingStories = componentTestStatuses.filter((status) => status.value === "status-value:error");
|
|
556
|
+
if (passingStories.length > 0) sections.push(formatPassingStoriesSection(passingStories));
|
|
557
|
+
if (failingStories.length > 0) sections.push(formatFailingStoriesSection(failingStories));
|
|
558
|
+
const a11yReports = testResults.a11yReports;
|
|
559
|
+
const a11yViolationCount = runA11y ? countA11yViolations(a11yReports) : 0;
|
|
560
|
+
if (runA11y && a11yReports && Object.keys(a11yReports).length > 0) {
|
|
561
|
+
const a11ySection = formatA11yReportsSection({
|
|
562
|
+
a11yReports,
|
|
563
|
+
origin
|
|
564
|
+
});
|
|
565
|
+
if (a11ySection) sections.push(a11ySection);
|
|
566
|
+
}
|
|
567
|
+
if (testResults.unhandledErrors.length > 0) sections.push(formatUnhandledErrorsSection(testResults.unhandledErrors));
|
|
568
|
+
return {
|
|
569
|
+
text: sections.join("\n\n"),
|
|
570
|
+
summary: {
|
|
571
|
+
passingStoryCount: passingStories.length,
|
|
572
|
+
failingStoryCount: failingStories.length,
|
|
573
|
+
a11yViolationCount,
|
|
574
|
+
unhandledErrorCount: testResults.unhandledErrors.length
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
function formatPassingStoriesSection(passingStories) {
|
|
579
|
+
return `## Passing Stories
|
|
580
|
+
|
|
581
|
+
- ${passingStories.map((status) => status.storyId).join("\n- ")}`;
|
|
582
|
+
}
|
|
583
|
+
function formatFailingStoriesSection(statuses) {
|
|
584
|
+
return `## Failing Stories
|
|
585
|
+
|
|
586
|
+
${statuses.map((status) => `### ${status.storyId}
|
|
587
|
+
|
|
588
|
+
${status.description || "No failure details available."}`).join("\n\n")}`;
|
|
589
|
+
}
|
|
590
|
+
function formatA11yReportsSection({ a11yReports, origin }) {
|
|
591
|
+
const a11yViolationSections = [];
|
|
592
|
+
for (const [storyId, reports] of Object.entries(a11yReports)) for (const report of reports) {
|
|
593
|
+
if ("error" in report && report.error) {
|
|
594
|
+
a11yViolationSections.push(`### ${storyId} - Error
|
|
595
|
+
|
|
596
|
+
${report.error.message}`);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
const violations = getA11yViolations(report);
|
|
600
|
+
if (violations.length === 0) continue;
|
|
601
|
+
for (const violation of violations) {
|
|
602
|
+
const nodes = violation.nodes.map((node) => {
|
|
603
|
+
const inspectLink = node.linkPath ? `${origin}${node.linkPath}` : void 0;
|
|
604
|
+
const parts = [];
|
|
605
|
+
if (node.impact) parts.push(`- **Impact**: ${node.impact}`);
|
|
606
|
+
if (node.failureSummary || node.message) parts.push(` **Message**: ${node.failureSummary || node.message}`);
|
|
607
|
+
parts.push(` **Element**: ${node.html || "(no html available)"}`);
|
|
608
|
+
if (inspectLink) parts.push(` **Inspect**: ${inspectLink}`);
|
|
609
|
+
return parts.join("\n");
|
|
610
|
+
}).join("\n");
|
|
611
|
+
a11yViolationSections.push(`### ${storyId} - ${violation.id}
|
|
612
|
+
|
|
613
|
+
${violation.description}
|
|
614
|
+
|
|
615
|
+
#### Affected Elements
|
|
616
|
+
${nodes}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (a11yViolationSections.length === 0) return;
|
|
620
|
+
return `## Accessibility Violations
|
|
621
|
+
|
|
622
|
+
${a11yViolationSections.join("\n\n")}`;
|
|
623
|
+
}
|
|
624
|
+
function formatUnhandledErrorsSection(errors) {
|
|
625
|
+
return `## Unhandled Errors
|
|
626
|
+
|
|
627
|
+
${errors.map((unhandledError) => `### ${unhandledError.name || "Unknown Error"}
|
|
628
|
+
|
|
629
|
+
**Error message**: ${unhandledError.message || "No message available"}
|
|
630
|
+
**Path**: ${unhandledError.VITEST_TEST_PATH || "No path available"}
|
|
631
|
+
**Test name**: ${unhandledError.VITEST_TEST_NAME || "No test name available"}
|
|
632
|
+
**Stack trace**:
|
|
633
|
+
${unhandledError.stack || "No stack trace available"}`).join("\n\n")}`;
|
|
634
|
+
}
|
|
635
|
+
function countA11yViolations(a11yReports) {
|
|
636
|
+
let count = 0;
|
|
637
|
+
for (const reports of Object.values(a11yReports ?? {})) for (const report of reports) {
|
|
638
|
+
if ("error" in report && report.error) continue;
|
|
639
|
+
count += getA11yViolations(report).length;
|
|
640
|
+
}
|
|
641
|
+
return count;
|
|
642
|
+
}
|
|
643
|
+
function getA11yViolations(report) {
|
|
644
|
+
if (!("violations" in report)) return [];
|
|
645
|
+
const { violations } = report;
|
|
646
|
+
if (!Array.isArray(violations)) return [];
|
|
647
|
+
return violations.map((violation) => ({
|
|
648
|
+
id: violation.id,
|
|
649
|
+
description: violation.description,
|
|
650
|
+
nodes: violation.nodes.map((node) => ({
|
|
651
|
+
impact: typeof node.impact === "string" ? node.impact : void 0,
|
|
652
|
+
failureSummary: typeof node.failureSummary === "string" ? node.failureSummary : void 0,
|
|
653
|
+
html: typeof node.html === "string" ? node.html : void 0,
|
|
654
|
+
linkPath: typeof node.linkPath === "string" ? node.linkPath : void 0
|
|
655
|
+
}))
|
|
656
|
+
}));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
//#endregion
|
|
660
|
+
//#region src/instructions/storybook-story-instructions.md
|
|
661
|
+
var storybook_story_instructions_default = "# Writing User Interfaces\n\nWhen writing UI, prefer breaking larger components up into smaller parts.\n\nALWAYS write a Storybook story for any component written. If editing a component, ensure appropriate changes have been made to stories for that component.\n\n## How to write good stories\n\nGoal: Cover every distinct piece of business logic and state the component can reach (happy paths, error/edge states, loading, permissions/roles, empty states, variations from props/context). Avoid redundant stories that show the same logic.\n\nInteractivity: If the component is interactive, add Interaction tests using play functions that drive the UI with storybook/test utilities (e.g., fn, userEvent, expect). Simulate key user flows: clicking buttons/links, typing, focus/blur, keyboard nav, form submit, async responses, toggle/selection changes, pagination/filters, etc. When passing `fn` functions as `args` for callback functions, make sure to add a play function which interacts with the component and assert whether the callback function was actually called.\n\nData/setup: Provide realistic props, state, and mocked data. Include meaningful labels/text to make behaviors observable. Stub network/services with deterministic fixtures; keep stories reliable.\n\nAssertions: In play functions, assert the visible outcome of the interaction (text, aria state, enabled/disabled, class/state changes, emitted events). Prefer role/label-based queries.\n\nVariants to consider (pick only those that change behavior): default vs. alternate themes; loading vs. loaded vs. empty vs. error; validated vs. invalid input; permissions/roles/capabilities; feature flags; size/density/layout variants that alter logic.\n\nAccessibility: Use semantic roles/labels; ensure focusable/keyboard interactions are test-covered where relevant.\n\nNaming/structure: Use clear story names that describe the scenario (“Error state after failed submit”). Group related variants logically; don’t duplicate.\n\nImports/format: Import Meta/StoryObj from the framework package; import test helpers from storybook/test (not @storybook/test). Keep stories minimal—only what's needed to demonstrate behavior.\n\n## Storybook 9 Essential Changes for Story Writing\n\n### Package Consolidation\n\n#### `Meta` and `StoryObj` imports\n\nUpdate story imports to use the framework package:\n\n```diff\n- import { Meta, StoryObj } from '{{RENDERER}}';\n+ import { Meta, StoryObj } from '{{FRAMEWORK}}';\n```\n\n#### Test utility imports\n\nUpdate test imports to use `storybook/test` instead of `@storybook/test`\n\n```diff\n- import { fn } from '@storybook/test';\n+ import { fn } from 'storybook/test';\n```\n\n### Global State Changes\n\nThe `globals` annotation has be renamed to `initialGlobals`:\n\n```diff\n// .storybook/preview.js\nexport default {\n- globals: { theme: 'light' }\n+ initialGlobals: { theme: 'light' }\n};\n```\n\n### Autodocs Configuration\n\nInstead of `parameters.docs.autodocs` in main.js, use tags:\n\n```js\n// .storybook/preview.js or in individual stories\nexport default {\n tags: ['autodocs'], // generates autodocs for all stories\n};\n```\n\n### Mocking imports in Storybook\n\nTo mock imports in Storybook, use Storybook's mocking features. ALWAYS mock external dependencies to ensure stories render consistently.\n\n1. **Register in the mock in Storybook's preview file**:\n To mock dependendencies, you MUST register a module mock in `.storybook/preview.ts` (or equivalent):\n\n```js\nimport { sb } from 'storybook/test';\n\n// Prefer spy mocks (keeps functions, but allows to override them and spy on them)\nsb.mock(import('some-library'), { spy: true });\n```\n\n**Important: Use file extensions when referring to relative files!**\n\n```js\nsb.mock(import('./relative/module.ts'), { spy: true });\n```\n\n2. **Specify mock values in stories**:\n You can override the behaviour of the mocks per-story using `beforeEach` and the `mocked()` type function:\n\n```js\nimport { expect, mocked, fn } from 'storybook/test';\nimport { library } from 'some-library';\n\nconst meta = {\n component: AuthButton,\n beforeEach: async () => {\n mocked(library).mockResolvedValue({ user: 'data' });\n },\n};\n\nexport const LoggedIn: Story = {\n play: async ({ canvas }) => {\n await expect(library).toHaveBeenCalled();\n },\n};\n```\n\nBefore doing this ensure you have mocked the import in the preview file.\n\n### Play Function Parameters\n\n- The play function has a `canvas` parameter that can be used directly with testing-library-like query methods.\n- It also has a `canvasElement` which is the actual DOM element.\n- The `within`-function imported from `storybook/test` transforms a DOM element to an object with query methods, similar to `canvas`.\n\n**DO NOT** use `within(canvas)` - it is redundant because `canvas` already has the query methods, `canvas` is not a DOM element.\n\n```ts\n// ✅ Correct: Use canvas directly\nplay: async ({ canvas }) => {\n await canvas.getByLabelText('Submit').click();\n};\n\n// ⚠️ Also acceptable: Use `canvasElement` with `within`\nimport { within } from 'storybook/test';\n\nplay: async ({ canvasElement }) => {\n const canvas = within(canvasElement);\n await canvas.getByLabelText('Submit').click();\n};\n\n// ❌ Wrong: Do NOT use within(canvas)\nplay: async ({ canvas }) => {\n const screen = within(canvas); // Error!\n};\n```\n\n### Key Requirements\n\n- **Node.js 20+**, **TypeScript 4.9+**\n- React Native uses `.rnstorybook` directory\n\n## Story Linking Agent Behavior\n\n- ALWAYS provide story links after any changes to stories files, including changes to existing stories.\n- After changing any UI components, ALWAYS search for related stories that might cover the changes you've made. If you find any, provide the story links to the user. THIS IS VERY IMPORTANT, as it allows the user to visually inspect the changes you've made. Even later in a session when changing UI components or stories that have already been linked to previously, YOU MUST PROVIDE THE LINKS AGAIN.\n- Use the {{PREVIEW_STORIES_TOOL_NAME}} tool to get the correct URLs for links to stories.\n";
|
|
662
|
+
|
|
663
|
+
//#endregion
|
|
664
|
+
//#region src/instructions/story-testing-instructions.md
|
|
665
|
+
var story_testing_instructions_default = "## Story Testing Requirements\n\n**Run `{{RUN_STORY_TESTS_TOOL_NAME}}` after EVERY component or story change.** This includes creating, modifying, or refactoring components, stories, or their dependencies.\n\n### Workflow\n\n1. Make your change\n2. Run `{{RUN_STORY_TESTS_TOOL_NAME}}` with affected stories for focused feedback (faster while iterating)\n3. If tests fail: analyze, fix{{A11Y_FIX_SUFFIX}}, re-run\n4. Repeat until all tests pass\n\nDo not skip tests, ignore failures, or move on with failing tests. If stuck after multiple attempts, report to user.\n\n### Focused vs. full-suite test runs\n\n- Prefer focused runs (`stories` input) during development to validate the parts you changed quickly.\n- Run all tests (omit `stories`) before final handoff, after broad/refactor changes, or when impact is unclear and you need project-wide verification.\n";
|
|
666
|
+
|
|
667
|
+
//#endregion
|
|
668
|
+
//#region src/instructions/a11y-instructions.md
|
|
669
|
+
var a11y_instructions_default = "### Accessibility Violations\n\n**Fix automatically** (semantic/structural, no visual change):\n\n- ARIA attributes, roles, labels, alt text\n- Heading hierarchy, landmarks, table structure\n- Keyboard access (tabindex, focus, handlers)\n- Document-level: lang attr, frame titles, duplicate IDs\n\n**Confirm with user first** (visual/design changes):\n\n- Color contrast ratios\n- Font sizes, spacing, layout\n- Focus indicator styling\n\nDescribe the issue, ask how the user wants to proceed, and provide 2-3 concrete options.\nDo not auto-apply visual changes before user confirmation, and do not claim visual issues are fixed until they approve an option.\n";
|
|
670
|
+
|
|
671
|
+
//#endregion
|
|
672
|
+
//#region src/utils/is-addon-a11y-enabled.ts
|
|
673
|
+
/**
|
|
674
|
+
* Check if @storybook/addon-a11y is enabled in the Storybook configuration.
|
|
675
|
+
*/
|
|
676
|
+
async function isAddonA11yEnabled(options) {
|
|
677
|
+
try {
|
|
678
|
+
return await options.presets.apply("isAddonA11yEnabled", false);
|
|
679
|
+
} catch {
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
//#endregion
|
|
685
|
+
//#region src/tools/get-storybook-story-instructions.ts
|
|
686
|
+
async function addGetUIBuildingInstructionsTool(server) {
|
|
687
|
+
const addonVitestAvailable = !!await getAddonVitestConstants();
|
|
688
|
+
server.tool({
|
|
689
|
+
name: GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME,
|
|
690
|
+
title: "Storybook Story Development Instructions",
|
|
691
|
+
get description() {
|
|
692
|
+
const testToolsetAvailable = (server.ctx.custom?.toolsets?.test ?? true) && addonVitestAvailable;
|
|
693
|
+
const a11yAvailable = testToolsetAvailable && (server.ctx.custom?.a11yEnabled ?? false);
|
|
694
|
+
return `Get comprehensive instructions for writing, testing, and fixing Storybook stories (.stories.tsx, .stories.ts, .stories.jsx, .stories.js, .stories.svelte, .stories.vue files).
|
|
695
|
+
|
|
696
|
+
CRITICAL: You MUST call this tool before:
|
|
697
|
+
- Creating new Storybook stories or story files
|
|
698
|
+
- Updating or modifying existing Storybook stories
|
|
699
|
+
- Adding new story variants or exports to story files
|
|
700
|
+
- Editing any file matching *.stories.* patterns
|
|
701
|
+
- Writing components that will need stories${testToolsetAvailable ? `
|
|
702
|
+
- Running story tests or fixing test failures` : ""}${a11yAvailable ? `
|
|
703
|
+
- Handling accessibility (a11y) violations in stories (fix semantic issues directly; ask before visual/design changes)` : ""}
|
|
704
|
+
|
|
705
|
+
This tool provides essential Storybook-specific guidance including:
|
|
706
|
+
- How to structure stories correctly for Storybook 9
|
|
707
|
+
- Required imports (Meta, StoryObj from framework package)
|
|
708
|
+
- Test utility imports (from 'storybook/test')
|
|
709
|
+
- Story naming conventions and best practices
|
|
710
|
+
- Play function patterns for interactive testing
|
|
711
|
+
- Mocking strategies for external dependencies
|
|
712
|
+
- Story variants and coverage requirements${testToolsetAvailable ? `
|
|
713
|
+
- How to handle test failures${a11yAvailable ? " and accessibility violations" : ""}` : ""}
|
|
714
|
+
|
|
715
|
+
Even if you're familiar with Storybook, call this tool to ensure you're following the correct patterns, import paths, and conventions for this specific Storybook setup.`;
|
|
716
|
+
},
|
|
717
|
+
enabled: () => server.ctx.custom?.toolsets?.dev ?? true
|
|
718
|
+
}, async () => {
|
|
719
|
+
try {
|
|
720
|
+
const { options, disableTelemetry } = server.ctx.custom ?? {};
|
|
721
|
+
if (!options) throw new Error("Options are required in addon context");
|
|
722
|
+
if (!disableTelemetry) await collectTelemetry({
|
|
723
|
+
event: "tool:getUIBuildingInstructions",
|
|
724
|
+
server,
|
|
725
|
+
toolset: "dev"
|
|
726
|
+
});
|
|
727
|
+
const frameworkPreset = await options.presets.apply("framework");
|
|
728
|
+
const framework = typeof frameworkPreset === "string" ? frameworkPreset : frameworkPreset?.name;
|
|
729
|
+
const renderer = frameworkToRendererMap[framework];
|
|
730
|
+
let uiInstructions = storybook_story_instructions_default.replace("{{FRAMEWORK}}", framework).replace("{{RENDERER}}", renderer ?? framework).replace("{{PREVIEW_STORIES_TOOL_NAME}}", PREVIEW_STORIES_TOOL_NAME);
|
|
731
|
+
if ((server.ctx.custom?.toolsets?.test ?? true) && !!await getAddonVitestConstants()) {
|
|
732
|
+
const a11yEnabled = server.ctx.custom?.a11yEnabled ?? false;
|
|
733
|
+
const a11yFixSuffix = a11yEnabled ? " (see a11y guidelines below)" : "";
|
|
734
|
+
const storyTestingInstructions = story_testing_instructions_default.replaceAll("{{RUN_STORY_TESTS_TOOL_NAME}}", RUN_STORY_TESTS_TOOL_NAME).replace("{{A11Y_FIX_SUFFIX}}", a11yFixSuffix);
|
|
735
|
+
uiInstructions += `\n\n${storyTestingInstructions}`;
|
|
736
|
+
if (a11yEnabled) uiInstructions += `\n${a11y_instructions_default}`;
|
|
737
|
+
}
|
|
738
|
+
return { content: [{
|
|
739
|
+
type: "text",
|
|
740
|
+
text: uiInstructions
|
|
741
|
+
}] };
|
|
742
|
+
} catch (error) {
|
|
743
|
+
return errorToMCPContent(error);
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
const frameworkToRendererMap = {
|
|
748
|
+
"@storybook/react-vite": "@storybook/react",
|
|
749
|
+
"@storybook/react-webpack5": "@storybook/react",
|
|
750
|
+
"@storybook/nextjs": "@storybook/react",
|
|
751
|
+
"@storybook/nextjs-vite": "@storybook/react",
|
|
752
|
+
"@storybook/react-native-web-vite": "@storybook/react",
|
|
753
|
+
"@storybook/vue3-vite": "@storybook/vue3",
|
|
754
|
+
"@nuxtjs/storybook": "@storybook/vue3",
|
|
755
|
+
"@storybook/angular": "@storybook/angular",
|
|
756
|
+
"@storybook/svelte-vite": "@storybook/svelte",
|
|
757
|
+
"@storybook/sveltekit": "@storybook/svelte",
|
|
758
|
+
"@storybook/preact-vite": "@storybook/preact",
|
|
759
|
+
"@storybook/web-components-vite": "@storybook/web-components",
|
|
760
|
+
"@storybook/html-vite": "@storybook/html"
|
|
761
|
+
};
|
|
762
|
+
|
|
354
763
|
//#endregion
|
|
355
764
|
//#region src/tools/is-manifest-available.ts
|
|
356
765
|
const getManifestStatus = async (options) => {
|
|
@@ -428,6 +837,7 @@ let transport;
|
|
|
428
837
|
let origin;
|
|
429
838
|
let initialize;
|
|
430
839
|
let disableTelemetry;
|
|
840
|
+
let a11yEnabled;
|
|
431
841
|
const initializeMCPServer = async (options, multiSource) => {
|
|
432
842
|
disableTelemetry = (await options.presets.apply("core", {}))?.disableTelemetry ?? false;
|
|
433
843
|
const server = new McpServer({
|
|
@@ -449,11 +859,14 @@ const initializeMCPServer = async (options, multiSource) => {
|
|
|
449
859
|
});
|
|
450
860
|
await addPreviewStoriesTool(server);
|
|
451
861
|
await addGetUIBuildingInstructionsTool(server);
|
|
862
|
+
a11yEnabled = await isAddonA11yEnabled(options);
|
|
863
|
+
await addRunStoryTestsTool(server, { a11yEnabled });
|
|
452
864
|
if ((await getManifestStatus(options)).available) {
|
|
453
865
|
logger.info("Experimental components manifest feature detected - registering component tools");
|
|
454
866
|
const contextAwareEnabled = () => server.ctx.custom?.toolsets?.docs ?? true;
|
|
455
867
|
await addListAllDocumentationTool(server, contextAwareEnabled);
|
|
456
868
|
await addGetDocumentationTool(server, contextAwareEnabled, { multiSource });
|
|
869
|
+
await addGetStoryDocumentationTool(server, contextAwareEnabled, { multiSource });
|
|
457
870
|
}
|
|
458
871
|
transport = new HttpTransport(server, { path: null });
|
|
459
872
|
origin = `http://localhost:${options.port}`;
|
|
@@ -469,6 +882,7 @@ const mcpServerHandler = async ({ req, res, options, addonOptions, sources, mani
|
|
|
469
882
|
toolsets: getToolsets(webRequest, addonOptions),
|
|
470
883
|
origin,
|
|
471
884
|
disableTelemetry,
|
|
885
|
+
a11yEnabled,
|
|
472
886
|
request: webRequest,
|
|
473
887
|
sources,
|
|
474
888
|
manifestProvider,
|
|
@@ -517,9 +931,9 @@ const mcpServerHandler = async ({ req, res, options, addonOptions, sources, mani
|
|
|
517
931
|
async function incomingMessageToWebRequest(req) {
|
|
518
932
|
const host = req.headers.host || "localhost";
|
|
519
933
|
const protocol = "encrypted" in req.socket && req.socket.encrypted ? "https" : "http";
|
|
520
|
-
const url
|
|
934
|
+
const url = new URL(req.url || "/", `${protocol}://${host}`);
|
|
521
935
|
const bodyBuffer = await buffer(req);
|
|
522
|
-
return new Request(url
|
|
936
|
+
return new Request(url, {
|
|
523
937
|
method: req.method,
|
|
524
938
|
headers: req.headers,
|
|
525
939
|
body: bodyBuffer.length > 0 ? new Uint8Array(bodyBuffer) : void 0
|
|
@@ -552,7 +966,8 @@ function getToolsets(request, addonOptions) {
|
|
|
552
966
|
if (!toolsetHeader || toolsetHeader.trim() === "") return addonOptions.toolsets;
|
|
553
967
|
const toolsets = {
|
|
554
968
|
dev: false,
|
|
555
|
-
docs: false
|
|
969
|
+
docs: false,
|
|
970
|
+
test: false
|
|
556
971
|
};
|
|
557
972
|
const enabledToolsets = toolsetHeader.split(",");
|
|
558
973
|
for (const enabledToolset of enabledToolsets) {
|
|
@@ -564,7 +979,7 @@ function getToolsets(request, addonOptions) {
|
|
|
564
979
|
|
|
565
980
|
//#endregion
|
|
566
981
|
//#region src/template.html
|
|
567
|
-
var template_default = "<!doctype html>\n<html>\n <head>\n {{REDIRECT_META}}\n <style>\n @font-face {\n font-family: 'Nunito Sans';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url('./sb-common-assets/nunito-sans-regular.woff2') format('woff2');\n }\n\n * {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n }\n\n html,\n body {\n height: 100%;\n font-family:\n 'Nunito Sans',\n -apple-system,\n BlinkMacSystemFont,\n 'Segoe UI',\n Roboto,\n Oxygen,\n Ubuntu,\n Cantarell,\n sans-serif;\n }\n\n body {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n padding: 2rem;\n background-color: #ffffff;\n color: rgb(46, 52, 56);\n line-height: 1.6;\n }\n\n p {\n margin-bottom: 1rem;\n }\n\n code {\n font-family: 'Monaco', 'Courier New', monospace;\n background: #f5f5f5;\n padding: 0.2em 0.4em;\n border-radius: 3px;\n }\n\n a {\n color: #1ea7fd;\n }\n\n .container {\n display: flex;\n flex-direction: column;\n align-items: center;\n }\n\n .toolsets {\n margin: 1.5rem 0;\n text-align: left;\n max-width: 500px;\n }\n\n .toolsets h3 {\n font-size: 1rem;\n margin-bottom: 0.75rem;\n text-align: center;\n }\n\n .toolset {\n margin-bottom: 1rem;\n padding: 0.75rem 1rem;\n border-radius: 6px;\n background: #f8f9fa;\n border: 1px solid #e9ecef;\n }\n\n .toolset-header {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n font-weight: 600;\n margin-bottom: 0.5rem;\n }\n\n .toolset-status {\n display: inline-block;\n padding: 0.15em 0.5em;\n border-radius: 3px;\n font-size: 0.75rem;\n font-weight: 500;\n text-transform: uppercase;\n }\n\n .toolset-status.enabled {\n background: #d4edda;\n color: #155724;\n }\n\n .toolset-status.disabled {\n background: #f8d7da;\n color: #721c24;\n }\n\n .toolset-tools {\n font-size: 0.875rem;\n color: #6c757d;\n padding-left: 1.5rem;\n margin: 0;\n }\n\n .toolset-tools li {\n margin-bottom: 0.25rem;\n }\n\n .toolset-tools code {\n font-size: 0.8rem;\n }\n\n .toolset-notice {\n font-size: 0.8rem;\n color: #856404;\n background: #fff3cd;\n padding: 0.5rem;\n border-radius: 4px;\n margin-top: 0.5rem;\n }\n\n .toolset-notice a {\n color: #533f03;\n }\n\n @media (prefers-color-scheme: dark) {\n body {\n background-color: rgb(34, 36, 37);\n color: rgb(201, 205, 207);\n }\n\n code {\n background: rgba(255, 255, 255, 0.1);\n }\n\n .toolset {\n background: rgba(255, 255, 255, 0.05);\n border-color: rgba(255, 255, 255, 0.1);\n }\n\n .toolset-tools {\n color: #adb5bd;\n }\n\n .toolset-status.enabled {\n background: rgba(40, 167, 69, 0.2);\n color: #75d67e;\n }\n\n .toolset-status.disabled {\n background: rgba(220, 53, 69, 0.2);\n color: #f5a6ad;\n }\n\n .toolset-notice {\n background: rgba(255, 193, 7, 0.15);\n color: #ffc107;\n }\n\n .toolset-notice a {\n color: #ffe066;\n }\n }\n </style>\n </head>\n <body>\n <div class=\"container\">\n <p>\n Storybook MCP server successfully running via\n <code>@storybook/addon-mcp</code>.\n </p>\n <p>\n See how to connect to it from your coding agent in\n <a\n target=\"_blank\"\n href=\"https://github.com/storybookjs/mcp/tree/main/packages/addon-mcp#configuring-your-agent\"\n >the addon's README</a\n >.\n </p>\n\n <div class=\"toolsets\">\n <h3>Available Toolsets</h3>\n\n <div class=\"toolset\">\n <div class=\"toolset-header\">\n <span>dev</span>\n <span class=\"toolset-status {{DEV_STATUS}}\">{{DEV_STATUS}}</span>\n </div>\n <ul class=\"toolset-tools\">\n <li><code>preview-stories</code></li>\n <li><code>get-storybook-story-instructions</code></li>\n </ul>\n </div>\n\n <div class=\"toolset\">\n <div class=\"toolset-header\">\n <span>docs</span>\n <span class=\"toolset-status {{DOCS_STATUS}}\">{{DOCS_STATUS}}</span>\n </div>\n <ul class=\"toolset-tools\">\n <li><code>list-all-documentation</code></li>\n <li><code>get-documentation</code></li>\n </ul>\n {{DOCS_NOTICE}}\n </div>\n </div>\n\n <p id=\"redirect-message\">\n Automatically redirecting to\n <a href=\"/manifests/components.html\">component manifest</a>\n in <span id=\"countdown\">10</span> seconds...\n </p>\n </div>\n <script>\n let countdown = 10;\n const countdownElement = document.getElementById('countdown');\n if (countdownElement) {\n setInterval(() => {\n countdown -= 1;\n countdownElement.textContent = countdown.toString();\n }, 1000);\n }\n <\/script>\n </body>\n</html>\n";
|
|
982
|
+
var template_default = "<!doctype html>\n<html>\n <head>\n {{REDIRECT_META}}\n <style>\n @font-face {\n font-family: 'Nunito Sans';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url('./sb-common-assets/nunito-sans-regular.woff2') format('woff2');\n }\n\n * {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n }\n\n html,\n body {\n height: 100%;\n font-family:\n 'Nunito Sans',\n -apple-system,\n BlinkMacSystemFont,\n 'Segoe UI',\n Roboto,\n Oxygen,\n Ubuntu,\n Cantarell,\n sans-serif;\n }\n\n body {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n padding: 2rem;\n background-color: #ffffff;\n color: rgb(46, 52, 56);\n line-height: 1.6;\n }\n\n p {\n margin-bottom: 1rem;\n }\n\n code {\n font-family: 'Monaco', 'Courier New', monospace;\n background: #f5f5f5;\n padding: 0.2em 0.4em;\n border-radius: 3px;\n }\n\n a {\n color: #1ea7fd;\n }\n\n .container {\n display: flex;\n flex-direction: column;\n align-items: center;\n }\n\n .toolsets {\n margin: 1.5rem 0;\n text-align: left;\n max-width: 500px;\n }\n\n .toolsets h3 {\n font-size: 1rem;\n margin-bottom: 0.75rem;\n text-align: center;\n }\n\n .toolset {\n margin-bottom: 1rem;\n padding: 0.75rem 1rem;\n border-radius: 6px;\n background: #f8f9fa;\n border: 1px solid #e9ecef;\n }\n\n .toolset-header {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n font-weight: 600;\n margin-bottom: 0.5rem;\n }\n\n .toolset-status {\n display: inline-block;\n padding: 0.15em 0.5em;\n border-radius: 3px;\n font-size: 0.75rem;\n font-weight: 500;\n text-transform: uppercase;\n }\n\n .toolset-status.enabled {\n background: #d4edda;\n color: #155724;\n }\n\n .toolset-status.disabled {\n background: #f8d7da;\n color: #721c24;\n }\n\n .toolset-tools {\n font-size: 0.875rem;\n color: #6c757d;\n padding-left: 1.5rem;\n margin: 0;\n }\n\n .toolset-tools li {\n margin-bottom: 0.25rem;\n }\n\n .toolset-tools code {\n font-size: 0.8rem;\n }\n\n .toolset-notice {\n font-size: 0.8rem;\n color: #856404;\n background: #fff3cd;\n padding: 0.5rem;\n border-radius: 4px;\n margin-top: 0.5rem;\n }\n\n .toolset-notice a {\n color: #533f03;\n }\n\n @media (prefers-color-scheme: dark) {\n body {\n background-color: rgb(34, 36, 37);\n color: rgb(201, 205, 207);\n }\n\n code {\n background: rgba(255, 255, 255, 0.1);\n }\n\n .toolset {\n background: rgba(255, 255, 255, 0.05);\n border-color: rgba(255, 255, 255, 0.1);\n }\n\n .toolset-tools {\n color: #adb5bd;\n }\n\n .toolset-status.enabled {\n background: rgba(40, 167, 69, 0.2);\n color: #75d67e;\n }\n\n .toolset-status.disabled {\n background: rgba(220, 53, 69, 0.2);\n color: #f5a6ad;\n }\n\n .toolset-notice {\n background: rgba(255, 193, 7, 0.15);\n color: #ffc107;\n }\n\n .toolset-notice a {\n color: #ffe066;\n }\n }\n </style>\n </head>\n <body>\n <div class=\"container\">\n <p>\n Storybook MCP server successfully running via\n <code>@storybook/addon-mcp</code>.\n </p>\n <p>\n See how to connect to it from your coding agent in\n <a\n target=\"_blank\"\n href=\"https://github.com/storybookjs/mcp/tree/main/packages/addon-mcp#configuring-your-agent\"\n >the addon's README</a\n >.\n </p>\n\n <div class=\"toolsets\">\n <h3>Available Toolsets</h3>\n\n <div class=\"toolset\">\n <div class=\"toolset-header\">\n <span>dev</span>\n <span class=\"toolset-status {{DEV_STATUS}}\">{{DEV_STATUS}}</span>\n </div>\n <ul class=\"toolset-tools\">\n <li><code>preview-stories</code></li>\n <li><code>get-storybook-story-instructions</code></li>\n </ul>\n </div>\n\n <div class=\"toolset\">\n <div class=\"toolset-header\">\n <span>docs</span>\n <span class=\"toolset-status {{DOCS_STATUS}}\">{{DOCS_STATUS}}</span>\n </div>\n <ul class=\"toolset-tools\">\n <li><code>list-all-documentation</code></li>\n <li><code>get-documentation</code></li>\n </ul>\n {{DOCS_NOTICE}}\n </div>\n\n <div class=\"toolset\">\n <div class=\"toolset-header\">\n <span>test</span>\n <span class=\"toolset-status {{TEST_STATUS}}\">{{TEST_STATUS}}</span>\n </div>\n <ul class=\"toolset-tools\">\n <li><code>run-story-tests</code>{{A11Y_BADGE}}</li>\n </ul>\n {{TEST_NOTICE}}\n </div>\n </div>\n\n <p id=\"redirect-message\">\n Automatically redirecting to\n <a href=\"/manifests/components.html\">component manifest</a>\n in <span id=\"countdown\">10</span> seconds...\n </p>\n </div>\n <script>\n let countdown = 10;\n const countdownElement = document.getElementById('countdown');\n if (countdownElement) {\n setInterval(() => {\n countdown -= 1;\n countdownElement.textContent = countdown.toString();\n }, 1000);\n }\n <\/script>\n </body>\n</html>\n";
|
|
568
983
|
|
|
569
984
|
//#endregion
|
|
570
985
|
//#region src/auth/composition-auth.ts
|
|
@@ -589,8 +1004,8 @@ const OAuthServerMetadata = v.object({
|
|
|
589
1004
|
const MANIFEST_CACHE_TTL = 3600 * 1e3;
|
|
590
1005
|
const REVALIDATION_TTL = 60 * 1e3;
|
|
591
1006
|
var AuthenticationError = class extends Error {
|
|
592
|
-
constructor(url
|
|
593
|
-
super(`Authentication failed for ${url
|
|
1007
|
+
constructor(url) {
|
|
1008
|
+
super(`Authentication failed for ${url}. Your token may be invalid or expired.`);
|
|
594
1009
|
this.name = "AuthenticationError";
|
|
595
1010
|
}
|
|
596
1011
|
};
|
|
@@ -630,21 +1045,21 @@ var CompositionAuth = class {
|
|
|
630
1045
|
return this.#authErrors.has(request);
|
|
631
1046
|
}
|
|
632
1047
|
/** Check if a URL requires authentication based on discovered auth requirements. */
|
|
633
|
-
#isAuthRequiredUrl(url
|
|
634
|
-
return this.#authRequiredUrls.some((authUrl) => url
|
|
1048
|
+
#isAuthRequiredUrl(url) {
|
|
1049
|
+
return this.#authRequiredUrls.some((authUrl) => url.startsWith(authUrl));
|
|
635
1050
|
}
|
|
636
1051
|
/** Build .well-known/oauth-protected-resource response. */
|
|
637
|
-
buildWellKnown(origin
|
|
1052
|
+
buildWellKnown(origin) {
|
|
638
1053
|
if (!this.#authRequirement) return null;
|
|
639
1054
|
return {
|
|
640
|
-
resource: `${origin
|
|
1055
|
+
resource: `${origin}/mcp`,
|
|
641
1056
|
authorization_servers: this.#authRequirement.resourceMetadata.authorization_servers,
|
|
642
1057
|
scopes_supported: this.#authRequirement.resourceMetadata.scopes_supported
|
|
643
1058
|
};
|
|
644
1059
|
}
|
|
645
1060
|
/** Build WWW-Authenticate header for 401 responses */
|
|
646
|
-
buildWwwAuthenticate(origin
|
|
647
|
-
return `Bearer error="unauthorized", error_description="Authorization needed for composed Storybooks", resource_metadata="${origin
|
|
1061
|
+
buildWwwAuthenticate(origin) {
|
|
1062
|
+
return `Bearer error="unauthorized", error_description="Authorization needed for composed Storybooks", resource_metadata="${origin}/.well-known/oauth-protected-resource"`;
|
|
648
1063
|
}
|
|
649
1064
|
/** Build sources configuration: local first, then refs that have manifests. */
|
|
650
1065
|
buildSources() {
|
|
@@ -659,10 +1074,10 @@ var CompositionAuth = class {
|
|
|
659
1074
|
}
|
|
660
1075
|
/** Create a manifest provider for multi-source mode. */
|
|
661
1076
|
createManifestProvider(localOrigin) {
|
|
662
|
-
return async (request, path
|
|
1077
|
+
return async (request, path, source) => {
|
|
663
1078
|
const token = extractBearerToken(request?.headers.get("Authorization"));
|
|
664
1079
|
const baseUrl = source?.url ?? localOrigin;
|
|
665
|
-
const manifestUrl = `${baseUrl}${path
|
|
1080
|
+
const manifestUrl = `${baseUrl}${path.replace("./", "/")}`;
|
|
666
1081
|
const isRemote = !!source?.url;
|
|
667
1082
|
const tokenForRequest = isRemote && this.#isAuthRequiredUrl(baseUrl) ? token : null;
|
|
668
1083
|
if (token && token !== this.#lastToken) {
|
|
@@ -701,17 +1116,17 @@ var CompositionAuth = class {
|
|
|
701
1116
|
* Fetch a manifest with optional auth token.
|
|
702
1117
|
* If the response is 200 but not a valid manifest, checks /mcp for auth issues.
|
|
703
1118
|
*/
|
|
704
|
-
async #fetchManifest(url
|
|
1119
|
+
async #fetchManifest(url, token) {
|
|
705
1120
|
const headers = { Accept: "application/json" };
|
|
706
1121
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
707
|
-
const response = await fetch(url
|
|
708
|
-
if (response.status === 401) throw new AuthenticationError(url
|
|
709
|
-
if (!response.ok) throw new Error(`Failed to fetch ${url
|
|
1122
|
+
const response = await fetch(url, { headers });
|
|
1123
|
+
if (response.status === 401) throw new AuthenticationError(url);
|
|
1124
|
+
if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status}`);
|
|
710
1125
|
const text = await response.text();
|
|
711
|
-
const schema = url
|
|
1126
|
+
const schema = url.includes("docs.json") ? DocsManifestMap : ComponentManifestMap;
|
|
712
1127
|
if (v.safeParse(v.pipe(v.string(), v.parseJson(), schema), text).success) return text;
|
|
713
|
-
if (await this.#isMcpUnauthorized(new URL(url
|
|
714
|
-
throw new Error(`Invalid manifest response from ${url
|
|
1128
|
+
if (await this.#isMcpUnauthorized(new URL(url).origin)) throw new AuthenticationError(url);
|
|
1129
|
+
throw new Error(`Invalid manifest response from ${url}: expected valid JSON manifest but got unexpected content.`);
|
|
715
1130
|
}
|
|
716
1131
|
/**
|
|
717
1132
|
* Check a ref to determine if it has a manifest and whether it requires auth.
|
|
@@ -744,9 +1159,9 @@ var CompositionAuth = class {
|
|
|
744
1159
|
return this.#parseAuthFromResponse(response);
|
|
745
1160
|
}
|
|
746
1161
|
/** Quick check: does the remote /mcp return 401? */
|
|
747
|
-
async #isMcpUnauthorized(origin
|
|
1162
|
+
async #isMcpUnauthorized(origin) {
|
|
748
1163
|
try {
|
|
749
|
-
return (await fetch(`${origin
|
|
1164
|
+
return (await fetch(`${origin}/mcp`, {
|
|
750
1165
|
method: "POST",
|
|
751
1166
|
headers: { "Content-Type": "application/json" },
|
|
752
1167
|
body: JSON.stringify({
|
|
@@ -811,7 +1226,7 @@ const previewAnnotations = async (existingAnnotations = []) => {
|
|
|
811
1226
|
};
|
|
812
1227
|
const experimental_devServer = async (app, options) => {
|
|
813
1228
|
const addonOptions = v.parse(AddonOptions, { toolsets: "toolsets" in options ? options.toolsets : {} });
|
|
814
|
-
const origin
|
|
1229
|
+
const origin = `http://localhost:${options.port}`;
|
|
815
1230
|
const refs = await getRefsFromConfig(options);
|
|
816
1231
|
const compositionAuth = new CompositionAuth();
|
|
817
1232
|
let sources;
|
|
@@ -822,10 +1237,10 @@ const experimental_devServer = async (app, options) => {
|
|
|
822
1237
|
if (compositionAuth.requiresAuth) logger.info(`Auth required for: ${compositionAuth.authUrls.join(", ")}`);
|
|
823
1238
|
sources = compositionAuth.buildSources();
|
|
824
1239
|
logger.info(`Sources: ${sources.map((s) => s.id).join(", ")}`);
|
|
825
|
-
manifestProvider = compositionAuth.createManifestProvider(origin
|
|
1240
|
+
manifestProvider = compositionAuth.createManifestProvider(origin);
|
|
826
1241
|
}
|
|
827
1242
|
app.get("/.well-known/oauth-protected-resource", (_req, res) => {
|
|
828
|
-
const wellKnown = compositionAuth.buildWellKnown(origin
|
|
1243
|
+
const wellKnown = compositionAuth.buildWellKnown(origin);
|
|
829
1244
|
if (!wellKnown) {
|
|
830
1245
|
res.writeHead(404);
|
|
831
1246
|
res.end("Not found");
|
|
@@ -839,7 +1254,7 @@ const experimental_devServer = async (app, options) => {
|
|
|
839
1254
|
if (compositionAuth.requiresAuth && !token) {
|
|
840
1255
|
res.writeHead(401, {
|
|
841
1256
|
"Content-Type": "text/plain",
|
|
842
|
-
"WWW-Authenticate": compositionAuth.buildWwwAuthenticate(origin
|
|
1257
|
+
"WWW-Authenticate": compositionAuth.buildWwwAuthenticate(origin)
|
|
843
1258
|
});
|
|
844
1259
|
res.end("401 - Unauthorized");
|
|
845
1260
|
return true;
|
|
@@ -859,8 +1274,11 @@ const experimental_devServer = async (app, options) => {
|
|
|
859
1274
|
});
|
|
860
1275
|
});
|
|
861
1276
|
const manifestStatus = await getManifestStatus(options);
|
|
1277
|
+
const addonVitestConstants = await getAddonVitestConstants();
|
|
1278
|
+
const a11yEnabled = await isAddonA11yEnabled(options);
|
|
862
1279
|
const isDevEnabled = addonOptions.toolsets?.dev ?? true;
|
|
863
1280
|
const isDocsEnabled = manifestStatus.available && (addonOptions.toolsets?.docs ?? true);
|
|
1281
|
+
const isTestEnabled = !!addonVitestConstants && (addonOptions.toolsets?.test ?? true);
|
|
864
1282
|
app.get("/mcp", (req, res) => {
|
|
865
1283
|
if (!req.headers["accept"]?.includes("text/html")) {
|
|
866
1284
|
if (requireAuth(req, res)) return;
|
|
@@ -883,7 +1301,10 @@ const experimental_devServer = async (app, options) => {
|
|
|
883
1301
|
This toolset requires enabling the experimental component manifest feature.
|
|
884
1302
|
<a target="_blank" href="https://github.com/storybookjs/mcp/tree/main/packages/addon-mcp#docs-tools-experimental">Learn how to enable it</a>
|
|
885
1303
|
</div>`;
|
|
886
|
-
const
|
|
1304
|
+
const testNoticeLines = [!addonVitestConstants && `This toolset requires <code>@storybook/addon-vitest</code>. <a target="_blank" href="https://storybook.js.org/docs/writing-tests/test-addon">Learn how to set it up</a>`, !a11yEnabled && `Add <code>@storybook/addon-a11y</code> for accessibility testing. <a target="_blank" href="https://storybook.js.org/docs/writing-tests/accessibility-testing">Learn more</a>`].filter(Boolean);
|
|
1305
|
+
const testNotice = testNoticeLines.length ? `<div class="toolset-notice">${testNoticeLines.join("<br>")}</div>` : "";
|
|
1306
|
+
const a11yBadge = a11yEnabled ? " <span class=\"toolset-status enabled\">+ accessibility</span>" : "";
|
|
1307
|
+
const html = template_default.replace("{{REDIRECT_META}}", manifestStatus.available ? "<meta http-equiv=\"refresh\" content=\"10;url=/manifests/components.html\" />" : "<style>#redirect-message { display: none; }</style>").replaceAll("{{DEV_STATUS}}", isDevEnabled ? "enabled" : "disabled").replaceAll("{{DOCS_STATUS}}", isDocsEnabled ? "enabled" : "disabled").replace("{{DOCS_NOTICE}}", docsNotice).replaceAll("{{TEST_STATUS}}", isTestEnabled ? "enabled" : "disabled").replace("{{TEST_NOTICE}}", testNotice).replace("{{A11Y_BADGE}}", a11yBadge);
|
|
887
1308
|
res.end(html);
|
|
888
1309
|
});
|
|
889
1310
|
return app;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@storybook/addon-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Help agents automatically write and test stories for your UI components",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -34,18 +34,21 @@
|
|
|
34
34
|
"picoquery": "^2.5.0",
|
|
35
35
|
"tmcp": "^1.16.0",
|
|
36
36
|
"valibot": "1.2.0",
|
|
37
|
-
"@storybook/mcp": "0.
|
|
37
|
+
"@storybook/mcp": "0.5.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"storybook": "10.3.0-alpha.
|
|
40
|
+
"@storybook/addon-a11y": "10.3.0-alpha.12",
|
|
41
|
+
"@storybook/addon-vitest": "10.3.0-alpha.12",
|
|
42
|
+
"storybook": "10.3.0-alpha.12"
|
|
41
43
|
},
|
|
42
44
|
"peerDependencies": {
|
|
45
|
+
"@storybook/addon-vitest": "^9.1.16 || ^10.0.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0",
|
|
43
46
|
"storybook": "^9.1.16 || ^10.0.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0"
|
|
44
47
|
},
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@storybook/addon-vitest": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
49
52
|
},
|
|
50
53
|
"storybook": {
|
|
51
54
|
"displayName": "Addon MCP",
|