Storybook Story Run
storybook_story_runLoad a Storybook story in headless Chromium, optionally capture a screenshot, and run an axe-core accessibility audit.
Instructions
Load a single Storybook story in headless Chromium via iframe.html, optionally screenshot it, and run an axe-core audit against the rendered output. Requires playwright.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| storybookUrl | Yes | Base URL of a running Storybook, e.g. http://localhost:6006. | |
| storyId | Yes | Story id as it appears in the URL (e.g. 'components-button--primary'). | |
| screenshotPath | No | If set, save a PNG of the rendered story to this absolute path. | |
| runAxe | No | Run an axe-core audit of the rendered story (default true). | |
| viewport | No | ||
| colorScheme | No | ||
| timeoutMs | No |
Implementation Reference
- src/tools/storybook-story-run.ts:31-127 (handler)The registerStorybookStoryRun function registers the 'storybook_story_run' tool with the MCP server. The handler (lines 40-126) launches headless Chromium via Playwright, navigates to the story's iframe.html URL, optionally takes a screenshot, and runs an axe-core accessibility audit against the rendered story.
export function registerStorybookStoryRun(server: McpServer) { server.registerTool( "storybook_story_run", { title: "Storybook Story Run", description: "Load a single Storybook story in headless Chromium via iframe.html, optionally screenshot it, and run an axe-core audit against the rendered output. Requires `playwright`.", inputSchema: InputShape, }, async (args) => { try { const pw = await loadOptional<typeof import("playwright")>( "playwright", "npm i -D playwright && npx playwright install chromium" ); const runAxe = args.runAxe ?? true; const timeout = args.timeoutMs ?? 20000; const base = args.storybookUrl.replace(/\/+$/, ""); const iframeUrl = `${base}/iframe.html?viewMode=story&id=${encodeURIComponent(args.storyId)}`; const browser = await pw.chromium.launch(); try { const context = await browser.newContext({ viewport: args.viewport ?? { width: 1280, height: 800 }, colorScheme: args.colorScheme ?? "light", }); const page = await context.newPage(); const errors: string[] = []; page.on("pageerror", (e: Error) => errors.push(e.message)); await page.goto(iframeUrl, { waitUntil: "networkidle", timeout }); // Storybook renders inside #storybook-root (newer) or #root (older) await page .waitForSelector("#storybook-root, #root", { timeout }) .catch(() => {}); const result: Record<string, unknown> = { storyId: args.storyId, iframeUrl, pageErrors: errors, }; if (args.screenshotPath) { await fs.mkdir(path.dirname(args.screenshotPath), { recursive: true }); await page.screenshot({ path: args.screenshotPath, fullPage: false }); result.screenshotPath = args.screenshotPath; } if (runAxe) { await page.addScriptTag({ content: axeSource.source }); const axeResults = await page.evaluate(async () => { // @ts-expect-error axe injected const axe = window.axe; const res = await axe.run( document.querySelector("#storybook-root, #root") ?? document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa", "best-practice"], }, resultTypes: ["violations", "incomplete"], } ); return res; }); const r = axeResults as { violations: Array<{ id: string; impact: string | null; help: string; helpUrl: string; nodes: Array<{ target: string[]; html: string; failureSummary?: string }>; }>; incomplete: Array<{ id: string; help: string; nodes: unknown[] }>; }; result.axe = { violationCount: r.violations.length, violations: r.violations.map((v) => ({ id: v.id, impact: v.impact, help: v.help, helpUrl: v.helpUrl, nodes: v.nodes.slice(0, 3).map((n) => ({ target: n.target, html: n.html.slice(0, 300), failureSummary: n.failureSummary, })), nodeCount: v.nodes.length, })), incompleteCount: r.incomplete.length, }; } return jsonResult(result); } finally { await browser.close(); } } catch (err) { return errorResult(err instanceof Error ? err.message : String(err)); } } ); } - Input validation schema for the tool using Zod: storybookUrl (URL), storyId (string), screenshotPath (optional string), runAxe (optional boolean, default true), viewport (optional {width, height}), colorScheme (optional 'light'|'dark'|'no-preference'), timeoutMs (optional positive integer).
const InputShape = { storybookUrl: z .string() .url() .describe("Base URL of a running Storybook, e.g. http://localhost:6006."), storyId: z .string() .describe("Story id as it appears in the URL (e.g. 'components-button--primary')."), screenshotPath: z .string() .optional() .describe("If set, save a PNG of the rendered story to this absolute path."), runAxe: z.boolean().optional().describe("Run an axe-core audit of the rendered story (default true)."), viewport: z .object({ width: z.number().int().positive(), height: z.number().int().positive(), }) .optional(), colorScheme: z.enum(["light", "dark", "no-preference"]).optional(), timeoutMs: z.number().int().positive().optional(), }; - src/index.ts:8-29 (registration)Import of registerStorybookStoryRun from the tool module.
import { registerStorybookStoryRun } from "./tools/storybook-story-run.js"; import { registerScaffoldComponent } from "./tools/scaffold-component.js"; async function main() { const server = new McpServer( { name: "mcp-frontend-tools", version: "2.0.0", }, { capabilities: { tools: {} }, instructions: "Frontend engineering toolbox: run real axe-core accessibility audits, take Playwright screenshots, enforce bundle budgets, diff design tokens, and execute Storybook stories headlessly. Use these before opening a PR that touches UI.", } ); registerAxeAudit(server); registerPageScreenshot(server); registerBundleBudget(server); registerDesignTokenDiff(server); registerStorybookStoryRun(server); registerScaffoldComponent(server); - src/index.ts:28-28 (registration)Registration call: registerStorybookStoryRun(server) wires the tool into the MCP server.
registerStorybookStoryRun(server); - src/util/optional.ts:5-16 (helper)The loadOptional helper used by the handler to dynamically import the 'playwright' peer dependency, providing a user-friendly install hint if missing.
export async function loadOptional<T>( moduleName: string, install: string ): Promise<T> { try { return (await import(/* @vite-ignore */ moduleName)) as T; } catch (err) { const hint = `The '${moduleName}' package is not installed. Install it with:\n ${install}`; const cause = err instanceof Error ? err.message : String(err); throw new Error(`${hint}\n\nUnderlying error: ${cause}`); } }