@storybook/addon-mcp 0.3.1 → 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 +505 -112
- package/dist/preview-stories-app-script.js +1 -1
- package/package.json +10 -7
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.
|
|
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
|
|
102
|
-
const indexUrl = `${origin
|
|
101
|
+
async function fetchStoryIndex(origin) {
|
|
102
|
+
const indexUrl = `${origin}/index.json`;
|
|
103
103
|
logger.debug(`Fetching story index from: ${indexUrl}`);
|
|
104
104
|
const response = await fetch(indexUrl);
|
|
105
105
|
if (!response.ok) throw new Error(`Failed to fetch story index: ${response.status} ${response.statusText}`);
|
|
@@ -127,81 +127,24 @@ const errorToMCPContent = (error) => {
|
|
|
127
127
|
};
|
|
128
128
|
|
|
129
129
|
//#endregion
|
|
130
|
-
//#region src/
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
236
|
-
return path
|
|
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
|
|
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
|
|
213
|
+
domain: origin,
|
|
272
214
|
csp: {
|
|
273
|
-
connectDomains: [origin
|
|
274
|
-
resourceDomains: [origin
|
|
275
|
-
frameDomains: [origin
|
|
276
|
-
baseUriDomains: [origin
|
|
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
|
|
292
|
-
if (!origin
|
|
293
|
-
const index = await fetchStoryIndex(origin
|
|
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
|
|
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
|
|
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
|
|
906
|
+
const url = new URL(req.url || "/", `${protocol}://${host}`);
|
|
521
907
|
const bodyBuffer = await buffer(req);
|
|
522
|
-
return new Request(url
|
|
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
|
|
593
|
-
super(`Authentication failed for ${url
|
|
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
|
|
634
|
-
return this.#authRequiredUrls.some((authUrl) => url
|
|
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
|
|
1024
|
+
buildWellKnown(origin) {
|
|
638
1025
|
if (!this.#authRequirement) return null;
|
|
639
1026
|
return {
|
|
640
|
-
resource: `${origin
|
|
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
|
|
647
|
-
return `Bearer error="unauthorized", error_description="Authorization needed for composed Storybooks", resource_metadata="${origin
|
|
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
|
|
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
|
|
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
|
|
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
|
|
708
|
-
if (response.status === 401) throw new AuthenticationError(url
|
|
709
|
-
if (!response.ok) throw new Error(`Failed to fetch ${url
|
|
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
|
|
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
|
|
714
|
-
throw new Error(`Invalid manifest response from ${url
|
|
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
|
|
1134
|
+
async #isMcpUnauthorized(origin) {
|
|
748
1135
|
try {
|
|
749
|
-
return (await fetch(`${origin
|
|
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
|
|
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
|
|
1212
|
+
manifestProvider = compositionAuth.createManifestProvider(origin);
|
|
826
1213
|
}
|
|
827
1214
|
app.get("/.well-known/oauth-protected-resource", (_req, res) => {
|
|
828
|
-
const wellKnown = compositionAuth.buildWellKnown(origin
|
|
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
|
|
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
|
|
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@storybook/addon-mcp",
|
|
3
|
-
"version": "0.3.
|
|
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.4.
|
|
37
|
+
"@storybook/mcp": "0.4.1"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"storybook": "10.3.0-alpha.
|
|
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
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@storybook/addon-vitest": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
49
52
|
},
|
|
50
53
|
"storybook": {
|
|
51
54
|
"displayName": "Addon MCP",
|