@storybook/addon-mcp 0.3.2 → 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.2";
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
@@ -108,6 +108,83 @@ async function fetchStoryIndex(origin) {
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
  /**
@@ -146,20 +223,30 @@ const AddonOptions = v.object({ toolsets: v.optional(v.object({
146
223
  docs: true,
147
224
  test: true
148
225
  }) });
149
- /**
150
- * Schema for a single story input when requesting story URLs.
151
- */
152
- const StoryInput = v.object({
153
- exportName: v.string(),
154
- 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.
155
- Otherwise don't set this.`)),
156
- absoluteStoryPath: v.string(),
226
+ const StoryInputProps = {
157
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,
158
228
  but you want to customize some args or other props.
159
229
  You can look up the component's documentation using the ${GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME} tool to see what props are available.`)),
160
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.
161
231
  Common globals include 'theme' (e.g., 'dark', 'light'), 'locale' (e.g., 'en', 'fr'), and 'backgrounds' (e.g., { value: '#000' }).`))
162
- });
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
+ })]);
163
250
  /**
164
251
  * Schema for the array of stories to fetch URLs for.
165
252
  */
@@ -169,24 +256,17 @@ const StoryInputArray = v.array(StoryInput);
169
256
  //#region src/tools/preview-stories/preview-stories-app-template.html
170
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";
171
258
 
172
- //#endregion
173
- //#region src/utils/slash.ts
174
- /**
175
- * Normalize paths to forward slashes for cross-platform compatibility
176
- * Storybook import paths always use forward slashes
177
- */
178
- function slash(path) {
179
- return path.replace(/\\/g, "/");
180
- }
181
-
182
259
  //#endregion
183
260
  //#region src/tools/preview-stories.ts
184
261
  const PREVIEW_STORIES_RESOURCE_URI = `ui://${PREVIEW_STORIES_TOOL_NAME}/preview.html`;
185
- 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.`)) });
186
266
  const PreviewStoriesOutput = v.object({ stories: v.array(v.union([v.object({
187
267
  title: v.string(),
188
268
  name: v.string(),
189
- 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."))
190
270
  }), v.object({
191
271
  input: StoryInput,
192
272
  error: v.string()
@@ -194,9 +274,6 @@ const PreviewStoriesOutput = v.object({ stories: v.array(v.union([v.object({
194
274
  async function addPreviewStoriesTool(server) {
195
275
  const previewStoryAppScript = await fs.readFile(url.fileURLToPath(import.meta.resolve("@storybook/addon-mcp/internal/preview-stories-app-script")), "utf-8");
196
276
  const appHtml = preview_stories_app_template_default.replace("// APP_SCRIPT_PLACEHOLDER", previewStoryAppScript);
197
- const normalizeImportPath = (importPath) => {
198
- return slash(normalizeStoryPath(path.posix.normalize(slash(importPath))));
199
- };
200
277
  server.resource({
201
278
  name: PREVIEW_STORIES_RESOURCE_URI,
202
279
  description: "App resource for the Preview Stories tool",
@@ -222,8 +299,9 @@ async function addPreviewStoriesTool(server) {
222
299
  });
223
300
  server.tool({
224
301
  name: PREVIEW_STORIES_TOOL_NAME,
225
- title: "Preview stories",
226
- 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.`,
227
305
  schema: PreviewStoriesInput,
228
306
  outputSchema: PreviewStoriesOutput,
229
307
  enabled: () => server.ctx.custom?.toolsets?.dev ?? true,
@@ -233,45 +311,38 @@ async function addPreviewStoriesTool(server) {
233
311
  const { origin, disableTelemetry } = server.ctx.custom ?? {};
234
312
  if (!origin) throw new Error("Origin is required in addon context");
235
313
  const index = await fetchStoryIndex(origin);
236
- const entriesList = Object.values(index.entries);
314
+ const resolvedStories = findStoryIds(index, input.stories);
237
315
  const structuredResult = [];
238
316
  const textResult = [];
239
- for (const inputParams of input.stories) {
240
- const { exportName, explicitStoryName, absoluteStoryPath } = inputParams;
241
- const normalizedCwd = slash(process.cwd());
242
- const normalizedAbsolutePath = slash(absoluteStoryPath);
243
- const relativePath = normalizeImportPath(path.posix.relative(normalizedCwd, normalizedAbsolutePath));
244
- logger.debug("Searching for:");
245
- logger.debug({
246
- exportName,
247
- explicitStoryName,
248
- absoluteStoryPath,
249
- relativePath
250
- });
251
- const foundStory = entriesList.find((entry) => normalizeImportPath(entry.importPath) === relativePath && [explicitStoryName, storyNameFromExport(exportName)].includes(entry.name));
252
- if (foundStory) {
253
- logger.debug(`Found story ID: ${foundStory.id}`);
254
- let previewUrl = `${origin}/?path=/story/${foundStory.id}`;
255
- const argsParam = buildArgsParam(inputParams.props ?? {});
256
- if (argsParam) previewUrl += `&args=${argsParam}`;
257
- const globalsParam = buildArgsParam(inputParams.globals ?? {});
258
- if (globalsParam) previewUrl += `&globals=${globalsParam}`;
317
+ for (const story of resolvedStories) {
318
+ if ("errorMessage" in story) {
259
319
  structuredResult.push({
260
- title: foundStory.title,
261
- name: foundStory.name,
262
- previewUrl
320
+ input: story.input,
321
+ error: story.errorMessage
263
322
  });
264
- textResult.push(previewUrl);
265
- } else {
266
- logger.debug("No story found");
267
- let errorMessage = `No story found for export name "${exportName}" with absolute file path "${absoluteStoryPath}"`;
268
- 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) {
269
328
  structuredResult.push({
270
- input: inputParams,
271
- error: errorMessage
329
+ input: story.input,
330
+ error: `No story found for story ID "${story.id}"`
272
331
  });
273
- textResult.push(errorMessage);
332
+ textResult.push(`No story found for story ID "${story.id}"`);
333
+ continue;
274
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);
275
346
  }
276
347
  if (!disableTelemetry) await collectTelemetry({
277
348
  event: "tool:previewStories",
@@ -293,53 +364,6 @@ async function addPreviewStoriesTool(server) {
293
364
  });
294
365
  }
295
366
 
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
367
  //#endregion
344
368
  //#region src/tools/run-story-tests.ts
345
369
  /**
@@ -360,7 +384,10 @@ async function getAddonVitestConstants() {
360
384
  const RunStoryTestsInput = v.object({
361
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.
362
386
  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.`))),
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.`))),
364
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)
365
392
  });
366
393
  /**
@@ -417,11 +444,11 @@ For visual/design accessibility violations (for example color contrast), ask the
417
444
  let storyIds;
418
445
  let inputStoryCount = 0;
419
446
  if (input.stories) {
420
- const { found, notFound } = findStoryIds(await fetchStoryIndex(origin), input.stories);
421
- storyIds = found.map((story) => story.id);
447
+ const resolvedStories = findStoryIds(await fetchStoryIndex(origin), input.stories);
448
+ storyIds = resolvedStories.filter((story) => "id" in story).map((story) => story.id);
422
449
  inputStoryCount = input.stories.length;
423
450
  if (storyIds.length === 0) {
424
- const errorMessages = notFound.map((story) => story.errorMessage).join("\n");
451
+ const errorMessages = resolvedStories.filter((story) => "errorMessage" in story).map((story) => story.errorMessage).join("\n");
425
452
  if (!disableTelemetry) await collectTelemetry({
426
453
  event: "tool:runStoryTests",
427
454
  server,
@@ -839,6 +866,7 @@ const initializeMCPServer = async (options, multiSource) => {
839
866
  const contextAwareEnabled = () => server.ctx.custom?.toolsets?.docs ?? true;
840
867
  await addListAllDocumentationTool(server, contextAwareEnabled);
841
868
  await addGetDocumentationTool(server, contextAwareEnabled, { multiSource });
869
+ await addGetStoryDocumentationTool(server, contextAwareEnabled, { multiSource });
842
870
  }
843
871
  transport = new HttpTransport(server, { path: null });
844
872
  origin = `http://localhost:${options.port}`;
@@ -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.2";
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.2",
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,12 +34,12 @@
34
34
  "picoquery": "^2.5.0",
35
35
  "tmcp": "^1.16.0",
36
36
  "valibot": "1.2.0",
37
- "@storybook/mcp": "0.4.1"
37
+ "@storybook/mcp": "0.5.0"
38
38
  },
39
39
  "devDependencies": {
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"
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"
43
43
  },
44
44
  "peerDependencies": {
45
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",