@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 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
- - `absoluteStoryPath`: Absolute path to the story file
160
- - `exportName`: The export name of the story
161
- - `explicitStoryName`: Optional explicit story name
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.1";
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$1) {
102
- const indexUrl = `${origin$1}/index.json`;
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/storybook-story-instructions.md
131
- 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+**, **Vite 5+**\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";
132
-
133
- //#endregion
134
- //#region src/tools/get-storybook-story-instructions.ts
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
- async function addGetUIBuildingInstructionsTool(server) {
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$1 = server.ctx.custom.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$1,
290
+ domain: origin,
272
291
  csp: {
273
- connectDomains: [origin$1],
274
- resourceDomains: [origin$1],
275
- frameDomains: [origin$1],
276
- baseUriDomains: [origin$1]
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: "Preview stories",
284
- description: `Use this tool to preview one or more stories, rendering them as an MCP App using the UI Resource or returning the raw URL for users to visit.`,
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: origin$1, disableTelemetry: disableTelemetry$1 } = server.ctx.custom ?? {};
292
- if (!origin$1) throw new Error("Origin is required in addon context");
293
- const index = await fetchStoryIndex(origin$1);
294
- const entriesList = Object.values(index.entries);
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 inputParams of input.stories) {
298
- const { exportName, explicitStoryName, absoluteStoryPath } = inputParams;
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
- title: foundStory.title,
319
- name: foundStory.name,
320
- previewUrl
320
+ input: story.input,
321
+ error: story.errorMessage
321
322
  });
322
- textResult.push(previewUrl);
323
- } else {
324
- logger.debug("No story found");
325
- let errorMessage = `No story found for export name "${exportName}" with absolute file path "${absoluteStoryPath}"`;
326
- if (!explicitStoryName) errorMessage += ` (did you forget to pass the explicit story name?)`;
323
+ textResult.push(story.errorMessage);
324
+ continue;
325
+ }
326
+ const indexEntry = index.entries[story.id];
327
+ if (!indexEntry) {
327
328
  structuredResult.push({
328
- input: inputParams,
329
- error: errorMessage
329
+ input: story.input,
330
+ error: `No story found for story ID "${story.id}"`
330
331
  });
331
- textResult.push(errorMessage);
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$1) await collectTelemetry({
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$1 = new URL(req.url || "/", `${protocol}://${host}`);
934
+ const url = new URL(req.url || "/", `${protocol}://${host}`);
521
935
  const bodyBuffer = await buffer(req);
522
- return new Request(url$1, {
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$1) {
593
- super(`Authentication failed for ${url$1}. Your token may be invalid or expired.`);
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$1) {
634
- return this.#authRequiredUrls.some((authUrl) => url$1.startsWith(authUrl));
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$1) {
1052
+ buildWellKnown(origin) {
638
1053
  if (!this.#authRequirement) return null;
639
1054
  return {
640
- resource: `${origin$1}/mcp`,
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$1) {
647
- return `Bearer error="unauthorized", error_description="Authorization needed for composed Storybooks", resource_metadata="${origin$1}/.well-known/oauth-protected-resource"`;
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$1, source) => {
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$1.replace("./", "/")}`;
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$1, token) {
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$1, { headers });
708
- if (response.status === 401) throw new AuthenticationError(url$1);
709
- if (!response.ok) throw new Error(`Failed to fetch ${url$1}: ${response.status}`);
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$1.includes("docs.json") ? DocsManifestMap : ComponentManifestMap;
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$1).origin)) throw new AuthenticationError(url$1);
714
- throw new Error(`Invalid manifest response from ${url$1}: expected valid JSON manifest but got unexpected content.`);
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$1) {
1162
+ async #isMcpUnauthorized(origin) {
748
1163
  try {
749
- return (await fetch(`${origin$1}/mcp`, {
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$1 = `http://localhost:${options.port}`;
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$1);
1240
+ manifestProvider = compositionAuth.createManifestProvider(origin);
826
1241
  }
827
1242
  app.get("/.well-known/oauth-protected-resource", (_req, res) => {
828
- const wellKnown = compositionAuth.buildWellKnown(origin$1);
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$1)
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 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);
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;
@@ -4,7 +4,7 @@ const MCP_APP_SIZE_CHANGED_EVENT = "storybook-mcp:size-changed";
4
4
 
5
5
  //#endregion
6
6
  //#region package.json
7
- var version = "0.3.1";
7
+ var version = "0.3.3";
8
8
 
9
9
  //#endregion
10
10
  //#region src/tools/preview-stories/preview-stories-app-script.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@storybook/addon-mcp",
3
- "version": "0.3.1",
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.4.0"
37
+ "@storybook/mcp": "0.5.0"
38
38
  },
39
39
  "devDependencies": {
40
- "storybook": "10.3.0-alpha.7"
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
- "bundler": {
46
- "nodeEntries": [
47
- "src/preset.ts"
48
- ]
48
+ "peerDependenciesMeta": {
49
+ "@storybook/addon-vitest": {
50
+ "optional": true
51
+ }
49
52
  },
50
53
  "storybook": {
51
54
  "displayName": "Addon MCP",