@storybook/addon-mcp 0.3.0 → 0.3.2

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/dist/preset.js CHANGED
@@ -15,7 +15,7 @@ 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.0";
18
+ var version = "0.3.2";
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}`);
@@ -127,81 +127,24 @@ const errorToMCPContent = (error) => {
127
127
  };
128
128
 
129
129
  //#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
130
+ //#region src/tools/tool-names.ts
131
+ /**
132
+ * Tool name constants extracted to avoid circular dependencies.
133
+ */
134
+ const PREVIEW_STORIES_TOOL_NAME = "preview-stories";
135
135
  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
- };
136
+ const RUN_STORY_TESTS_TOOL_NAME = "run-story-tests";
196
137
 
197
138
  //#endregion
198
139
  //#region src/types.ts
199
140
  const AddonOptions = v.object({ toolsets: v.optional(v.object({
200
141
  dev: v.exactOptional(v.boolean(), true),
201
- docs: v.exactOptional(v.boolean(), true)
142
+ docs: v.exactOptional(v.boolean(), true),
143
+ test: v.exactOptional(v.boolean(), true)
202
144
  }), {
203
145
  dev: true,
204
- docs: true
146
+ docs: true,
147
+ test: true
205
148
  }) });
206
149
  /**
207
150
  * Schema for a single story input when requesting story URLs.
@@ -232,13 +175,12 @@ var preview_stories_app_template_default = "<!doctype html>\n<html>\n <head>\n
232
175
  * Normalize paths to forward slashes for cross-platform compatibility
233
176
  * Storybook import paths always use forward slashes
234
177
  */
235
- function slash(path$1) {
236
- return path$1.replace(/\\/g, "/");
178
+ function slash(path) {
179
+ return path.replace(/\\/g, "/");
237
180
  }
238
181
 
239
182
  //#endregion
240
183
  //#region src/tools/preview-stories.ts
241
- const PREVIEW_STORIES_TOOL_NAME = "preview-stories";
242
184
  const PREVIEW_STORIES_RESOURCE_URI = `ui://${PREVIEW_STORIES_TOOL_NAME}/preview.html`;
243
185
  const PreviewStoriesInput = v.object({ stories: StoryInputArray });
