@storybook/addon-mcp
Version:
Help agents automatically write and test stories for your UI components
1,635 lines • 64 kB
JavaScript
import CJS_COMPAT_NODE_URL_lqqi79b8sm from 'node:url';
import CJS_COMPAT_NODE_PATH_lqqi79b8sm from 'node:path';
import CJS_COMPAT_NODE_MODULE_lqqi79b8sm from "node:module";
var __filename = CJS_COMPAT_NODE_URL_lqqi79b8sm.fileURLToPath(import.meta.url);
var __dirname = CJS_COMPAT_NODE_PATH_lqqi79b8sm.dirname(__filename);
var require = CJS_COMPAT_NODE_MODULE_lqqi79b8sm.createRequire(import.meta.url);
// ------------------------------------------------------------
// end of CJS compatibility banner, injected by Storybook's esbuild configuration
// ------------------------------------------------------------
// src/mcp-handler.ts
import { McpServer } from "tmcp";
import { ValibotJsonSchemaAdapter } from "@tmcp/adapter-valibot";
import { HttpTransport } from "@tmcp/transport-http";
// package.json
var package_default = {
name: "@storybook/addon-mcp",
version: "10.6.5",
description: "Help agents automatically write and test stories for your UI components",
keywords: [
"ai",
"mcp",
"storybook-addon"
],
homepage: "https://github.com/storybookjs/storybook/tree/next/code/addons/mcp",
bugs: {
url: "https://github.com/storybookjs/storybook/issues"
},
repository: {
type: "git",
url: "https://github.com/storybookjs/storybook.git",
directory: "code/addons/mcp"
},
funding: {
type: "opencollective",
url: "https://opencollective.com/storybook"
},
license: "MIT",
author: "Storybook Core Team",
type: "module",
exports: {
"./internal/preview-stories-app-script": "./dist/tools/preview-stories/preview-stories-app-script.js",
"./package.json": "./package.json",
"./preset": "./dist/preset.js"
},
files: [
"dist/**/*",
"README.md",
"*.js",
"*.d.ts",
"!src/**/*"
],
dependencies: {
"@tmcp/adapter-valibot": "^0.1.5",
"@tmcp/transport-http": "^0.8.5",
tmcp: "^1.19.4",
valibot: "^1.4.0"
},
devDependencies: {
"@storybook/addon-a11y": "workspace:*",
"@storybook/addon-vitest": "workspace:*",
storybook: "workspace:*",
typescript: "^6.0.3"
},
peerDependencies: {
"@storybook/addon-vitest": "workspace:^",
storybook: "workspace:^"
},
peerDependenciesMeta: {
"@storybook/addon-vitest": {
optional: !0
}
},
publishConfig: {
access: "public"
},
mcpName: "io.github.storybookjs/addon-mcp",
storybook: {
displayName: "Addon MCP",
supportedFrameworks: [
"react",
"vue3",
"angular",
"web-components",
"html",
"svelte",
"preact",
"react-native"
],
icon: "https://user-images.githubusercontent.com/321738/63501763-88dbf600-c4cc-11e9-96cd-94adadc2fd72.png"
}
};
// src/mcp-handler.ts
import { buffer } from "node:stream/consumers";
// src/telemetry.ts
import { logger } from "storybook/internal/node-logger";
import { telemetry } from "storybook/internal/telemetry";
async function collectTelemetry({
event,
server,
...payload
}) {
try {
return await telemetry("addon-mcp", {
event,
mcpSessionId: server.ctx.sessionId,
clientInfo: server.ctx.sessionInfo?.clientInfo,
clientCapabilities: server.ctx.sessionInfo?.clientCapabilities,
// Which channel drove the call: the `storybook ai` CLI marks its requests with the proxy
// header; everything else is a direct MCP client.
transport: server.ctx.custom?.cliClient ? "cli" : "mcp",
...payload
});
} catch (error) {
logger.debug(`Error collecting telemetry: ${String(error)}`);
}
}
// src/mcp-handler.ts
import { logger as logger4 } from "storybook/internal/node-logger";
import { getEffectiveToolAvailability, getToolAvailability } from "storybook/internal/core-server";
import { buildServerInstructions } from "storybook/internal/skills";
// src/constants.ts
var STORYBOOK_MCP_PROXY_HEADER = "X-Storybook-MCP-Proxy", DEFAULT_MCP_ENDPOINT = "/mcp";
// src/tools/tool-registry.ts
import { logger as logger3 } from "storybook/internal/node-logger";
import {
createCompositionDocsSources,
createDocsToolset
} from "storybook/internal/toolsets-docs";
// src/utils/format-validation-issues.ts
function formatPath(path2) {
if (!path2 || path2.length === 0) return "";
let out = "";
for (let segment of path2) {
let key = typeof segment == "object" && segment !== null && "key" in segment ? segment.key : segment;
typeof key == "number" ? out += `[${key}]` : key != null && (out += out === "" ? String(key) : `.${String(key)}`);
}
return out;
}
function summarizeIssue(issue) {
let path2 = formatPath(issue.path);
if (issue.kind === "schema" && (issue.type === "object" || issue.type === "strict_object" || issue.type === "loose_object") && typeof issue.expected == "string" && issue.expected.length >= 2 && issue.expected.startsWith('"') && issue.expected.endsWith('"') && issue.received === "undefined") {
let field = issue.expected.slice(1, -1), parentPathStr = formatPath((issue.path ?? []).slice(0, -1)), where = parentPathStr ? ` at \`${parentPathStr}\`` : "";
return { path: path2, message: `Missing required field \`${field}\`${where}.` };
}
return { path: path2, message: issue.message };
}
function withFriendlyErrors(schema) {
let original = schema["~standard"];
return {
...schema,
"~standard": {
...original,
validate(input) {
let result = original.validate(input), trim = (r) => (
// Cast: the spec issue type is the base shape; validators
// (notably valibot) augment with `kind`, `type`, `expected`,
// `received` — see {@link StandardSchemaIssue}.
r.issues ? { issues: r.issues.map(summarizeIssue) } : r
);
return result instanceof Promise ? result.then(trim) : trim(result);
}
}
};
}
// src/tools/get-storybook-story-instructions.ts
import { resolveSkillInputs } from "storybook/internal/core-server";
import { buildStoryInstructions } from "storybook/internal/skills";
// src/utils/errors.ts
var errorToMCPContent = (error) => ({
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: !0
});
// src/tools/tool-names.ts
import { toMcpToolName } from "storybook/open-service";
var PREVIEW_STORIES_TOOL_NAME = toMcpToolName("stories.preview"), GET_CHANGED_STORIES_TOOL_NAME = toMcpToolName("stories.changed"), GET_STORIES_BY_COMPONENT_TOOL_NAME = toMcpToolName("stories.findByComponent"), RUN_STORY_TESTS_TOOL_NAME = toMcpToolName("test.run"), DISPLAY_REVIEW_TOOL_NAME = toMcpToolName("review.create"), GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME = "get-storybook-story-instructions";
// src/tools/get-storybook-story-instructions.ts
async function addGetUIBuildingInstructionsTool(server, enabled = () => server.ctx.custom?.toolsets?.dev ?? !0, {
docsEnabled = !1,
addonVitestAvailable = !1
} = {}) {
server.tool(
{
name: GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME,
title: "Storybook Story Development Instructions",
get description() {
let testSupported = (server.ctx.custom?.toolsets?.test ?? !0) && addonVitestAvailable, a11yAvailable = testSupported && (server.ctx.custom?.a11yEnabled ?? !1);
return getStorybookStoryInstructionsDescription({
testSupported,
a11yAvailable
});
},
enabled
},
async () => {
try {
let { options, disableTelemetry: disableTelemetry2 } = server.ctx.custom ?? {};
if (!options)
throw new Error("Options are required in addon context");
return disableTelemetry2 || await collectTelemetry({
event: "tool:getUIBuildingInstructions",
server,
toolset: "dev"
}), {
content: [{ type: "text", text: await buildStorybookStoryInstructions(options, {
toolsets: server.ctx.custom?.toolsets,
a11yEnabled: server.ctx.custom?.a11yEnabled,
addonVitestAvailable,
docsEnabled,
reviewEnabled: server.ctx.custom?.reviewEnabled
}) }]
};
} catch (error) {
return errorToMCPContent(error);
}
}
);
}
function getStorybookStoryInstructionsDescription({
testSupported,
a11yAvailable
}) {
return `Get comprehensive instructions for writing, testing, and fixing Storybook stories (.stories.tsx, .stories.ts, .stories.jsx, .stories.js, .stories.svelte, .stories.vue files).
CRITICAL: You MUST call this tool before:
- Creating new Storybook stories or story files
- Updating or modifying existing Storybook stories
- Adding new story variants or exports to story files
- Editing any file matching *.stories.* patterns
- Writing components that will need stories
- Editing anything that changes how the UI looks \u2014 components, styles, CSS, themes, colors, or design tokens; a shared file with no stories of its own still changes its consumers' stories${testSupported ? `
- Running story tests or fixing test failures` : ""}${a11yAvailable ? `
- Handling accessibility (a11y) violations in stories (fix semantic issues directly; ask before visual/design changes)` : ""}
This tool provides essential Storybook-specific guidance including:
- How to structure stories correctly for Storybook 9
- Required imports (Meta, StoryObj from framework package)
- Test utility imports (from 'storybook/test')
- Story naming conventions and best practices
- Play function patterns for interactive testing
- Mocking strategies for external dependencies
- Story variants and coverage requirements${testSupported ? `
- How to handle test failures${a11yAvailable ? " and accessibility violations" : ""}` : ""}
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.`;
}
function getStorybookStoryInstructionsToolMetadata(options) {
return {
name: GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME,
title: "Storybook Story Development Instructions",
description: getStorybookStoryInstructionsDescription(options)
};
}
async function buildStorybookStoryInstructions(options, {
toolsets,
a11yEnabled: a11yEnabled2,
addonVitestAvailable,
docsEnabled,
reviewEnabled: reviewEnabledOverride
} = {}) {
let inputs = await resolveSkillInputs(options);
return buildStoryInstructions({
transport: "mcp",
framework: inputs.framework,
renderer: inputs.renderer,
changeDetectionEnabled: inputs.changeDetectionEnabled,
reviewEnabled: reviewEnabledOverride ?? inputs.reviewEnabled,
testSupported: (toolsets?.test ?? !0) && (addonVitestAvailable ?? inputs.testSupported),
a11yEnabled: a11yEnabled2 ?? inputs.a11yEnabled,
docsEnabled: (toolsets?.docs ?? !0) && (docsEnabled ?? inputs.docsEnabled)
});
}
// src/tools/preview-stories.ts
import url from "node:url";
import fs from "node:fs/promises";
// src/tools/preview-stories/preview-stories-app-template.html
var preview_stories_app_template_default = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html[data-theme='light'] {
color-scheme: light;
}
html[data-theme='dark'] {
color-scheme: dark;
}
html,
body {
width: 100%;
margin: 0;
padding: 0;
background-color: var(--color-background-secondary);
}
body {
display: flex;
flex-direction: column;
gap: 1rem;
}
:root {
/*
These are fallback values, if the MCP client doesn't set them.
See https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#theming
*/
--color-text-primary: light-dark(black, white);
--color-border-primary: light-dark(#ccc, #444);
--color-background-secondary: light-dark(#f9f9f9, #1e1e1e);
--font-sans:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Oxygen-Sans', Ubuntu, Cantarell,
'Helvetica Neue', sans-serif;
--font-heading-xs-size: 1rem;
--font-heading-xs-line-height: 1.25;
--border-width-regular: 1px;
}
.story-heading {
font-family: var(--font-sans);
font-size: var(--font-heading-xs-size);
line-height: var(--font-heading-xs-line-height);
color: var(--color-text-primary);
padding: 0.5rem 0;
}
.story-iframe {
background-color: white;
border: var(--border-width-regular) solid var(--color-border-primary);
}
body:has(> article:only-child) h1 {
/* if there is only one story rendered, hide its heading */
display: none;
}
</style>
<template id="preview-template">
<article>
<h1 class="story-heading"></h1>
<iframe class="story-iframe"></iframe>
</article>
</template>
<script type="module">
// APP_SCRIPT_PLACEHOLDER
</script>
</head>
<body></body>
</html>
`;
// src/tools/preview-stories.ts
var PREVIEW_STORIES_RESOURCE_URI = `ui://${PREVIEW_STORIES_TOOL_NAME}/preview.html`;
async function addPreviewStoriesResource(server) {
let previewStoryAppScript = await fs.readFile(
url.fileURLToPath(
import.meta.resolve("@storybook/addon-mcp/internal/preview-stories-app-script")
),
"utf-8"
), appHtml = preview_stories_app_template_default.replace("// APP_SCRIPT_PLACEHOLDER", previewStoryAppScript);
server.resource(
{
name: PREVIEW_STORIES_RESOURCE_URI,
description: "App resource for the Preview Stories tool",
uri: PREVIEW_STORIES_RESOURCE_URI,
mimeType: "text/html;profile=mcp-app"
},
() => {
let origin2 = server.ctx.custom.origin;
return {
contents: [
{
uri: PREVIEW_STORIES_RESOURCE_URI,
mimeType: "text/html;profile=mcp-app",
text: appHtml,
_meta: {
ui: {
prefersBorder: !1,
domain: origin2,
csp: {
connectDomains: [origin2],
resourceDomains: [origin2],
frameDomains: [origin2],
baseUriDomains: [origin2]
}
}
}
}
]
};
}
);
}
// src/tools/tool-registry.ts
import { OpenServiceMissingToolsetError, toMcpToolName as toMcpToolName3 } from "storybook/open-service";
// src/tools/toolset-tools.ts
import { getService } from "storybook/internal/core-server";
import { logger as logger2 } from "storybook/internal/node-logger";
import { OpenServiceToolsetOutputMismatchError } from "storybook/internal/server-errors";
import {
getToolset,
invokeToolsetMethod,
parseToolsetMethodId,
resolveToolsetDescription,
toMcpToolName as toMcpToolName2
} from "storybook/open-service";
// src/tools/toolset-origin.ts
function storybookRootFromRequest(request, trustedOrigin, endpoint) {
if (request?.url)
try {
let url2 = new URL(request.url), normalizedEndpoint = endpoint.replace(/\/$/, ""), normalizedPathname = url2.pathname.replace(/\/$/, ""), rootPath = normalizedPathname.endsWith(normalizedEndpoint) ? normalizedPathname.slice(0, -normalizedEndpoint.length) : normalizedPathname.replace(/\/[^/]+$/, "");
return `${trustedOrigin.replace(/\/$/, "")}${rootPath}`;
} catch {
return;
}
}
function resolveToolsetOrigin(context) {
if (context.origin)
return context.request ? storybookRootFromRequest(
context.request,
context.origin,
context.endpoint ?? DEFAULT_MCP_ENDPOINT
) ?? context.origin : context.origin;
}
// src/tools/toolset-tools.ts
function resolveToolset(options, server) {
return options.resolveToolset ? options.resolveToolset(server) : getToolset(parseToolsetMethodId(options.method).toolsetId);
}
function resolveMethod(toolset, options) {
return toolset.methods[parseToolsetMethodId(options.method).methodName];
}
function isAgentFacingError(error) {
return error instanceof Error && error.agentFacing === !0;
}
async function toStructuredContent(outputSchema, data) {
if (!outputSchema || data === void 0)
return;
let result = await outputSchema["~standard"].validate(data);
if (result.issues)
throw new OpenServiceToolsetOutputMismatchError({ issues: result.issues });
return result.value;
}
function buildContext(server) {
return {
transport: "mcp",
// The toolset origin is the complete UI base URL, including any deployment subpath.
origin: resolveToolsetOrigin(server.ctx.custom ?? {}),
getService: (serviceId, serviceOptions) => getService(serviceId, serviceOptions)
};
}
async function reportToolsetTelemetry(server, event, report) {
if (!report || server.ctx.custom?.disableTelemetry)
return;
let { payload, toolset, tool } = report;
await collectTelemetry({ event, server, ...payload, toolset, tool });
}
async function callToolsetMethod(server, options, input) {
let toolset = resolveToolset(options, server), { methodName } = parseToolsetMethodId(options.method), method = toolset.methods[methodName];
try {
let outcome = await invokeToolsetMethod(toolset, methodName, input, buildContext(server));
await reportToolsetTelemetry(server, options.mcpEventName, outcome.telemetry);
let structuredContent = await toStructuredContent(method.output, outcome.data);
return {
content: (Array.isArray(outcome.markdown) ? outcome.markdown : [outcome.markdown]).map((text) => ({ type: "text", text })),
...structuredContent !== void 0 ? { structuredContent } : {},
...outcome.ok ? {} : { isError: !0 }
};
} catch (error) {
return isAgentFacingError(error) ? { content: [{ type: "text", text: error.message }], isError: !0 } : (logger2.error(`MCP tool "${toMcpToolName2(options.method)}" failed: ${error}`), errorToMCPContent(error));
}
}
function getToolsetToolMetadata(options) {
let method = resolveMethod(resolveToolset(options), options), descriptionCtx = {
transport: "mcp",
getService: (serviceId, serviceOptions) => getService(serviceId, serviceOptions)
}, entries = method.input.entries, hasInput = !entries || Object.keys(entries).length > 0;
return {
name: toMcpToolName2(options.method),
title: method.title,
description: resolveToolsetDescription(method.description, descriptionCtx),
...hasInput ? { schema: options.wrapSchema ? options.wrapSchema(method.input) : method.input } : {},
...method.output ? { outputSchema: method.output } : {},
...options.extras
};
}
function registerToolsetTool(server, options, enabled) {
server.tool(
{ ...getToolsetToolMetadata(options), enabled },
((input) => callToolsetMethod(server, options, input))
);
}
// src/tools/tool-registry.ts
var isToolsetEnabled = (toolset, toolsets) => toolsets?.[toolset] ?? !0, isToolAvailable = (definition, context) => definition.available?.(context) ?? !0, isMetadataToolEnabled = (definition, context) => isToolsetEnabled(definition.toolset, context.toolsets) && isToolAvailable(definition, context), createToolsetEnabled = (server, toolset) => () => server.ctx.custom?.toolsets?.[toolset] ?? !0;
function fromToolset(definition) {
let { options, available, wrapEnabled, ...rest } = definition;
return {
...rest,
// Read from the constant, not the registry: this array is built at import time, while toolsets
// register later from their preset hooks. Each availability gate is written to match the
// condition under which its toolset registers; if they still disagree, resolution fails loudly
// (getToolset throws) and the registry drops that one tool with an error log rather than taking
// down the whole server (see resolveDefinitionOrDrop).
name: toMcpToolName3(options.method),
available: (context) => available?.(context) ?? !0,
getMetadata: () => getToolsetToolMetadata(options),
register: async (server, context, enabled) => {
registerToolsetTool(server, options, wrapEnabled?.(server, context, enabled) ?? enabled);
}
};
}
function compositionDocsToolset(server) {
let custom = server?.ctx.custom;
return createDocsToolset({
sources: createCompositionDocsSources({
sources: custom?.sources ?? [{ id: "local", title: "Local" }],
manifestProvider: custom?.manifestProvider,
getRequest: () => custom?.request,
localAccess: custom?.localAccess
})
});
}
var DOCS_EVENT_NAMES = {
"docs.list": "tool:listAllDocumentation",
"docs.show": "tool:getDocumentation",
"docs.showStory": "tool:getDocumentationForStory"
};
function docsToolDefinition(method) {
let forContext = (context) => ({
method,
mcpEventName: DOCS_EVENT_NAMES[method],
...context.multiSource ? { resolveToolset: (server) => compositionDocsToolset(server) } : {}
});
return {
name: toMcpToolName3(method),
toolset: "docs",
available: ({ availability }) => availability.docsEnabled,
getMetadata: (context) => getToolsetToolMetadata(forContext(context)),
register: async (server, context, enabled) => {
registerToolsetTool(server, forContext(context), enabled);
}
};
}
var docsToolDefinitions = [
docsToolDefinition("docs.list"),
docsToolDefinition("docs.show"),
docsToolDefinition("docs.showStory")
], addonToolDefinitions = [
fromToolset({
toolset: "dev",
options: {
method: "stories.preview",
mcpEventName: "tool:previewStories",
extras: { _meta: { ui: { resourceUri: PREVIEW_STORIES_RESOURCE_URI } } }
}
}),
{
name: GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME,
toolset: "dev",
getMetadata: ({ availability, toolsets }) => {
let testSupported = isToolsetEnabled("test", toolsets) && availability.testSupported;
return getStorybookStoryInstructionsToolMetadata({
testSupported,
a11yAvailable: testSupported && availability.a11yEnabled
});
},
register: (server, { availability, toolsets }, enabled) => addGetUIBuildingInstructionsTool(server, enabled, {
docsEnabled: isToolsetEnabled("docs", toolsets) && availability.docsEnabled,
addonVitestAvailable: availability.testSupported
}),
getLocalTool: ({ availability, toolsets, options }) => ({
call: async () => ({ content: [{ type: "text", text: await buildStorybookStoryInstructions(options, {
toolsets,
a11yEnabled: availability.a11yEnabled,
addonVitestAvailable: availability.testSupported,
docsEnabled: isToolsetEnabled("docs", toolsets) && availability.docsEnabled,
reviewEnabled: availability.reviewEnabled
}) }] })
})
},
fromToolset({
toolset: "dev",
available: ({ availability }) => availability.changeDetectionEnabled,
options: { method: "stories.changed", mcpEventName: "tool:getChangedStories" }
}),
fromToolset({
toolset: "dev",
available: ({ availability }) => availability.moduleGraphSupported,
options: { method: "stories.findByComponent", mcpEventName: "tool:getStoriesByComponent" }
}),
fromToolset({
toolset: "dev",
// Registered whenever the CLI default could turn review on; the per-request `reviewEnabled`
// context (explicit flag, or the trusted local-client header) decides whether a given MCP
// client actually sees the tool.
available: ({ availability }) => availability.reviewEnabledForCli,
wrapEnabled: (server, { availability }, enabled) => async () => (await enabled?.() ?? !0) && (server.ctx.custom?.reviewEnabled ?? availability.reviewEnabled),
options: {
method: "review.create",
mcpEventName: "tool:displayReview",
wrapSchema: withFriendlyErrors
}
}),
fromToolset({
toolset: "test",
available: ({ availability }) => availability.testSupported,
options: { method: "test.run", mcpEventName: "tool:runStoryTests" }
}),
// Docs run on the core docs toolset in both modes. A composition builds its toolset per request,
// because the sources it reads and the provider that fetches them belong to the request.
...docsToolDefinitions
];
function dropToolIfToolsetMissing(name, error) {
if (!(error instanceof OpenServiceMissingToolsetError))
throw error;
logger3.error(`Skipping MCP tool "${name}", its backing toolset is not registered: ${error}`);
}
function resolveDefinitionOrDrop(name, resolve) {
try {
return resolve();
} catch (error) {
return dropToolIfToolsetMissing(name, error);
}
}
function getAddonToolMetadata(context) {
return addonToolDefinitions.filter((definition) => isMetadataToolEnabled(definition, context)).flatMap((definition) => {
let metadata = resolveDefinitionOrDrop(
definition.name,
() => definition.getMetadata(context)
);
return metadata ? [metadata] : [];
});
}
function getAddonLocalTools(context) {
return Object.fromEntries(
addonToolDefinitions.filter((definition) => isMetadataToolEnabled(definition, context)).flatMap((definition) => {
let localTool = resolveDefinitionOrDrop(
definition.name,
() => definition.getLocalTool?.(context)
);
return localTool ? [[definition.name, localTool]] : [];
})
);
}
async function registerAddonMcpTools(server, context) {
isToolsetEnabled("dev", context.toolsets) && await addPreviewStoriesResource(server);
for (let definition of addonToolDefinitions)
if (isToolsetEnabled(definition.toolset, context.toolsets) && isToolAvailable(definition, context))
try {
await definition.register(
server,
context,
createToolsetEnabled(server, definition.toolset)
);
} catch (error) {
dropToolIfToolsetMissing(definition.name, error);
}
}
// src/mcp-handler.ts
var transport, origin, initialize, disableTelemetry, a11yEnabled, reviewGates, initializeMCPServer = async (options, multiSource) => {
let core = await options.presets.apply("core", {}), features2 = await options.presets.apply("features", {});
disableTelemetry = core?.disableTelemetry ?? !1;
let rawAvailability = await getToolAvailability(options, { features: features2 }), availability = getEffectiveToolAvailability(rawAvailability, { multiSource });
a11yEnabled = availability.a11yEnabled, reviewGates = {
reviewEnabled: availability.reviewEnabled,
reviewEnabledForCli: availability.reviewEnabledForCli
};
let server, serverOptions = {
adapter: new ValibotJsonSchemaAdapter(),
get instructions() {
return buildServerInstructions({
transport: "mcp",
devEnabled: server?.ctx.custom?.toolsets?.dev ?? !0,
testSupported: (server?.ctx.custom?.toolsets?.test ?? !0) && availability.testSupported,
docsEnabled: (server?.ctx.custom?.toolsets?.docs ?? !0) && availability.docsEnabled,
changeDetectionEnabled: availability.changeDetectionEnabled,
moduleGraphSupported: availability.moduleGraphSupported,
reviewEnabled: server?.ctx.custom?.reviewEnabled ?? availability.reviewEnabled
});
},
capabilities: {
tools: { listChanged: !0 },
resources: { listChanged: !0 }
}
};
return server = new McpServer(
{
name: package_default.name,
version: package_default.version,
description: package_default.description
},
serverOptions
).withContext(), disableTelemetry || server.on("initialize", async () => {
await collectTelemetry({ event: "session:initialized", server });
}), await registerAddonMcpTools(server, { availability, multiSource }), transport = new HttpTransport(server, { path: null }), origin = `http://localhost:${options.port}`, logger4.debug(`MCP server origin: ${origin}`), server;
}, mcpServerHandler = async ({
req,
res,
options,
addonOptions,
endpoint = DEFAULT_MCP_ENDPOINT,
sources,
manifestProvider,
localAccess,
compositionAuth
}) => {
initialize || (initialize = initializeMCPServer(
options,
sources?.some((s) => s.url)
)), await initialize;
let webRequest = await incomingMessageToWebRequest(req), addonContext = {
options,
endpoint,
toolsets: getToolsets(webRequest, addonOptions),
reviewEnabled: isReviewEnabledForRequest(webRequest, reviewGates),
cliClient: webRequest.headers.get(STORYBOOK_MCP_PROXY_HEADER) === "true",
origin,
disableTelemetry,
a11yEnabled,
request: webRequest,
sources,
manifestProvider,
localAccess
}, response = await transport.respond(webRequest, addonContext);
if (response) {
let body = await response.arrayBuffer(), finalResponse = compositionAuth.hadAuthError(webRequest) ? new Response("401 - Unauthorized", {
status: 401,
headers: {
"Content-Type": "text/plain",
"WWW-Authenticate": compositionAuth.buildWwwAuthenticate(origin)
}
}) : new Response(body, { status: response.status, headers: response.headers });
await webResponseToServerResponse(finalResponse, res);
}
};
async function incomingMessageToWebRequest(req) {
let host = req.headers.host || "localhost", protocol = "encrypted" in req.socket && req.socket.encrypted ? "https" : "http", url2 = new URL(req.url || "/", `${protocol}://${host}`), bodyBuffer = await buffer(req);
return new Request(url2, {
method: req.method,
headers: req.headers,
body: bodyBuffer.length > 0 ? new Uint8Array(bodyBuffer) : void 0
});
}
async function webResponseToServerResponse(webResponse, nodeResponse) {
if (nodeResponse.statusCode = webResponse.status, webResponse.headers.forEach((value, key) => {
nodeResponse.setHeader(key, value);
}), webResponse.body) {
let reader = webResponse.body.getReader();
try {
for (; ; ) {
let { done, value } = await reader.read();
if (done) break;
nodeResponse.write(value);
}
} finally {
reader.releaseLock();
}
}
nodeResponse.end();
}
function isReviewEnabledForRequest(request, gates) {
return gates.reviewEnabled || gates.reviewEnabledForCli && request.headers.get(STORYBOOK_MCP_PROXY_HEADER) === "true";
}
function getToolsets(request, addonOptions) {
let toolsetHeader = request.headers.get("X-MCP-Toolsets");
if (!toolsetHeader || toolsetHeader.trim() === "")
return addonOptions.toolsets;
let toolsets = {
dev: !1,
docs: !1,
test: !1
}, enabledToolsets = toolsetHeader.split(",");
for (let enabledToolset of enabledToolsets) {
let trimmedToolset = enabledToolset.trim();
trimmedToolset in toolsets && (toolsets[trimmedToolset] = !0);
}
return toolsets;
}
// src/types.ts
import * as v from "valibot";
var isLiteralEndpointPathname = (endpoint) => {
try {
let { pathname } = new URL(endpoint, "http://storybook.local");
return pathname === endpoint && pathname !== "/";
} catch {
return !1;
}
}, AddonOptions = v.object({
endpoint: v.optional(
v.pipe(
v.string(),
v.check(isLiteralEndpointPathname, "Endpoint must be a literal URL pathname")
)
),
toolsets: v.optional(
v.object({
dev: v.exactOptional(v.boolean(), !0),
docs: v.exactOptional(v.boolean(), !0),
test: v.exactOptional(v.boolean(), !0)
}),
{
// Default values for toolsets
dev: !0,
docs: !0,
test: !0
}
)
});
// src/preset.ts
import * as v5 from "valibot";
import {
createLocalDocsAccess,
getEffectiveToolAvailability as getEffectiveToolAvailability3,
getToolAvailability as getToolAvailability3,
isValidHost,
loadManifests
} from "storybook/internal/core-server";
import { isWebContainer } from "storybook/internal/common";
// src/template.html
var template_default = `<!doctype html>
<html>
<head>
<style>
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./sb-common-assets/nunito-sans-regular.woff2') format('woff2');
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
font-family:
'Nunito Sans',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
sans-serif;
}
body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 2rem;
background-color: #ffffff;
color: rgb(46, 52, 56);
line-height: 1.6;
}
p {
margin-bottom: 1rem;
}
code {
font-family: 'Monaco', 'Courier New', monospace;
background: #f5f5f5;
padding: 0.2em 0.4em;
border-radius: 3px;
}
a {
color: #1ea7fd;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
}
.toolsets {
margin: 1.5rem 0;
text-align: left;
max-width: 500px;
}
.toolsets h3 {
font-size: 1rem;
margin-bottom: 0.75rem;
text-align: center;
}
.toolset {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border-radius: 6px;
background: #f8f9fa;
border: 1px solid #e9ecef;
}
.toolset-header {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.toolset-status {
display: inline-block;
padding: 0.15em 0.5em;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.toolset-status.enabled {
background: #d4edda;
color: #155724;
}
.toolset-status.disabled {
background: #f8d7da;
color: #721c24;
}
.toolset-tools {
font-size: 0.875rem;
color: #6c757d;
padding-left: 1.5rem;
margin: 0;
}
.toolset-tools li {
margin-bottom: 0.25rem;
}
.toolset-tools code {
font-size: 0.8rem;
}
.toolset-notice {
font-size: 0.8rem;
color: #856404;
background: #fff3cd;
padding: 0.5rem;
border-radius: 4px;
margin-top: 0.5rem;
}
.toolset-notice a {
color: #533f03;
}
@media (prefers-color-scheme: dark) {
body {
background-color: rgb(34, 36, 37);
color: rgb(201, 205, 207);
}
code {
background: rgba(255, 255, 255, 0.1);
}
.toolset {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.toolset-tools {
color: #adb5bd;
}
.toolset-status.enabled {
background: rgba(40, 167, 69, 0.2);
color: #75d67e;
}
.toolset-status.disabled {
background: rgba(220, 53, 69, 0.2);
color: #f5a6ad;
}
.toolset-notice {
background: rgba(255, 193, 7, 0.15);
color: #ffc107;
}
.toolset-notice a {
color: #ffe066;
}
}
</style>
</head>
<body>
<div class="container">
<p>
Storybook MCP server successfully running via
<code>@storybook/addon-mcp</code>.
</p>
<p>
See how to connect to it from your coding agent in
<a target="_blank" href="https://github.com/storybookjs/storybook/tree/next/code/addons/mcp"
>the addon's README</a
>.
</p>
<div class="toolsets">
<h3>Available Toolsets</h3>
<div class="toolset">
<div class="toolset-header">
<span>dev</span>
<span class="toolset-status {{DEV_STATUS}}">{{DEV_STATUS}}</span>
</div>
<ul class="toolset-tools">
<li><code>stories-preview</code></li>
<li><code>get-storybook-story-instructions</code></li>
<li>
<code>stories-changed</code>
<span class="toolset-status {{CHANGE_DETECTION_STATUS}}"
>{{CHANGE_DETECTION_STATUS}}</span
>
</li>
<li>
<code>stories-find-by-component</code>
<span class="toolset-status {{STORIES_BY_COMPONENT_STATUS}}"
>{{STORIES_BY_COMPONENT_STATUS}}</span
>
</li>
<li>
<code>review-create</code>
<span class="toolset-status {{REVIEW_STATUS}}">{{REVIEW_STATUS}}</span>
</li>
</ul>
{{DEV_NOTICE}}
</div>
<div class="toolset">
<div class="toolset-header">
<span>docs</span>
<span class="toolset-status {{DOCS_STATUS}}">{{DOCS_STATUS}}</span>
</div>
<ul class="toolset-tools">
<li><code>docs-list</code></li>
<li><code>docs-show</code></li>
<li><code>docs-show-story</code></li>
</ul>
{{DOCS_NOTICE}}
</div>
<div class="toolset">
<div class="toolset-header">
<span>test</span>
<span class="toolset-status {{TEST_STATUS}}">{{TEST_STATUS}}</span>
</div>
<ul class="toolset-tools">
<li><code>test-run</code>{{A11Y_BADGE}}</li>
</ul>
{{TEST_NOTICE}}
</div>
</div>
{{MANIFEST_DEBUGGER_LINK}}
</div>
</body>
</html>
`;
// src/preset.ts
import path from "node:path";
// src/auth/composition-auth.ts
import {
ComponentManifestMap,
DocsManifestMap
} from "storybook/internal/toolsets-docs";
import { logger as logger5 } from "storybook/internal/node-logger";
import * as v2 from "valibot";
var OAuthResourceMetadata = v2.object({
resource: v2.optional(v2.string()),
authorization_servers: v2.pipe(v2.array(v2.string()), v2.minLength(1)),
scopes_supported: v2.optional(v2.array(v2.string()))
}), OAuthServerMetadata = v2.object({
issuer: v2.string(),
authorization_endpoint: v2.string(),
token_endpoint: v2.string(),
scopes_supported: v2.optional(v2.array(v2.string()))
}), MANIFEST_CACHE_TTL = 3600 * 1e3, REVALIDATION_TTL = 60 * 1e3, AuthenticationError = class extends Error {
constructor(url2) {
super(`Authentication failed for ${url2}. Your token may be invalid or expired.`), this.name = "AuthenticationError";
}
}, CompositionAuth = class {
#authRequirement = null;
#authRequiredUrls = [];
#refsWithManifests = [];
#manifestCache = /* @__PURE__ */ new Map();
#lastToken = null;
#authErrors = /* @__PURE__ */ new WeakMap();
/** Initialize by checking which refs require authentication and have manifests. */
async initialize(refs) {
for (let ref of refs)
try {
let result = await this.#checkRef(ref.url);
if (result === "manifest-not-found") {
logger5.warn(
`[addon-mcp] Skipping composed ref "${ref.title}" (${ref.url}): components manifest not found at ${ref.url}/manifests/components.json. Enable the \`componentsManifest\` feature on that Storybook to compose it into MCP.`
);
continue;
}
if (result === "manifest-invalid") {
logger5.warn(
`[addon-mcp] Skipping composed ref "${ref.title}" (${ref.url}): its components manifest failed validation. This usually means a version mismatch between the composed Storybook and this one \u2014 upgrade the older side.`
);
continue;
}
if (this.#refsWithManifests.push(ref), result === "public") continue;
if (this.#authRequiredUrls.push(ref.url), !this.#authRequirement)
this.#authRequirement = result;
else {
let existingServer = this.#authRequirement.resourceMetadata.authorization_servers[0], newServer = result.resourceMetadata.authorization_servers[0];
existingServer !== newServer && logger5.warn(
`[addon-mcp] Composed ref "${ref.title}" uses a different OAuth server (${newServer}) than the first authenticated ref (${existingServer}). Only the first OAuth server will be used for authentication.`
);
}
} catch (error) {
logger5.warn(
`[addon-mcp] Skipping composed ref "${ref.title}" (${ref.url}): the ref could not be reached (${error instanceof Error ? error.message : String(error)}). Refs are only checked at dev-server boot \u2014 restart Storybook once the ref is reachable.`
);
}
}
get requiresAuth() {
return this.#authRequiredUrls.length > 0;
}
get authUrls() {
return this.#authRequiredUrls;
}
/** Check if a request encountered an auth error during manifest fetching. */
hadAuthError(request) {
return this.#authErrors.has(request);
}
/** Check if a URL requires authentication based on discovered auth requirements. */
#isAuthRequiredUrl(url2) {
return this.#authRequiredUrls.some((authUrl) => url2.startsWith(authUrl));
}
/** Build .well-known/oauth-protected-resource response. */
buildWellKnown(origin2) {
return this.#authRequirement ? {
resource: `${origin2}/mcp`,
authorization_servers: this.#authRequirement.resourceMetadata.authorization_servers,
scopes_supported: this.#authRequirement.resourceMetadata.scopes_supported
} : null;
}
/** Build WWW-Authenticate header for 401 responses */
buildWwwAuthenticate(origin2) {
return `Bearer error="unauthorized", error_description="Authorization needed for composed Storybooks", resource_metadata="${origin2}/.well-known/oauth-protected-resource"`;
}
/** Build sources configuration: local first, then refs that have manifests. */
buildSources() {
return [
{ id: "local", title: "Local" },
...this.#refsWithManifests.map((ref) => ({
id: ref.id,
title: ref.title,
url: ref.url
}))
];
}
/**
* Create a manifest provider for multi-source mode.
*
* Remote sources are fetched over HTTP with auth and caching; the local source fetches from
* `localOrigin`. In `experimentalDocgenServer` mode core 404s the local `/manifests/*.json`, and
* the composition reads that source through its own access instead of this provider.
*/
createManifestProvider(localOrigin) {
return async (request, path2, source) => {
let token = extractBearerToken(request?.headers.get("Authorization")), remoteSource = source?.url ? { ...source, url: source.url } : void 0, baseUrl = remoteSource?.url ?? localOrigin, manifestUrl = `${baseUrl}${path2.replace(/^\.\//, "/")}`, isRemote = !!remoteSource, tokenForRequest = isRemote && this.#isAuthRequiredUrl(baseUrl) ? token : null;
if (token && token !== this.#lastToken && (this.#manifestCache.clear(), this.#lastToken = token), isRemote) {
let cached = this.#manifestCache.get(manifestUrl);
if (cached)
if (Date.now() - cached.timestamp > MANIFEST_CACHE_TTL)
this.#manifestCache.delete(manifestUrl);
else
return (!cached.lastRevalidatedAt || Date.now() - cached.lastRevalidatedAt > REVALIDATION_TTL) && (cached.lastRevalidatedAt = Date.now(), this.#fetchManifest(manifestUrl, tokenForRequest).then(
(text) => this.#manifestCache.set(manifestUrl, {
text,
timestamp: Date.now(),
lastRevalidatedAt: Date.now()
})
).catch(() => {
})), cached.text;
}
try {
let text = await this.#fetchManifest(manifestUrl, tokenForRequest);
return isRemote && this.#manifestCache.set(manifestUrl, { text, timestamp: Date.now() }), text;
} catch (error) {
throw error instanceof AuthenticationError && request && this.#authErrors.set(request, error), error;
}
};
}
/**
* Fetch a manifest with optional auth token.
* If the response is 200 but not a valid manifest, checks /mcp for auth issues.
*/
async #fetchManifest(url2, token) {
let headers = { Accept: "application/json" };
token && (headers.Authorization = `Bearer ${token}`);
let response = await fetch(url2, { headers });
if (response.status === 401)
throw new AuthenticationError(url2);
if (!response.ok)
throw new Error(`Failed to fetch ${url2}: ${response.status}`);
let text = await response.text(), { pathname } = new URL(url2), isComponentsManifest = pathname.endsWith("/manifests/components.json"), isDocsManifest = pathname.endsWith("/manifests/docs.json"), schema = isComponentsManifest ? ComponentManifestMap : isDocsManifest ? DocsManifestMap : v2.unknown();
if (v2.safeParse(v2.pipe(v2.string(), v2.parseJson(), schema), text).success)
return text;
throw await this.#isMcpUnauthorized(new URL(url2).origin) ? new AuthenticationError(url2) : new Error(
`Invalid manifest response from ${url2}: expected valid JSON manifest but got unexpected content.`
);
}
/**
* Check a ref to determine if it has a manifest and whether it requires auth.
* Returns 'public' if the ref has a valid manifest without auth,
* an AuthRequirement if auth is needed, or the reason the ref has no usable manifest:
* 'manifest-not-found' for a non-OK or non-JSON response (SPA fallback pages land here),
* 'manifest-invalid' for real JSON that fails the manifest schema.
*/
async #checkRef(refUrl) {
let response = await fetch(`${refUrl}/manifests/components.json`, {
headers: { Accept: "application/json" }
}), authReq = await this.#parseAuthFromResponse(response);
if (authReq) return authReq;
let noManifestReason = "manifest-not-found";
if (response.ok) {
let text = await response.text(), json = v2.safeParse(v2.pipe(v2.string(), v2.parseJson()), text);
if (json.success) {
if (v2.safeParse(ComponentManifestMap, json.output).success)
return "public";
noManifestReason = "manifest-invalid";
}
}
let mcpAuth = await this.#checkMcpAuth(refUrl);
return mcpAuth || noManifestReason;
}
/** Check /mcp endpoint for 401 auth requirement. */
async #checkMcpAuth(refUrl) {
let response = await fetch(`${refUrl}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" })
});
return this.#parseAuthFromResponse(response);
}
/** Quick check: does the remote /mcp return 401? */
async #isMcpUnauthorized(origin2) {
try {
return (await fetch(`${origin2}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" })
})).status === 401;
} catch {
return !1;
}
}
/** Extract auth requirement from a 401 response's WWW-Authenticate header. */
async #parseAuthFromResponse(response) {
if (response.status !== 401) return null;
let wwwAuth = response.headers.get("WWW-Authenticate");
if (!wwwAuth) return null;
let match = wwwAuth.match(/resource_metadata="([^"]+)"/);
if (!match?.[1]) return null;
let resourceMetadataUrl = match[1], resourceResponse = await fetch(resourceMetadataUrl);
if (!resourceResponse.ok)
return logger5.warn(
`[addon-mcp] Failed to fetch OAuth resource metadata from ${resourceMetadataUrl}: ${resourceResponse.status}`
), null;
let resourceResult = v2.safeParse(OAuthResourceMetadata, await resourceResponse.json());
if (!resourceResult.success)
return logger5.warn(
`[addon-mcp] Invalid OAuth resource metadata from ${resourceMetadataUrl}: ${resourceResult.issues.map((i) => i.message).join(", ")}`
), null;
let serverMetadataUrl = `${resourceResult.output.authorization_servers[0]}/.well-known/oauth-authorization-server`, serverResponse = await fetch(serverMetadataUrl);
if (!serverResponse.ok)
return logger5.warn(
`[addon-mcp] Failed to fetch OAuth server metadata from ${serverMetadataUrl}: ${serverResponse.status}`
), null;
let serverResult = v2.safeParse(OAuthServerMetadata, await serverResponse.json());
return serverResult.success ? {
resourceMetadataUrl,
resourceMetadata: resourceResult.output,
serverMetadata: serverResult.output
} : (logger5.warn(
`[addon-mcp] Invalid OAuth server metadata from ${serverMetadataUrl}: ${serverResult.issues.map((i) => i.message).join(", ")}`
), null);
}
};
function extractBearerToken(authHeader) {
let bearer = (Array.isArray(authHeader) ? authHeader : [authHeader]).find((value) => typeof value == "string" && value.startsWith("Bearer "));
return bearer ? bearer.slice(7) : null;
}
// src/auth/resolve-composition-sources.ts
import { ComponentManifestMap as ComponentManifestMap2 } from "storybook/internal/toolsets-docs";
import * as v3 from "valibot";
// src/auth/get-refs-from-config.ts
async function getRefsFromConfig(options) {
try {
let refs = await options.presets.apply("refs", {});
return !refs || typeof refs != "object" ? [] : Object.entries(refs).flatMap(([key, value]) => {
if (!value || typeof value != "object")
return [];
let { title, url: url2, disable } = value;
return disable || typeof url2 != "string" || url2.length === 0 ? [] : [
{
id: key,
title: typeof title == "string" && title.length > 0 ? title : key,
url: url2.replace(/\/$/, "")
}
];
});
} catch {
return [];
}
}
// src/auth/resolve-composition-sources.ts
var MANIFEST_PROBE_TIMEOUT_MS = 3e3;
async function resolveCompositionSources(options) {
let refs = await getRefsFromConfig(options), compositionAuth = new CompositionAuth();
if (refs.length === 0)
return { refs, compositionAuth, sources: void 0, multiSource: !1 };
await compositionAuth.initialize(refs);
let sources = compositionAuth.buildSources();
return {
refs,
compositionAuth,
sources,
multiSource: sources.some((source) => !!source.url)
};
}
async function resolveServerlessCompositionSources(options) {
let refs = await getRefsFromConfig(options), refsWithManifests = await Promise.all(
refs.map(
async (ref) => await hasServerlessComponentManifestSource(ref.url) ? ref : void 0
)
), sources = [
{ id: "local", title: "Local" },
...refsWithManifests.filter((ref) => !!ref).map((ref) => ({
id: ref.id,
title: ref.title,
url: ref.url
}))
];
return {
refs,
sources,
multiSource: sources.some((source) => !!source.url)
};
}
async function hasServerlessComponentManifestSource(refUrl) {
let controller = new AbortController(), timeout = setTimeout(() => controller.abort(), MANIFEST_PROBE_TIMEOUT_MS);
try {
let response = await fetch(
new URL("manifests/components.json", `${refUrl.replace(/\/$/, "")}/`),
{
headers: { Accept: "application/json" },
signal: controller.signal
}
);
if (response.status === 401 && hasOAuthResourceMetadataChallenge(response))
return !0;
if (!response.ok)
return !1;
let text = await response.text();
return v3.safeParse(v3.pipe(v3.string(), v3.parseJson(), ComponentManifestMap2), text).success;
} catch {
return !1;
} finally {
clearTimeout(timeout);
}
}
function hasOAuthResourceMetadataChallenge(response) {
return /resource_metadata="[^"]+"/.test(response.headers.get("WWW-Authenticate") ?? "");
}
// src/preset.ts
import { logger as logger7 } from "storybook/internal/node-logger";
// src/storybook-ai-metadata.ts
import { ValibotJsonSchemaAdapter as ValibotJsonSchemaAdapter2 } from "@tmcp/adapter-valibot";
import * as v4 from "valibot";
import {
getEffectiveToolAvailability as getEffectiveToolAvailability2,
getToolAvailability as getToolAvailability2,
isModuleGraphSupportedByBuilder
} from "storybook/internal/core-server";
import { buildServerInstructions as buildServerInstructions2 } from "storybook/internal/skills";
var createEmptyInputSchema = () => ({ type: "object", properties: {} });
async function buildStorybookAiMetadata(options, existingMetadata) {
let toolsets = v4.parse(AddonOptions, {
endpoint: options.endpoint,
toolsets: options.toolsets ?? {}
}).toolsets, features2 = await options.presets.apply("features", {}), devEnabled = toolsets?.dev ?? !0, moduleGraphSupported = await isModuleGraphSupportedByBuilder(options), rawAvailability = await getToolAvailability2(options, {
features: features2,
moduleGraphSupported
}), availability = {
...rawAvailability,
reviewEnabled: rawAvailability.reviewEnabledForCli
}, testSupported = (toolsets?.test ?? !0) && availability.testSupported, docsToolsetEnabled = toolsets?.docs ?? !0, multiSource = docsToolsetEnabled ? (await resolveServerlessCompositionSources(options)).multiSource : !1, registryAvailability = getEffectiveToolAvailability2(availability, { multiSource }), docsEnabled = docsToolsetEnabled && registryAvailability.docsEnabled, registryContext = { availability: registryAvailability, multiSource, toolsets }, toolMetadata = getAddonToolMetadata(registryContext), localTools = {
...existingMetadata?.localTools,
...getAddonLocalTools({ ...registryContext, options })
};
return {
instructions: joinInstructions(
existingMetadata?.instructions,
buildServerInstructions2({
transport: "mcp",
devEnabled,
testSupported,
docsEnabled,
changeDetectionEnabled: availability.changeDetectionEnabled,
moduleGraphSupported: availability.moduleGraphSupported,
reviewEnabled: availability.reviewEnabled
})
),
tools: mergeToolDescriptors(
existingMetadata?.tools ?? [],
await toToolDescriptors(toolMetadata)
),
localTools
};
}
async function toToolDescriptors(toolMetadata) {
let adapter = new ValibotJsonSchemaAdapter2();
return Promise.all(
toolMetadata.map(async ({ schema, outputSchema, ...metadata }) => ({
...metadata,
inputSchema: schema ? await toJsonSchema(adapter, schema) : createEmptyInputSchema(),
...outputSchema ? { outputSchema: await toJsonSchema(adapter, outputSchema) } : {}
}))
);
}
async function toJsonSchema(adapter, schema) {
return await adapter.toJsonSchema(
schema
);
}
function joinInstructions(...sections) {
let uniqueSections = [], seen = /* @__PURE__ */ new Set();
for (let section of sections) {
let trimmed = section?.trim();
trimmed && !seen.has(trimmed) && (seen.add(trimmed), uniqueSections.push(trimmed));
}
return uniqueSections.join(`
`);
}
function mergeToolDescriptors(...groups) {
let toolsByName = /* @__PURE__ */ new Map();
for (let tool of groups.flat())
toolsByName.has(tool.name) && toolsByName.delete(tool.name), toolsByName.set(tool.name, tool);
return [...toolsByName.values()];
}
// src/utils/get-story-index.ts
import { logger as logger6 } from "storybook/internal/node-logger";
async function getStoryIndex(options) {
let generator = await options.presets.apply(
"storyIndexGenerator"
);
if (!generator)
throw new Error(
"Storybook story index generator is unavailable. These MCP tools require a running Storybook dev server with a builder that exposes the story index."
);
let index = await generator.getIndex();
return logger6.debug(`Story index entries found: ${Object.keys(index.entries).length}`), index;
}
// src/preset.ts
var previewAnnotations = async (existingAnnotations = []) => [...existingAnnotations, path.join(import.meta.dirname, "preview.js")], experimental_devServer = async (app, options) => {
let addonOptions = v5.parse(AddonOptions, {
endpoint: options.endpoint,
toolsets: options.toolsets ?? {}
}), origin2 = `http://localhost:${options.port}`, endpoint = addonOptions.endpoint ?? DEFAULT_MCP_ENDPOINT, core = await options.presets.apply("core", {}), hostValidation = {
host: options.host,
allowedHosts: core?.allowedHosts,
localAddress: options.localAddress,
networkAddress: options.networkAddress
}, skipOriginValidation = isWebContainer(), rejectDisallowedOrigin = (req, res) => {
if (skipOriginValidation)
return !1;
let { origin: requestOrigin } = req.headers;
if (!requestOrigin)
return !1;
try {
if (isValidHost(new URL(requestOrigin).host, hostValidation))
return !1;
} catch {
}
return res.writeHead(403, { "Content-Type": "text/plain" }), res.end("Invalid origin"), !0;
}, rejectNonJsonBody = (req, res) => req.headers["content-type"]?.split(";")[0].trim().toLowerCase() === "application/json" ? !1 : (res.writeHead(415, { "Content-Type": "text/plain" }), res.end("Unsupported Media Type: Content-Type must be application/json"), !0), { refs, compositionAuth, sources, multiSource } = await resolveCompositionSources(options), rawAvailability = await getToolAvailability3(options), localAccess = rawAvailability.docgenServer && refs.length > 0 ? createLocalDocsAccess({
storyIndex: { getIndex: () => getStoryIndex(options) },
getManifests: () => loadManifests(options.presets)
}) : void 0, createManifestProvider;
refs.length > 0 && (logger7.info(`Initialized composition with ${refs.length} remote Storybook(s)`), compositionAuth.requiresAuth && logger7.info(`Auth required for: ${compositionAuth.authUrls.join(", ")}`), logger7.info(`Sources: ${(sources ?? []).map((s) => s.id).join(", ")}`), createManifestProvider = () => compositionAuth.createManifestProvider(origin2));
let manifestAccessFor = (req) => ({
manifestProvider: createManifestProvider?.(req),
localAccess
});
app.get("/.well-known/oauth-protected-resource", (_req, res) => {
let wellKnown = compositionAuth.buildWellKnown(origin2);
if (!wellKnown) {
res.writeHead(404), res.end("Not found");
return;
}
res.writeHead(200, { "Content-Type": "application/json" }), res.end(JSON.stringify(wellKnown));
});
let requireAuth = (req, res) => {
let token = extractBearerToken(req.headers.authorization);
return compositionAuth.requiresAuth && !token ? (res.writeHead(401, {
"Content-Type": "text/plain",
"WWW-Authenticate": compositionAuth.buildWwwAuthenticate(origin2)
}), res.end("401 - Unauthorized"), !0) : !1;
};
app.post(endpoint, (req, res) => {
if (!rejectDisallowedOrigin(req, res) && !requireAuth(req, res) && !rejectNonJsonBody(req, res))
return mcpServerHandler({
req,
res,
options,
addonOptions,
endpoint,
sources,
...manifestAccessFor(req),
compositionAuth
});
});
let {
moduleGraphSupported,
changeDetectionEnabled,
reviewEnabled,
reviewEnabledForCli,
docsEnabled,
docsHasManifests,
docsFeatureEnabled,
testSupported,
a11yEnabled: a11yEnabled2
} = getEffectiveToolAvailability3(rawAvailability, { multiSource }), isDevEnabled = addonOptions.toolsets?.dev ?? !0, isDocsEnabled = docsEnabled && (addonOptions.toolsets?.docs ?? !0), isTestEnabled = testSupported && (addonOptions.toolsets?.test ?? !0);
return app.get(endpoint, (req, res) => {
if (rejectDisallowedOrigin(req, res)) return;
if (!req.headers.accept?.includes("text/html"))
return requireAuth(req, res) ? void 0 : mcpServerHandler({
req,
res,
options,
addonOptions,
endpoint,
sources,
...manifestAccessFor(req),
compositionAuth
});
res.writeHead(200, { "Content-Type": "text/html" });
let docsNotice = "";
docsHasManifests ? docsFeatureEnabled || (docsNotice = `<div class="toolset-notice">
This toolset requires enabling the component manifest feature.
<a target="_blank" href="https://github.com/storybookjs/storybook/tree/next/code/addons/mcp">Learn how to enable it</a>
</div>`) : docsNotice = docsFeatureEnabled ? `<div class="toolset-notice">
This toolset requires a components manifest, which your framework does not generate. On <code>@storybook/vue3-vite</code>, also enable <code>experimentalDocgenServer</code>.
<a target="_blank" href="https://storybook.js.org/docs/ai/mcp/overview#framework-support">See which frameworks are supported</a>
</div>` : `<div class="toolset-notice">
This toolset requires a components manifest, which is generated by the React frameworks, <code>@storybook/angular-vite</code> and <code>@storybook/vue3-vite</code>. Enable the <code>componentsManifest</code> feature, plus <code>experimentalDocgenServer</code> on <code>@storybook/vue3-vite</code>.
<a target="_blank" href="https://storybook.js.org/docs/ai/mcp/overview#framework-support">Learn how to enable it</a>
</div>`;
let testNoticeLines = [
!testSupported && 'This toolset requires Storybook 10.3.0+ with <code>@storybook/addon-vitest</code>. <a target="_blank" href="https://storybook.js.org/docs/writing-tests/integrations/vitest-addon#install-and-set-up">Learn how to set it up</a>',
!a11yEnabled2 && '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), testNotice = testNoticeLines.length ? `<div class="toolset-notice">${testNoticeLines.join("<br>")}</div>` : "", a11yBadge = a11yEnabled2 ? ' <span class="toolset-status enabled">+ accessibility</span>' : "", devNoticeLines = isDevEnabled ? [
!moduleGraphSupported && "<code>stories-find-by-component</code> requires a dev server with a builder that supports the module graph (Vite or Webpack 5).",
!changeDetectionEnabled && "<code>stories-changed</code> requires enabling the <code>changeDetection</code> feature flag.",
!reviewEnabled && (reviewEnabledForCli ? "<code>review-create</code> is enabled for <code>storybook ai</code> CLI clients (the Claude/Codex plugins); direct MCP clients need the <code>experimentalReview</code> feature flag." : "<code>review-create</code> requires the <code>changeDetection</code> feature flag and is off when <code>experimentalReview</code> is set to <code>false</code>.")
].filter(Boolean) : ["The <code>dev</code> toolset is disabled via addon options."], devNotice = devNoticeLines.length ? `<div class="toolset-notice">${devNoticeLines.join("<br>")}</div>` : "", statusWord = (enabled) => enabled ? "enabled" : "disabled", html = template_default.replaceAll("{{DEV_STATUS}}", isDevEnabled ? "enabled" : "disabled").replaceAll(
"{{STORIES_BY_COMPONENT_STATUS}}",
statusWord(isDevEnabled && moduleGraphSupported)
).replaceAll("{{CHANGE_DETECTION_STATUS}}", statusWord(isDevEnabled && changeDetectionEnabled)).replaceAll("{{REVIEW_STATUS}}", statusWord(isDevEnabled && reviewEnabledForCli)).replace("{{DEV_NOTICE}}", devNotice).replaceAll("{{DOCS_STATUS}}", isDocsEnabled ? "enabled" : "disabled").replace("{{DOCS_NOTICE}}", docsNotice).replaceAll("{{TEST_STATUS}}", isTestEnabled ? "enabled" : "disabled").replace("{{TEST_NOTICE}}", testNotice).replace(
"{{MANIFEST_DEBUGGER_LINK}}",
rawAvailability.docsEnabled ? '<p>View the <a href="/manifests/components.html">component manifest debugger</a>.</p>' : ""
).replace("{{A11Y_BADGE}}", a11yBadge);
res.end(html);
}), app;
}, experimental_storybookAi = async (existingMetadata, options) => buildStorybookAiMetadata(options, existingMetadata), features = async (existingFeatures) => ({
...existingFeatures,
componentsManifest: !0
});
export {
experimental_devServer,
experimental_storybookAi,
features,
previewAnnotations
};