244
186
  const PreviewStoriesOutput = v.object({ stories: v.array(v.union([v.object({
@@ -261,19 +203,19 @@ async function addPreviewStoriesTool(server) {
261
203
  uri: PREVIEW_STORIES_RESOURCE_URI,
262
204
  mimeType: "text/html;profile=mcp-app"
263
205
  }, () => {
264
- const origin$1 = server.ctx.custom.origin;
206
+ const origin = server.ctx.custom.origin;
265
207
  return { contents: [{
266
208
  uri: PREVIEW_STORIES_RESOURCE_URI,
267
209
  mimeType: "text/html;profile=mcp-app",
268
210
  text: appHtml,
269
211
  _meta: { ui: {
270
212
  prefersBorder: false,
271
- domain: origin$1,
213
+ domain: origin,
272
214
  csp: {
273
- connectDomains: [origin$1],
274
- resourceDomains: [origin$1],
275
- frameDomains: [origin$1],
276
- baseUriDomains: [origin$1]
215
+ connectDomains: [origin],
216
+ resourceDomains: [origin],
217
+ frameDomains: [origin],
218
+ baseUriDomains: [origin]
277
219
  }
278
220
  } }
279
221
  }] };
@@ -288,9 +230,9 @@ async function addPreviewStoriesTool(server) {
288
230
  _meta: { ui: { resourceUri: PREVIEW_STORIES_RESOURCE_URI } }
289
231
  }, async (input) => {
290
232
  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);
233
+ const { origin, disableTelemetry } = server.ctx.custom ?? {};
234
+ if (!origin) throw new Error("Origin is required in addon context");
235
+ const index = await fetchStoryIndex(origin);
294
236
  const entriesList = Object.values(index.entries);
295
237
  const structuredResult = [];
296
238
  const textResult = [];
@@ -309,7 +251,7 @@ async function addPreviewStoriesTool(server) {
309
251
  const foundStory = entriesList.find((entry) => normalizeImportPath(entry.importPath) === relativePath && [explicitStoryName, storyNameFromExport(exportName)].includes(entry.name));
310
252
  if (foundStory) {
311
253
  logger.debug(`Found story ID: ${foundStory.id}`);
312
- let previewUrl = `${origin$1}/?path=/story/${foundStory.id}`;
254
+ let previewUrl = `${origin}/?path=/story/${foundStory.id}`;
313
255
  const argsParam = buildArgsParam(inputParams.props ?? {});
314
256
  if (argsParam) previewUrl += `&args=${argsParam}`;
315
257
  const globalsParam = buildArgsParam(inputParams.globals ?? {});
@@ -331,7 +273,7 @@ async function addPreviewStoriesTool(server) {
331
273
  textResult.push(errorMessage);
332
274
  }
333
275
  }
334
- if (!disableTelemetry$1) await collectTelemetry({
276
+ if (!disableTelemetry) await collectTelemetry({
335
277
  event: "tool:previewStories",
336
278
  server,
337
279
  toolset: "dev",
@@ -351,6 +293,446 @@ async function addPreviewStoriesTool(server) {
351
293
  });
352
294
  }
353
295
 
296
+ //#endregion
297
+ //#region src/utils/find-story-ids.ts
298
+ /**
299
+ * Finds story IDs in the story index that match the given story inputs.
300
+ *
301
+ * @param index - The Storybook story index
302
+ * @param stories - Array of story inputs to search for
303
+ * @returns Object containing found stories with their IDs and not-found stories with error messages
304
+ */
305
+ function findStoryIds(index, stories) {
306
+ const entriesList = Object.values(index.entries);
307
+ const result = {
308
+ found: [],
309
+ notFound: []
310
+ };
311
+ for (const storyInput of stories) {
312
+ const { exportName, explicitStoryName, absoluteStoryPath } = storyInput;
313
+ const normalizedCwd = slash(process.cwd());
314
+ const normalizedAbsolutePath = slash(absoluteStoryPath);
315
+ const relativePath = `./${path.posix.relative(normalizedCwd, normalizedAbsolutePath)}`;
316
+ logger.debug("Searching for:");
317
+ logger.debug({
318
+ exportName,
319
+ explicitStoryName,
320
+ absoluteStoryPath,
321
+ relativePath
322
+ });
323
+ const foundEntry = entriesList.find((entry) => entry.importPath === relativePath && [explicitStoryName, storyNameFromExport(exportName)].includes(entry.name));
324
+ if (foundEntry) {
325
+ logger.debug(`Found story ID: ${foundEntry.id}`);
326
+ result.found.push({
327
+ id: foundEntry.id,
328
+ input: storyInput
329
+ });
330
+ } else {
331
+ logger.debug("No story found");
332
+ let errorMessage = `No story found for export name "${exportName}" with absolute file path "${absoluteStoryPath}"`;
333
+ if (!explicitStoryName) errorMessage += ` (did you forget to pass the explicit story name?)`;
334
+ result.notFound.push({
335
+ input: storyInput,
336
+ errorMessage
337
+ });
338
+ }
339
+ }
340
+ return result;
341
+ }
342
+
343
+ //#endregion
344
+ //#region src/tools/run-story-tests.ts
345
+ /**
346
+ * Check if addon-vitest is available by trying to import its constants.
347
+ * Returns the constants if available, undefined otherwise.
348
+ */
349
+ async function getAddonVitestConstants() {
350
+ try {
351
+ const mod = await import("@storybook/addon-vitest/constants");
352
+ return {
353
+ TRIGGER_TEST_RUN_REQUEST: mod.TRIGGER_TEST_RUN_REQUEST,
354
+ TRIGGER_TEST_RUN_RESPONSE: mod.TRIGGER_TEST_RUN_RESPONSE
355
+ };
356
+ } catch {
357
+ return;
358
+ }
359
+ }
360
+ const RunStoryTestsInput = v.object({
361
+ stories: v.optional(v.pipe(StoryInputArray, v.description(`Stories to test for focused feedback. Omit this field to run tests for all available stories.
362
+ Prefer running tests for specific stories while developing to get faster feedback,
363
+ and only omit this when you explicitly need to run all tests for comprehensive verification.`))),
364
+ 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)
365
+ });
366
+ /**
367
+ * Creates a queue that ensures concurrent calls are executed in sequence.
368
+ * Call `wait()` to wait for your turn, then call the
369
+ * returned `done()` function when done to unblock the next caller.
370
+ */
371
+ function createAsyncQueue() {
372
+ let tail = Promise.resolve();
373
+ /**
374
+ * Wait for all previously queued operations to complete, then return
375
+ * a `done` function that must be called when the current operation finishes.
376
+ */
377
+ async function wait() {
378
+ let done;
379
+ const gate = new Promise((resolve) => {
380
+ done = resolve;
381
+ });
382
+ const previousTail = tail;
383
+ tail = previousTail.then(() => gate, () => gate);
384
+ await previousTail.catch(() => {});
385
+ return done;
386
+ }
387
+ return { wait };
388
+ }
389
+ async function addRunStoryTestsTool(server, { a11yEnabled }) {
390
+ const addonVitestConstants = await getAddonVitestConstants();
391
+ const testRunQueue = createAsyncQueue();
392
+ const description = `Run story tests.
393
+ Provide stories for focused runs (faster while iterating),
394
+ or omit stories to run all tests for full-project verification.
395
+ Use this continuously to monitor test results as you work on your UI components and stories.
396
+ Results will include passing/failing status` + (a11yEnabled ? `, and accessibility violation reports.
397
+ For visual/design accessibility violations (for example color contrast), ask the user before changing styles.` : ".");
398
+ server.tool({
399
+ name: RUN_STORY_TESTS_TOOL_NAME,
400
+ title: "Storybook Tests",
401
+ description,
402
+ schema: RunStoryTestsInput,
403
+ enabled: () => {
404
+ if (!addonVitestConstants) return false;
405
+ return server.ctx.custom?.toolsets?.test ?? true;
406
+ }
407
+ }, async (input) => {
408
+ let done;
409
+ try {
410
+ done = await testRunQueue.wait();
411
+ const runA11y = input.a11y ?? true;
412
+ const { origin, options, disableTelemetry } = server.ctx.custom ?? {};
413
+ if (!origin) throw new Error("Origin is required in addon context");
414
+ if (!options) throw new Error("Options are required in addon context");
415
+ const channel = options.channel;
416
+ if (!channel) throw new Error("Channel is not available");
417
+ let storyIds;
418
+ let inputStoryCount = 0;
419
+ if (input.stories) {
420
+ const { found, notFound } = findStoryIds(await fetchStoryIndex(origin), input.stories);
421
+ storyIds = found.map((story) => story.id);
422
+ inputStoryCount = input.stories.length;
423
+ if (storyIds.length === 0) {
424
+ const errorMessages = notFound.map((story) => story.errorMessage).join("\n");
425
+ if (!disableTelemetry) await collectTelemetry({
426
+ event: "tool:runStoryTests",
427
+ server,
428
+ toolset: "test",
429
+ runA11y,
430
+ inputStoryCount,
431
+ matchedStoryCount: 0,
432
+ passingStoryCount: 0,
433
+ failingStoryCount: 0,
434
+ a11yViolationCount: 0,
435
+ unhandledErrorCount: 0
436
+ });
437
+ return { content: [{
438
+ type: "text",
439
+ text: `No stories found matching the provided input.
440
+
441
+ ${errorMessages}`
442
+ }] };
443
+ }
444
+ logger.info(`Running focused tests for story IDs: ${storyIds.join(", ")}`);
445
+ } else logger.info("Running tests for all stories");
446
+ const testResults = (await triggerTestRun(channel, addonVitestConstants.TRIGGER_TEST_RUN_REQUEST, addonVitestConstants.TRIGGER_TEST_RUN_RESPONSE, storyIds, { a11y: runA11y })).result;
447
+ if (!testResults) throw new Error("Test run response missing result data");
448
+ const { text, summary } = formatRunStoryTestResults({
449
+ testResults,
450
+ runA11y,
451
+ origin
452
+ });
453
+ if (!disableTelemetry) await collectTelemetry({
454
+ event: "tool:runStoryTests",
455
+ server,
456
+ toolset: "test",
457
+ runA11y,
458
+ inputStoryCount,
459
+ matchedStoryCount: testResults.storyIds?.length ?? storyIds?.length ?? 0,
460
+ ...summary
461
+ });
462
+ return { content: [{
463
+ type: "text",
464
+ text
465
+ }] };
466
+ } catch (error) {
467
+ return errorToMCPContent(error);
468
+ } finally {
469
+ try {
470
+ done?.();
471
+ } catch (error) {
472
+ logger.warn(`Failed to release test run queue: ${String(error)}`);
473
+ }
474
+ }
475
+ });
476
+ }
477
+ /**
478
+ * Trigger a test run via Storybook channel events.
479
+ * This is the channel-based API for triggering tests in addon-vitest.
480
+ */
481
+ function triggerTestRun(channel, triggerTestRunRequestEventName, triggerTestRunResponseEventName, storyIds, config) {
482
+ return new Promise((resolve, reject) => {
483
+ const requestId = `mcp-${Date.now()}`;
484
+ let settled = false;
485
+ const cleanup = () => {
486
+ channel.off(triggerTestRunResponseEventName, handleResponse);
487
+ };
488
+ const settle = (callback) => {
489
+ if (settled) return;
490
+ settled = true;
491
+ cleanup();
492
+ callback();
493
+ };
494
+ const handleResponse = (payload) => {
495
+ if (payload.requestId !== requestId) return;
496
+ switch (payload.status) {
497
+ case "completed":
498
+ if (payload.result) settle(() => resolve(payload));
499
+ else settle(() => reject(/* @__PURE__ */ new Error("Test run completed but no result was returned")));
500
+ break;
501
+ case "error":
502
+ settle(() => reject(new Error(payload.error?.message ?? "Test run failed with unknown error")));
503
+ break;
504
+ case "cancelled":
505
+ settle(() => reject(/* @__PURE__ */ new Error("Test run was cancelled")));
506
+ break;
507
+ default: settle(() => reject(/* @__PURE__ */ new Error("Unexpected test run response")));
508
+ }
509
+ };
510
+ channel.on(triggerTestRunResponseEventName, handleResponse);
511
+ const request = {
512
+ requestId,
513
+ actor: "addon-mcp",
514
+ storyIds,
515
+ config
516
+ };
517
+ try {
518
+ channel.emit(triggerTestRunRequestEventName, request);
519
+ } catch (error) {
520
+ settle(() => reject(error instanceof Error ? error : new Error(String(error))));
521
+ }
522
+ });
523
+ }
524
+ function formatRunStoryTestResults({ testResults, runA11y, origin }) {
525
+ const sections = [];
526
+ const componentTestStatuses = testResults.componentTestStatuses;
527
+ const passingStories = componentTestStatuses.filter((status) => status.value === "status-value:success");
528
+ const failingStories = componentTestStatuses.filter((status) => status.value === "status-value:error");
529
+ if (passingStories.length > 0) sections.push(formatPassingStoriesSection(passingStories));
530
+ if (failingStories.length > 0) sections.push(formatFailingStoriesSection(failingStories));
531
+ const a11yReports = testResults.a11yReports;
532
+ const a11yViolationCount = runA11y ? countA11yViolations(a11yReports) : 0;
533
+ if (runA11y && a11yReports && Object.keys(a11yReports).length > 0) {
534
+ const a11ySection = formatA11yReportsSection({
535
+ a11yReports,
536
+ origin
537
+ });
538
+ if (a11ySection) sections.push(a11ySection);
539
+ }
540
+ if (testResults.unhandledErrors.length > 0) sections.push(formatUnhandledErrorsSection(testResults.unhandledErrors));
541
+ return {
542
+ text: sections.join("\n\n"),
543
+ summary: {
544
+ passingStoryCount: passingStories.length,
545
+ failingStoryCount: failingStories.length,
546
+ a11yViolationCount,
547
+ unhandledErrorCount: testResults.unhandledErrors.length
548
+ }
549
+ };
550
+ }
551
+ function formatPassingStoriesSection(passingStories) {
552
+ return `## Passing Stories
553
+
554
+ - ${passingStories.map((status) => status.storyId).join("\n- ")}`;
555
+ }
556
+ function formatFailingStoriesSection(statuses) {
557
+ return `## Failing Stories
558
+
559
+ ${statuses.map((status) => `### ${status.storyId}
560
+
561
+ ${status.description || "No failure details available."}`).join("\n\n")}`;
562
+ }
563
+ function formatA11yReportsSection({ a11yReports, origin }) {
564
+ const a11yViolationSections = [];
565
+ for (const [storyId, reports] of Object.entries(a11yReports)) for (const report of reports) {
566
+ if ("error" in report && report.error) {
567
+ a11yViolationSections.push(`### ${storyId} - Error
568
+
569
+ ${report.error.message}`);
570
+ continue;
571
+ }
572
+ const violations = getA11yViolations(report);
573
+ if (violations.length === 0) continue;
574
+ for (const violation of violations) {
575
+ const nodes = violation.nodes.map((node) => {
576
+ const inspectLink = node.linkPath ? `${origin}${node.linkPath}` : void 0;
577
+ const parts = [];
578
+ if (node.impact) parts.push(`- **Impact**: ${node.impact}`);
579
+ if (node.failureSummary || node.message) parts.push(` **Message**: ${node.failureSummary || node.message}`);
580
+ parts.push(` **Element**: ${node.html || "(no html available)"}`);
581
+ if (inspectLink) parts.push(` **Inspect**: ${inspectLink}`);
582
+ return parts.join("\n");
583
+ }).join("\n");
584
+ a11yViolationSections.push(`### ${storyId} - ${violation.id}
585
+
586
+ ${violation.description}
587
+
588
+ #### Affected Elements
589
+ ${nodes}`);
590
+ }
591
+ }
592
+ if (a11yViolationSections.length === 0) return;
593
+ return `## Accessibility Violations
594
+
595
+ ${a11yViolationSections.join("\n\n")}`;
596
+ }
597
+ function formatUnhandledErrorsSection(errors) {
598
+ return `## Unhandled Errors
599
+
600
+ ${errors.map((unhandledError) => `### ${unhandledError.name || "Unknown Error"}
601
+
602
+ **Error message**: ${unhandledError.message || "No message available"}
603
+ **Path**: ${unhandledError.VITEST_TEST_PATH || "No path available"}
604
+ **Test name**: ${unhandledError.VITEST_TEST_NAME || "No test name available"}
605
+ **Stack trace**:
606
+ ${unhandledError.stack || "No stack trace available"}`).join("\n\n")}`;
607
+ }
608
+ function countA11yViolations(a11yReports) {
609
+ let count = 0;
610
+ for (const reports of Object.values(a11yReports ?? {})) for (const report of reports) {
611
+ if ("error" in report && report.error) continue;
612
+ count += getA11yViolations(report).length;
613
+ }
614
+ return count;
615
+ }
616
+ function getA11yViolations(report) {
617
+ if (!("violations" in report)) return [];
618
+ const { violations } = report;
619
+ if (!Array.isArray(violations)) return [];
620
+ return violations.map((violation) => ({
621
+ id: violation.id,
622
+ description: violation.description,
623
+ nodes: violation.nodes.map((node) => ({
624
+ impact: typeof node.impact === "string" ? node.impact : void 0,
625
+ failureSummary: typeof node.failureSummary === "string" ? node.failureSummary : void 0,
626
+ html: typeof node.html === "string" ? node.html : void 0,
627
+ linkPath: typeof node.linkPath === "string" ? node.linkPath : void 0
628
+ }))
629
+ }));
630
+ }
631
+
632
+ //#endregion
633
+ //#region src/instructions/storybook-story-instructions.md
634
+ 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";
635
+
636
+ //#endregion
637
+ //#region src/instructions/story-testing-instructions.md
638
+ 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";
639
+
640
+ //#endregion
641
+ //#region src/instructions/a11y-instructions.md
642
+ 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";
643
+
644
+ //#endregion
645
+ //#region src/utils/is-addon-a11y-enabled.ts
646
+ /**
647
+ * Check if @storybook/addon-a11y is enabled in the Storybook configuration.
648
+ */
649
+ async function isAddonA11yEnabled(options) {
650
+ try {
651
+ return await options.presets.apply("isAddonA11yEnabled", false);
652
+ } catch {
653
+ return false;
654
+ }
655
+ }
656
+
657
+ //#endregion
658
+ //#region src/tools/get-storybook-story-instructions.ts
659
+ async function addGetUIBuildingInstructionsTool(server) {
660
+ const addonVitestAvailable = !!await getAddonVitestConstants();
661
+ server.tool({
662
+ name: GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME,
663
+ title: "Storybook Story Development Instructions",
664
+ get description() {
665
+ const testToolsetAvailable = (server.ctx.custom?.toolsets?.test ?? true) && addonVitestAvailable;
666
+ const a11yAvailable = testToolsetAvailable && (server.ctx.custom?.a11yEnabled ?? false);
667
+ return `Get comprehensive instructions for writing, testing, and fixing Storybook stories (.stories.tsx, .stories.ts, .stories.jsx, .stories.js, .stories.svelte, .stories.vue files).
668
+
669
+ CRITICAL: You MUST call this tool before:
670
+ - Creating new Storybook stories or story files
671
+ - Updating or modifying existing Storybook stories
672
+ - Adding new story variants or exports to story files
673
+ - Editing any file matching *.stories.* patterns
674
+ - Writing components that will need stories${testToolsetAvailable ? `
675
+ - Running story tests or fixing test failures` : ""}${a11yAvailable ? `
676
+ - Handling accessibility (a11y) violations in stories (fix semantic issues directly; ask before visual/design changes)` : ""}
677
+
678
+ This tool provides essential Storybook-specific guidance including:
679
+ - How to structure stories correctly for Storybook 9
680
+ - Required imports (Meta, StoryObj from framework package)
681
+ - Test utility imports (from 'storybook/test')
682
+ - Story naming conventions and best practices
683
+ - Play function patterns for interactive testing
684
+ - Mocking strategies for external dependencies
685
+ - Story variants and coverage requirements${testToolsetAvailable ? `
686
+ - How to handle test failures${a11yAvailable ? " and accessibility violations" : ""}` : ""}
687
+
688
+ 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.`;
689
+ },
690
+ enabled: () => server.ctx.custom?.toolsets?.dev ?? true
691
+ }, async () => {
692
+ try {
693
+ const { options, disableTelemetry } = server.ctx.custom ?? {};
694
+ if (!options) throw new Error("Options are required in addon context");
695
+ if (!disableTelemetry) await collectTelemetry({
696
+ event: "tool:getUIBuildingInstructions",
697
+ server,
698
+ toolset: "dev"
699
+ });
700
+ const frameworkPreset = await options.presets.apply("framework");
701
+ const framework = typeof frameworkPreset === "string" ? frameworkPreset : frameworkPreset?.name;
702
+ const renderer = frameworkToRendererMap[framework];
703
+ let uiInstructions = storybook_story_instructions_default.replace("{{FRAMEWORK}}", framework).replace("{{RENDERER}}", renderer ?? framework).replace("{{PREVIEW_STORIES_TOOL_NAME}}", PREVIEW_STORIES_TOOL_NAME);
704
+ if ((server.ctx.custom?.toolsets?.test ?? true) && !!await getAddonVitestConstants()) {
705
+ const a11yEnabled = server.ctx.custom?.a11yEnabled ?? false;
706
+ const a11yFixSuffix = a11yEnabled ? " (see a11y guidelines below)" : "";
707
+ const storyTestingInstructions = story_testing_instructions_default.replaceAll("{{RUN_STORY_TESTS_TOOL_NAME}}", RUN_STORY_TESTS_TOOL_NAME).replace("{{A11Y_FIX_SUFFIX}}", a11yFixSuffix);
708
+ uiInstructions += `\n\n${storyTestingInstructions}`;
709
+ if (a11yEnabled) uiInstructions += `\n${a11y_instructions_default}`;
710
+ }
711
+ return { content: [{
712
+ type: "text",
713
+ text: uiInstructions
714
+ }] };
715
+ } catch (error) {
716
+ return errorToMCPContent(error);
717
+ }
718
+ });
719
+ }
720
+ const frameworkToRendererMap = {
721
+ "@storybook/react-vite": "@storybook/react",
722
+ "@storybook/react-webpack5": "@storybook/react",
723
+ "@storybook/nextjs": "@storybook/react",
724
+ "@storybook/nextjs-vite": "@storybook/react",
725
+ "@storybook/react-native-web-vite": "@storybook/react",
726
+ "@storybook/vue3-vite": "@storybook/vue3",
727
+ "@nuxtjs/storybook": "@storybook/vue3",
728
+ "@storybook/angular": "@storybook/angular",
729
+ "@storybook/svelte-vite": "@storybook/svelte",
730
+ "@storybook/sveltekit": "@storybook/svelte",
731
+ "@storybook/preact-vite": "@storybook/preact",
732
+ "@storybook/web-components-vite": "@storybook/web-components",
733
+ "@storybook/html-vite": "@storybook/html"
734
+ };
735
+
354
736
  //#endregion
355
737
  //#region src/tools/is-manifest-available.ts
356
738
  const getManifestStatus = async (options) => {
@@ -428,6 +810,7 @@ let transport;
428
810
  let origin;
429
811
  let initialize;
430
812
  let disableTelemetry;
813
+ let a11yEnabled;
431
814
  const initializeMCPServer = async (options, multiSource) => {
432
815
  disableTelemetry = (await options.presets.apply("core", {}))?.disableTelemetry ?? false;
433
816
  const server = new McpServer({
@@ -449,6 +832,8 @@ const initializeMCPServer = async (options, multiSource) => {
449
832
  });
450
833
  await addPreviewStoriesTool(server);
451
834
  await addGetUIBuildingInstructionsTool(server);
835
+ a11yEnabled = await isAddonA11yEnabled(options);
836
+ await addRunStoryTestsTool(server, { a11yEnabled });
452
837
  if ((await getManifestStatus(options)).available) {
453
838
  logger.info("Experimental components manifest feature detected - registering component tools");
454
839
  const contextAwareEnabled = () => server.ctx.custom?.toolsets?.docs ?? true;
@@ -469,6 +854,7 @@ const mcpServerHandler = async ({ req, res, options, addonOptions, sources, mani
469
854
  toolsets: getToolsets(webRequest, addonOptions),
470
855
  origin,
471
856
  disableTelemetry,
857
+ a11yEnabled,
472
858
  request: webRequest,
473
859
  sources,
474
860
  manifestProvider,
@@ -517,9 +903,9 @@ const mcpServerHandler = async ({ req, res, options, addonOptions, sources, mani
517
903
  async function incomingMessageToWebRequest(req) {
518
904
  const host = req.headers.host || "localhost";
519
905
  const protocol = "encrypted" in req.socket && req.socket.encrypted ? "https" : "http";
520
- const url$1 = new URL(req.url || "/", `${protocol}://${host}`);
906
+ const url = new URL(req.url || "/", `${protocol}://${host}`);
521
907
  const bodyBuffer = await buffer(req);
522
- return new Request(url$1, {
908
+ return new Request(url, {
523
909
  method: req.method,
524
910
  headers: req.headers,
525
911
  body: bodyBuffer.length > 0 ? new Uint8Array(bodyBuffer) : void 0
@@ -552,7 +938,8 @@ function getToolsets(request, addonOptions) {
552
938
  if (!toolsetHeader || toolsetHeader.trim() === "") return addonOptions.toolsets;
553
939
  const toolsets = {
554
940
  dev: false,
555
- docs: false
941
+ docs: false,
942
+ test: false
556
943
  };
557
944
  const enabledToolsets = toolsetHeader.split(",");
558
945
  for (const enabledToolset of enabledToolsets) {
@@ -564,7 +951,7 @@ function getToolsets(request, addonOptions) {
564
951
 
565
952
  //#endregion
566
953
  //#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";
954
+ 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
955
 
569
956
  //#endregion
570
957
  //#region src/auth/composition-auth.ts
@@ -589,8 +976,8 @@ const OAuthServerMetadata = v.object({
589
976
  const MANIFEST_CACHE_TTL = 3600 * 1e3;
590
977
  const REVALIDATION_TTL = 60 * 1e3;
591
978
  var AuthenticationError = class extends Error {
592
- constructor(url$1) {
593
- super(`Authentication failed for ${url$1}. Your token may be invalid or expired.`);
979
+ constructor(url) {
980
+ super(`Authentication failed for ${url}. Your token may be invalid or expired.`);
594
981
  this.name = "AuthenticationError";
595
982
  }
596
983
  };
@@ -630,21 +1017,21 @@ var CompositionAuth = class {
630
1017
  return this.#authErrors.has(request);
631
1018
  }
632
1019
  /** 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));
1020
+ #isAuthRequiredUrl(url) {
1021
+ return this.#authRequiredUrls.some((authUrl) => url.startsWith(authUrl));
635
1022
  }
636
1023
  /** Build .well-known/oauth-protected-resource response. */
637
- buildWellKnown(origin$1) {
1024
+ buildWellKnown(origin) {
638
1025
  if (!this.#authRequirement) return null;
639
1026
  return {
640
- resource: `${origin$1}/mcp`,
1027
+ resource: `${origin}/mcp`,
641
1028
  authorization_servers: this.#authRequirement.resourceMetadata.authorization_servers,
642
1029
  scopes_supported: this.#authRequirement.resourceMetadata.scopes_supported
643
1030
  };
644
1031
  }
645
1032
  /** 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"`;
1033
+ buildWwwAuthenticate(origin) {
1034
+ return `Bearer error="unauthorized", error_description="Authorization needed for composed Storybooks", resource_metadata="${origin}/.well-known/oauth-protected-resource"`;
648
1035
  }
649
1036
  /** Build sources configuration: local first, then refs that have manifests. */
650
1037
  buildSources() {
@@ -659,10 +1046,10 @@ var CompositionAuth = class {
659
1046
  }
660
1047
  /** Create a manifest provider for multi-source mode. */
661
1048
  createManifestProvider(localOrigin) {
662
- return async (request, path$1, source) => {
1049
+ return async (request, path, source) => {
663
1050
  const token = extractBearerToken(request?.headers.get("Authorization"));
664
1051
  const baseUrl = source?.url ?? localOrigin;
665
- const manifestUrl = `${baseUrl}${path$1.replace("./", "/")}`;
1052
+ const manifestUrl = `${baseUrl}${path.replace("./", "/")}`;
666
1053
  const isRemote = !!source?.url;
667
1054
  const tokenForRequest = isRemote && this.#isAuthRequiredUrl(baseUrl) ? token : null;
668
1055
  if (token && token !== this.#lastToken) {
@@ -701,17 +1088,17 @@ var CompositionAuth = class {
701
1088
  * Fetch a manifest with optional auth token.
702
1089
  * If the response is 200 but not a valid manifest, checks /mcp for auth issues.
703
1090
  */
704
- async #fetchManifest(url$1, token) {
1091
+ async #fetchManifest(url, token) {
705
1092
  const headers = { Accept: "application/json" };
706
1093
  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}`);
1094
+ const response = await fetch(url, { headers });
1095
+ if (response.status === 401) throw new AuthenticationError(url);
1096
+ if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status}`);
710
1097
  const text = await response.text();
711
- const schema = url$1.includes("docs.json") ? DocsManifestMap : ComponentManifestMap;
1098
+ const schema = url.includes("docs.json") ? DocsManifestMap : ComponentManifestMap;
712
1099
  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.`);
1100
+ if (await this.#isMcpUnauthorized(new URL(url).origin)) throw new AuthenticationError(url);
1101
+ throw new Error(`Invalid manifest response from ${url}: expected valid JSON manifest but got unexpected content.`);
715
1102
  }
716
1103
  /**
717
1104
  * Check a ref to determine if it has a manifest and whether it requires auth.
@@ -744,9 +1131,9 @@ var CompositionAuth = class {
744
1131
  return this.#parseAuthFromResponse(response);
745
1132
  }
746
1133
  /** Quick check: does the remote /mcp return 401? */
747
- async #isMcpUnauthorized(origin$1) {
1134
+ async #isMcpUnauthorized(origin) {
748
1135
  try {
749
- return (await fetch(`${origin$1}/mcp`, {
1136
+ return (await fetch(`${origin}/mcp`, {
750
1137
  method: "POST",
751
1138
  headers: { "Content-Type": "application/json" },
752
1139
  body: JSON.stringify({
@@ -811,7 +1198,7 @@ const previewAnnotations = async (existingAnnotations = []) => {
811
1198
  };
812
1199
  const experimental_devServer = async (app, options) => {
813
1200
  const addonOptions = v.parse(AddonOptions, { toolsets: "toolsets" in options ? options.toolsets : {} });
814
- const origin$1 = `http://localhost:${options.port}`;
1201
+ const origin = `http://localhost:${options.port}`;
815
1202
  const refs = await getRefsFromConfig(options);
816
1203
  const compositionAuth = new CompositionAuth();
817
1204
  let sources;
@@ -822,10 +1209,10 @@ const experimental_devServer = async (app, options) => {
822
1209
  if (compositionAuth.requiresAuth) logger.info(`Auth required for: ${compositionAuth.authUrls.join(", ")}`);
823
1210
  sources = compositionAuth.buildSources();
824
1211
  logger.info(`Sources: ${sources.map((s) => s.id).join(", ")}`);
825
- manifestProvider = compositionAuth.createManifestProvider(origin$1);
1212
+ manifestProvider = compositionAuth.createManifestProvider(origin);
826
1213
  }
827
1214
  app.get("/.well-known/oauth-protected-resource", (_req, res) => {
828
- const wellKnown = compositionAuth.buildWellKnown(origin$1);
1215
+ const wellKnown = compositionAuth.buildWellKnown(origin);
829
1216
  if (!wellKnown) {
830
1217
  res.writeHead(404);
831
1218
  res.end("Not found");
@@ -839,7 +1226,7 @@ const experimental_devServer = async (app, options) => {
839
1226
  if (compositionAuth.requiresAuth && !token) {
840
1227
  res.writeHead(401, {
841
1228
  "Content-Type": "text/plain",
842
- "WWW-Authenticate": compositionAuth.buildWwwAuthenticate(origin$1)
1229
+ "WWW-Authenticate": compositionAuth.buildWwwAuthenticate(origin)
843
1230
  });
844
1231
  res.end("401 - Unauthorized");
845
1232
  return true;
@@ -859,8 +1246,11 @@ const experimental_devServer = async (app, options) => {
859
1246
  });
860
1247
  });
861
1248
  const manifestStatus = await getManifestStatus(options);
1249
+ const addonVitestConstants = await getAddonVitestConstants();
1250
+ const a11yEnabled = await isAddonA11yEnabled(options);
862
1251
  const isDevEnabled = addonOptions.toolsets?.dev ?? true;
863
1252
  const isDocsEnabled = manifestStatus.available && (addonOptions.toolsets?.docs ?? true);
1253
+ const isTestEnabled = !!addonVitestConstants && (addonOptions.toolsets?.test ?? true);
864
1254
  app.get("/mcp", (req, res) => {
865
1255
  if (!req.headers["accept"]?.includes("text/html")) {
866
1256
  if (requireAuth(req, res)) return;
@@ -883,7 +1273,10 @@ const experimental_devServer = async (app, options) => {
883
1273
  This toolset requires enabling the experimental component manifest feature.
884
1274
  <a target="_blank" href="https://github.com/storybookjs/mcp/tree/main/packages/addon-mcp#docs-tools-experimental">Learn how to enable it</a>
885
1275
  </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);
1276
+ 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);
1277
+ const testNotice = testNoticeLines.length ? `<div class="toolset-notice">${testNoticeLines.join("<br>")}</div>` : "";
1278
+ const a11yBadge = a11yEnabled ? " <span class=\"toolset-status enabled\">+ accessibility</span>" : "";
1279
+ 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
1280
  res.end(html);
888
1281
  });
889
1282
  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.0";
7
+ var version = "0.3.2";
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.0",
3
+ "version": "0.3.2",
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.3.0"
37
+ "@storybook/mcp": "0.4.1"
38
38
  },
39
39
  "devDependencies": {
40
- "storybook": "10.3.0-alpha.7"
40
+ "@storybook/addon-a11y": "10.3.0-alpha.8",
41
+ "@storybook/addon-vitest": "10.3.0-alpha.8",
42
+ "storybook": "10.3.0-alpha.8"
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",