@storybook/addon-mcp 0.2.0 → 0.2.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/README.md CHANGED
@@ -125,6 +125,7 @@ This addon should work with any MCP-compatible client that supports the `tool` c
125
125
  - [Cline](https://docs.cline.bot/mcp/configuring-mcp-servers)
126
126
  - [Zed Editor](https://zed.dev/docs/ai/mcp#as-custom-servers)
127
127
  - [Continue](https://docs.continue.dev/customize/deep-dives/mcp#how-to-configure-mcp-servers)
128
+ - [Codex](https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers)
128
129
 
129
130
  For clients not listed above, consult their documentation for MCP server configuration. The server configuration typically requires:
130
131
 
@@ -152,7 +153,7 @@ Provides agents with standardized instructions for UI component development with
152
153
 
153
154
  The instructions ensure agents follow your project's conventions when creating or modifying UI components and their corresponding stories.
154
155
 
155
- #### 2. Get Story URLs (`get_story_urls`)
156
+ #### 2. Preview Stories (`preview-stories`)
156
157
 
157
158
  Allows agents to retrieve direct URLs to specific stories in your Storybook. The agent can request URLs for multiple stories by providing:
158
159
 
package/dist/preset.js CHANGED
@@ -2,16 +2,19 @@ import { McpServer } from "tmcp";
2
2
  import { ValibotJsonSchemaAdapter } from "@tmcp/adapter-valibot";
3
3
  import { HttpTransport } from "@tmcp/transport-http";
4
4
  import path from "node:path";
5
+ import url from "node:url";
5
6
  import { storyNameFromExport } from "storybook/internal/csf";
6
7
  import { logger } from "storybook/internal/node-logger";
7
8
  import * as v from "valibot";
8
9
  import { telemetry } from "storybook/internal/telemetry";
10
+ import { stringify } from "picoquery";
11
+ import fs from "node:fs/promises";
9
12
  import { addGetDocumentationTool, addListAllDocumentationTool } from "@storybook/mcp";
10
13
  import { buffer } from "node:stream/consumers";
11
14
 
12
15
  //#region package.json
13
16
  var name = "@storybook/addon-mcp";
14
- var version = "0.2.0";
17
+ var version = "0.2.2";
15
18
  var description = "Help agents automatically write and test stories for your UI components";
16
19
 
17
20
  //#endregion
@@ -30,6 +33,61 @@ async function collectTelemetry({ event, server, ...payload }) {
30
33
  }
31
34
  }
32
35
 
36
+ //#endregion
37
+ //#region src/utils/build-args-param.ts
38
+ const HEX_REGEXP = /^#([a-f0-9]{3,4}|[a-f0-9]{6}|[a-f0-9]{8})$/i;
39
+ const COLOR_REGEXP = /^(rgba?|hsla?)\(([0-9]{1,3}),\s?([0-9]{1,3})%?,\s?([0-9]{1,3})%?,?\s?([0-9](\.[0-9]{1,2})?)?\)$/i;
40
+ /**
41
+ * Encodes special values for Storybook's args URL format.
42
+ * Handles undefined, null, booleans, dates, hex colors, and rgba/hsla colors.
43
+ */
44
+ function encodeSpecialValues(value) {
45
+ if (value === void 0) return "!undefined";
46
+ if (value === null) return "!null";
47
+ if (typeof value === "string") {
48
+ if (HEX_REGEXP.test(value)) return `!hex(${value.slice(1)})`;
49
+ if (COLOR_REGEXP.test(value)) return `!${value.replace(/[\s%]/g, "")}`;
50
+ return value;
51
+ }
52
+ if (typeof value === "boolean") return `!${value}`;
53
+ if (value instanceof Date) return `!date(${value.toISOString()})`;
54
+ if (Array.isArray(value)) return value.map(encodeSpecialValues);
55
+ if (typeof value === "object" && value !== null) return Object.entries(value).reduce((acc, [key, val]) => Object.assign(acc, { [key]: encodeSpecialValues(val) }), {});
56
+ return value;
57
+ }
58
+ /**
59
+ * Replaces some url-encoded characters with their decoded equivalents.
60
+ * The URI RFC specifies these should be encoded, but all browsers will
61
+ * tolerate them being decoded, so we opt to go with it for cleaner looking URIs.
62
+ */
63
+ function decodeKnownQueryChar(chr) {
64
+ switch (chr) {
65
+ case "%20": return "+";
66
+ case "%5B": return "[";
67
+ case "%5D": return "]";
68
+ case "%2C": return ",";
69
+ case "%3A": return ":";
70
+ }
71
+ return chr;
72
+ }
73
+ const KNOWN_QUERY_CHAR_REGEXP = /%[0-9A-F]{2}/g;
74
+ /**
75
+ * Builds a Storybook args query parameter string from an object of props.
76
+ *
77
+ * The format uses semicolons as delimiters and colons for key:value pairs,
78
+ * with special encoding for booleans, null, undefined, dates, and colors.
79
+ *
80
+ * Example output: "disabled:!true;label:Hello+World;count:42"
81
+ */
82
+ function buildArgsParam(args) {
83
+ if (!args || Object.keys(args).length === 0) return "";
84
+ return stringify(encodeSpecialValues(args), {
85
+ delimiter: ";",
86
+ nesting: true,
87
+ nestingSyntax: "js"
88
+ }).replace(KNOWN_QUERY_CHAR_REGEXP, decodeKnownQueryChar).split(";").map((part) => part.replace("=", ":")).join(";");
89
+ }
90
+
33
91
  //#endregion
34
92
  //#region src/utils/fetch-story-index.ts
35
93
  /**
@@ -67,95 +125,9 @@ const errorToMCPContent = (error) => {
67
125
  };
68
126
  };
69
127
 
70
- //#endregion
71
- //#region src/types.ts
72
- const AddonOptions = v.object({
73
- toolsets: v.optional(v.object({
74
- dev: v.exactOptional(v.boolean(), true),
75
- docs: v.exactOptional(v.boolean(), true)
76
- }), {
77
- dev: true,
78
- docs: true
79
- }),
80
- experimentalFormat: v.optional(v.picklist(["xml", "markdown"]), "markdown")
81
- });
82
- /**
83
- * Schema for a single story input when requesting story URLs.
84
- */
85
- const StoryInput = v.object({
86
- exportName: v.string(),
87
- explicitStoryName: v.optional(v.string()),
88
- absoluteStoryPath: v.string()
89
- });
90
- /**
91
- * Schema for the array of stories to fetch URLs for.
92
- */
93
- const StoryInputArray = v.array(StoryInput);
94
- /**
95
- * Schema for the output URL array.
96
- */
97
- const StoryUrlArray = v.array(v.string());
98
-
99
- //#endregion
100
- //#region src/tools/get-story-urls.ts
101
- const GET_STORY_URLS_TOOL_NAME = "get-story-urls";
102
- const GetStoryUrlsInput = v.object({ stories: StoryInputArray });
103
- async function addGetStoryUrlsTool(server) {
104
- server.tool({
105
- name: GET_STORY_URLS_TOOL_NAME,
106
- title: "Get stories' URLs",
107
- description: `Get the URL for one or more stories.`,
108
- schema: GetStoryUrlsInput,
109
- enabled: () => server.ctx.custom?.toolsets?.dev ?? true
110
- }, async (input) => {
111
- try {
112
- const { origin: origin$1, disableTelemetry: disableTelemetry$1 } = server.ctx.custom ?? {};
113
- if (!origin$1) throw new Error("Origin is required in addon context");
114
- const index = await fetchStoryIndex(origin$1);
115
- const entriesList = Object.values(index.entries);
116
- const result = [];
117
- let foundStoryCount = 0;
118
- for (const { exportName, explicitStoryName, absoluteStoryPath } of input.stories) {
119
- const relativePath = `./${path.relative(process.cwd(), absoluteStoryPath)}`;
120
- logger.debug("Searching for:");
121
- logger.debug({
122
- exportName,
123
- explicitStoryName,
124
- absoluteStoryPath,
125
- relativePath
126
- });
127
- const foundStoryId = entriesList.find((entry) => entry.importPath === relativePath && [explicitStoryName, storyNameFromExport(exportName)].includes(entry.name))?.id;
128
- if (foundStoryId) {
129
- logger.debug(`Found story ID: ${foundStoryId}`);
130
- result.push(`${origin$1}/?path=/story/${foundStoryId}`);
131
- foundStoryCount++;
132
- } else {
133
- logger.debug("No story found");
134
- let errorMessage = `No story found for export name "${exportName}" with absolute file path "${absoluteStoryPath}"`;
135
- if (!explicitStoryName) errorMessage += ` (did you forget to pass the explicit story name?)`;
136
- result.push(errorMessage);
137
- }
138
- }
139
- if (!disableTelemetry$1) await collectTelemetry({
140
- event: "tool:getStoryUrls",
141
- server,
142
- toolset: "dev",
143
- inputStoryCount: input.stories.length,
144
- outputStoryCount: foundStoryCount
145
- });
146
- return { content: result.map((text) => ({
147
- type: "text",
148
- text
149
- })) };
150
- } catch (error) {
151
- return errorToMCPContent(error);
152
- }
153
- });
154
- }
155
-
156
128
  //#endregion
157
129
  //#region src/storybook-story-instructions.md
158
- var storybook_story_instructions_default = "# Writing User Interfaces\n\nWhen writing UI, prefer breaking larger components up into smaller parts.\n\nALWAYS write a Storybook story for any component written. If editing a component, ensure appropriate changes have been made to stories for that component.\n\n## How to write good stories\n\nGoal: Cover every distinct piece of business logic and state the component can reach (happy paths, error/edge states, loading, permissions/roles, empty states, variations from props/context). Avoid redundant stories that show the same logic.\n\nInteractivity: If the component is interactive, add Interaction tests using play functions that drive the UI with storybook/test utilities (e.g., fn, userEvent, expect). Simulate key user flows: clicking buttons/links, typing, focus/blur, keyboard nav, form submit, async responses, toggle/selection changes, pagination/filters, etc. When passing `fn` functions as `args` for callback functions, make sure to add a play function which interacts with the component and assert whether the callback function was actually called.\n\nData/setup: Provide realistic props, state, and mocked data. Include meaningful labels/text to make behaviors observable. Stub network/services with deterministic fixtures; keep stories reliable.\n\nAssertions: In play functions, assert the visible outcome of the interaction (text, aria state, enabled/disabled, class/state changes, emitted events). Prefer role/label-based queries.\n\nVariants to consider (pick only those that change behavior): default vs. alternate themes; loading vs. loaded vs. empty vs. error; validated vs. invalid input; permissions/roles/capabilities; feature flags; size/density/layout variants that alter logic.\n\nAccessibility: Use semantic roles/labels; ensure focusable/keyboard interactions are test-covered where relevant.\n\nNaming/structure: Use clear story names that describe the scenario (“Error state after failed submit”). Group related variants logically; don’t duplicate.\n\nImports/format: Import Meta/StoryObj from the framework package; import test helpers from storybook/test (not @storybook/test). Keep stories minimal—only what's needed to demonstrate behavior.\n\n## Storybook 9 Essential Changes for Story Writing\n\n### Package Consolidation\n\n#### `Meta` and `StoryObj` imports\n\nUpdate story imports to use the framework package:\n\n```diff\n- import { Meta, StoryObj } from '{{RENDERER}}';\n+ import { Meta, StoryObj } from '{{FRAMEWORK}}';\n```\n\n#### Test utility imports\n\nUpdate test imports to use `storybook/test` instead of `@storybook/test`\n\n```diff\n- import { fn } from '@storybook/test';\n+ import { fn } from 'storybook/test';\n```\n\n### Global State Changes\n\nThe `globals` annotation has be renamed to `initialGlobals`:\n\n```diff\n// .storybook/preview.js\nexport default {\n- globals: { theme: 'light' }\n+ initialGlobals: { theme: 'light' }\n};\n```\n\n### Autodocs Configuration\n\nInstead of `parameters.docs.autodocs` in main.js, use tags:\n\n```js\n// .storybook/preview.js or in individual stories\nexport default {\n tags: ['autodocs'], // generates autodocs for all stories\n};\n```\n\n### Mocking imports in Storybook\n\nTo mock imports in Storybook, use Storybook's mocking features. ALWAYS mock external dependencies to ensure stories render consistently.\n\n1. **Register in the mock in Storybook's preview file**:\n To mock dependendencies, you MUST register a module mock in `.storybook/preview.ts` (or equivalent):\n\n```js\nimport { sb } from 'storybook/test';\n\n// Prefer spy mocks (keeps functions, but allows to override them and spy on them)\nsb.mock(import('some-library'), { spy: true });\n```\n\n**Important: Use file extensions when referring to relative files!**\n\n```js\nsb.mock(import('./relative/module.ts'), { spy: true });\n```\n\n2. **Specify mock values in stories**:\n You can override the behaviour of the mocks per-story using `beforeEach` and the `mocked()` type function:\n\n```js\nimport { expect, mocked, fn } from 'storybook/test';\nimport { library } from 'some-library';\n\nconst meta = {\n component: AuthButton,\n beforeEach: async () => {\n mocked(library).mockResolvedValue({ user: 'data' });\n },\n};\n\nexport const LoggedIn: Story = {\n play: async ({ canvas }) => {\n await expect(library).toHaveBeenCalled();\n },\n};\n```\n\nBefore doing this ensure you have mocked the import in the preview file.\n\n### Play Function Parameters\n\n- The play function has a `canvas` parameter that can be used directly with testing-library-like query methods.\n- It also has a `canvasElement` which is the actual DOM element.\n- The `within`-function imported from `storybook/test` transforms a DOM element to an object with query methods, similar to `canvas`.\n\n**DO NOT** use `within(canvas)` - it is redundant because `canvas` already has the query methods, `canvas` is not a DOM element.\n\n```ts\n// ✅ Correct: Use canvas directly\nplay: async ({ canvas }) => {\n await canvas.getByLabelText('Submit').click();\n};\n\n// ⚠️ Also acceptable: Use `canvasElement` with `within`\nimport { within } from 'storybook/test';\n\nplay: async ({ canvasElement }) => {\n const canvas = within(canvasElement);\n await canvas.getByLabelText('Submit').click();\n};\n\n// ❌ Wrong: Do NOT use within(canvas)\nplay: async ({ canvas }) => {\n const screen = within(canvas); // Error!\n};\n```\n\n### Key Requirements\n\n- **Node.js 20+**, **TypeScript 4.9+**, **Vite 5+**\n- React Native uses `.rnstorybook` directory\n\n## Story Linking Agent Behavior\n\n- ALWAYS provide story links after any changes to stories files, including changes to existing stories.\n- After changing any UI components, ALWAYS search for related stories that might cover the changes you've made. If you find any, provide the story links to the user. THIS IS VERY IMPORTANT, as it allows the user to visually inspect the changes you've made. Even later in a session when changing UI components or stories that have already been linked to previously, YOU MUST PROVIDE THE LINKS AGAIN.\n- Use the {{GET_STORY_URLS_TOOL_NAME}} tool to get the correct URLs for links to stories.\n";
130
+ var storybook_story_instructions_default = "# Writing User Interfaces\n\nWhen writing UI, prefer breaking larger components up into smaller parts.\n\nALWAYS write a Storybook story for any component written. If editing a component, ensure appropriate changes have been made to stories for that component.\n\n## How to write good stories\n\nGoal: Cover every distinct piece of business logic and state the component can reach (happy paths, error/edge states, loading, permissions/roles, empty states, variations from props/context). Avoid redundant stories that show the same logic.\n\nInteractivity: If the component is interactive, add Interaction tests using play functions that drive the UI with storybook/test utilities (e.g., fn, userEvent, expect). Simulate key user flows: clicking buttons/links, typing, focus/blur, keyboard nav, form submit, async responses, toggle/selection changes, pagination/filters, etc. When passing `fn` functions as `args` for callback functions, make sure to add a play function which interacts with the component and assert whether the callback function was actually called.\n\nData/setup: Provide realistic props, state, and mocked data. Include meaningful labels/text to make behaviors observable. Stub network/services with deterministic fixtures; keep stories reliable.\n\nAssertions: In play functions, assert the visible outcome of the interaction (text, aria state, enabled/disabled, class/state changes, emitted events). Prefer role/label-based queries.\n\nVariants to consider (pick only those that change behavior): default vs. alternate themes; loading vs. loaded vs. empty vs. error; validated vs. invalid input; permissions/roles/capabilities; feature flags; size/density/layout variants that alter logic.\n\nAccessibility: Use semantic roles/labels; ensure focusable/keyboard interactions are test-covered where relevant.\n\nNaming/structure: Use clear story names that describe the scenario (“Error state after failed submit”). Group related variants logically; don’t duplicate.\n\nImports/format: Import Meta/StoryObj from the framework package; import test helpers from storybook/test (not @storybook/test). Keep stories minimal—only what's needed to demonstrate behavior.\n\n## Storybook 9 Essential Changes for Story Writing\n\n### Package Consolidation\n\n#### `Meta` and `StoryObj` imports\n\nUpdate story imports to use the framework package:\n\n```diff\n- import { Meta, StoryObj } from '{{RENDERER}}';\n+ import { Meta, StoryObj } from '{{FRAMEWORK}}';\n```\n\n#### Test utility imports\n\nUpdate test imports to use `storybook/test` instead of `@storybook/test`\n\n```diff\n- import { fn } from '@storybook/test';\n+ import { fn } from 'storybook/test';\n```\n\n### Global State Changes\n\nThe `globals` annotation has be renamed to `initialGlobals`:\n\n```diff\n// .storybook/preview.js\nexport default {\n- globals: { theme: 'light' }\n+ initialGlobals: { theme: 'light' }\n};\n```\n\n### Autodocs Configuration\n\nInstead of `parameters.docs.autodocs` in main.js, use tags:\n\n```js\n// .storybook/preview.js or in individual stories\nexport default {\n tags: ['autodocs'], // generates autodocs for all stories\n};\n```\n\n### Mocking imports in Storybook\n\nTo mock imports in Storybook, use Storybook's mocking features. ALWAYS mock external dependencies to ensure stories render consistently.\n\n1. **Register in the mock in Storybook's preview file**:\n To mock dependendencies, you MUST register a module mock in `.storybook/preview.ts` (or equivalent):\n\n```js\nimport { sb } from 'storybook/test';\n\n// Prefer spy mocks (keeps functions, but allows to override them and spy on them)\nsb.mock(import('some-library'), { spy: true });\n```\n\n**Important: Use file extensions when referring to relative files!**\n\n```js\nsb.mock(import('./relative/module.ts'), { spy: true });\n```\n\n2. **Specify mock values in stories**:\n You can override the behaviour of the mocks per-story using `beforeEach` and the `mocked()` type function:\n\n```js\nimport { expect, mocked, fn } from 'storybook/test';\nimport { library } from 'some-library';\n\nconst meta = {\n component: AuthButton,\n beforeEach: async () => {\n mocked(library).mockResolvedValue({ user: 'data' });\n },\n};\n\nexport const LoggedIn: Story = {\n play: async ({ canvas }) => {\n await expect(library).toHaveBeenCalled();\n },\n};\n```\n\nBefore doing this ensure you have mocked the import in the preview file.\n\n### Play Function Parameters\n\n- The play function has a `canvas` parameter that can be used directly with testing-library-like query methods.\n- It also has a `canvasElement` which is the actual DOM element.\n- The `within`-function imported from `storybook/test` transforms a DOM element to an object with query methods, similar to `canvas`.\n\n**DO NOT** use `within(canvas)` - it is redundant because `canvas` already has the query methods, `canvas` is not a DOM element.\n\n```ts\n// ✅ Correct: Use canvas directly\nplay: async ({ canvas }) => {\n await canvas.getByLabelText('Submit').click();\n};\n\n// ⚠️ Also acceptable: Use `canvasElement` with `within`\nimport { within } from 'storybook/test';\n\nplay: async ({ canvasElement }) => {\n const canvas = within(canvasElement);\n await canvas.getByLabelText('Submit').click();\n};\n\n// ❌ Wrong: Do NOT use within(canvas)\nplay: async ({ canvas }) => {\n const screen = within(canvas); // Error!\n};\n```\n\n### Key Requirements\n\n- **Node.js 20+**, **TypeScript 4.9+**, **Vite 5+**\n- React Native uses `.rnstorybook` directory\n\n## Story Linking Agent Behavior\n\n- ALWAYS provide story links after any changes to stories files, including changes to existing stories.\n- After changing any UI components, ALWAYS search for related stories that might cover the changes you've made. If you find any, provide the story links to the user. THIS IS VERY IMPORTANT, as it allows the user to visually inspect the changes you've made. Even later in a session when changing UI components or stories that have already been linked to previously, YOU MUST PROVIDE THE LINKS AGAIN.\n- Use the {{PREVIEW_STORIES_TOOL_NAME}} tool to get the correct URLs for links to stories.\n";
159
131
 
160
132
  //#endregion
161
133
  //#region src/tools/get-storybook-story-instructions.ts
@@ -198,7 +170,7 @@ Even if you're familiar with Storybook, call this tool to ensure you're followin
198
170
  const renderer = frameworkToRendererMap[framework];
199
171
  return { content: [{
200
172
  type: "text",
201
- text: storybook_story_instructions_default.replace("{{FRAMEWORK}}", framework).replace("{{RENDERER}}", renderer ?? framework).replace("{{GET_STORY_URLS_TOOL_NAME}}", GET_STORY_URLS_TOOL_NAME)
173
+ text: storybook_story_instructions_default.replace("{{FRAMEWORK}}", framework).replace("{{RENDERER}}", renderer ?? framework).replace("{{PREVIEW_STORIES_TOOL_NAME}}", PREVIEW_STORIES_TOOL_NAME)
202
174
  }] };
203
175
  } catch (error) {
204
176
  return errorToMCPContent(error);
@@ -221,6 +193,163 @@ const frameworkToRendererMap = {
221
193
  "@storybook/html-vite": "@storybook/html"
222
194
  };
223
195
 
196
+ //#endregion
197
+ //#region src/types.ts
198
+ const AddonOptions = v.object({
199
+ toolsets: v.optional(v.object({
200
+ dev: v.exactOptional(v.boolean(), true),
201
+ docs: v.exactOptional(v.boolean(), true)
202
+ }), {
203
+ dev: true,
204
+ docs: true
205
+ }),
206
+ experimentalFormat: v.optional(v.picklist(["xml", "markdown"]), "markdown")
207
+ });
208
+ /**
209
+ * Schema for a single story input when requesting story URLs.
210
+ */
211
+ const StoryInput = v.object({
212
+ exportName: v.string(),
213
+ explicitStoryName: v.pipe(v.optional(v.string()), v.description(`If the story has an explicit name set via the "name" propoerty, that is different from the export name, provide it here.
214
+ Otherwise don't set this.`)),
215
+ absoluteStoryPath: v.string(),
216
+ props: v.pipe(v.optional(v.record(v.string(), v.any())), v.description(`Optional custom props to pass to the story for rendering. Use this when you don't want to render the default story,
217
+ but you want to customize some args or other props.
218
+ You can look up the component's documentation using the ${GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME} tool to see what props are available.`)),
219
+ globals: v.pipe(v.optional(v.record(v.string(), v.any())), v.description(`Optional Storybook globals to set for the story preview. Globals are used for things like theme, locale, viewport, and other cross-cutting concerns.
220
+ Common globals include 'theme' (e.g., 'dark', 'light'), 'locale' (e.g., 'en', 'fr'), and 'backgrounds' (e.g., { value: '#000' }).`))
221
+ });
222
+ /**
223
+ * Schema for the array of stories to fetch URLs for.
224
+ */
225
+ const StoryInputArray = v.array(StoryInput);
226
+
227
+ //#endregion
228
+ //#region src/tools/preview-stories/preview-stories-app-template.html
229
+ var preview_stories_app_template_default = "<!doctype html>\n<html>\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <style>\n * {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n }\n html[data-theme='light'] {\n color-scheme: light;\n }\n html[data-theme='dark'] {\n color-scheme: dark;\n }\n html,\n body {\n width: 100%;\n margin: 0;\n padding: 0;\n background-color: var(--color-background-secondary);\n }\n body {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n }\n :root {\n /*\n These are fallback values, if the MCP client doesn't set them.\n See https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#theming\n */\n --color-text-primary: light-dark(black, white);\n --color-border-primary: light-dark(#ccc, #444);\n --color-background-secondary: light-dark(#f9f9f9, #1e1e1e);\n --font-sans:\n -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Oxygen-Sans',\n Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;\n --font-heading-xs-size: 1rem;\n --font-heading-xs-line-height: 1.25;\n --border-width-regular: 1px;\n }\n .story-heading {\n font-family: var(--font-sans);\n font-size: var(--font-heading-xs-size);\n line-height: var(--font-heading-xs-line-height);\n color: var(--color-text-primary);\n padding: 0.5rem 0;\n }\n .story-iframe {\n background-color: white;\n border: var(--border-width-regular) solid var(--color-border-primary);\n }\n body:has(> article:only-child) h1 {\n /* if there is only one story rendered, hide its heading */\n display: none;\n }\n </style>\n <template id=\"preview-template\">\n <article>\n <h1 class=\"story-heading\"></h1>\n <iframe class=\"story-iframe\"></iframe>\n </article>\n </template>\n <script type=\"module\">\n // APP_SCRIPT_PLACEHOLDER\n <\/script>\n </head>\n <body></body>\n</html>\n";
230
+
231
+ //#endregion
232
+ //#region src/utils/slash.ts
233
+ /**
234
+ * Normalize paths to forward slashes for cross-platform compatibility
235
+ * Storybook import paths always use forward slashes
236
+ */
237
+ function slash(path$1) {
238
+ return path$1.replace(/\\/g, "/");
239
+ }
240
+
241
+ //#endregion
242
+ //#region src/tools/preview-stories.ts
243
+ const PREVIEW_STORIES_TOOL_NAME = "preview-stories";
244
+ const PREVIEW_STORIES_RESOURCE_URI = `ui://${PREVIEW_STORIES_TOOL_NAME}/preview.html`;
245
+ const PreviewStoriesInput = v.object({ stories: StoryInputArray });
246
+ const PreviewStoriesOutput = v.object({ stories: v.array(v.union([v.object({
247
+ title: v.string(),
248
+ name: v.string(),
249
+ previewUrl: v.string()
250
+ }), v.object({
251
+ input: StoryInput,
252
+ error: v.string()
253
+ })])) });
254
+ async function addPreviewStoriesTool(server) {
255
+ const previewStoryAppScript = await fs.readFile(url.fileURLToPath(import.meta.resolve("@storybook/addon-mcp/internal/preview-stories-app-script")), "utf-8");
256
+ const appHtml = preview_stories_app_template_default.replace("// APP_SCRIPT_PLACEHOLDER", previewStoryAppScript);
257
+ server.resource({
258
+ name: PREVIEW_STORIES_RESOURCE_URI,
259
+ description: "App resource for the Preview Stories tool",
260
+ uri: PREVIEW_STORIES_RESOURCE_URI,
261
+ mimeType: "text/html;profile=mcp-app"
262
+ }, () => {
263
+ const origin$1 = server.ctx.custom.origin;
264
+ return { contents: [{
265
+ uri: PREVIEW_STORIES_RESOURCE_URI,
266
+ mimeType: "text/html;profile=mcp-app",
267
+ text: appHtml,
268
+ _meta: { ui: {
269
+ prefersBorder: false,
270
+ domain: origin$1,
271
+ csp: {
272
+ connectDomains: [origin$1],
273
+ resourceDomains: [origin$1],
274
+ frameDomains: [origin$1],
275
+ baseUriDomains: [origin$1]
276
+ }
277
+ } }
278
+ }] };
279
+ });
280
+ server.tool({
281
+ name: PREVIEW_STORIES_TOOL_NAME,
282
+ title: "Preview stories",
283
+ description: `Use this tool to preview one or more stories, rendering them as an MCP App using the UI Resource or returning the raw URL for users to visit.`,
284
+ schema: PreviewStoriesInput,
285
+ outputSchema: PreviewStoriesOutput,
286
+ enabled: () => server.ctx.custom?.toolsets?.dev ?? true,
287
+ _meta: { ui: { resourceUri: PREVIEW_STORIES_RESOURCE_URI } }
288
+ }, async (input) => {
289
+ try {
290
+ const { origin: origin$1, disableTelemetry: disableTelemetry$1 } = server.ctx.custom ?? {};
291
+ if (!origin$1) throw new Error("Origin is required in addon context");
292
+ const index = await fetchStoryIndex(origin$1);
293
+ const entriesList = Object.values(index.entries);
294
+ const structuredResult = [];
295
+ const textResult = [];
296
+ for (const inputParams of input.stories) {
297
+ const { exportName, explicitStoryName, absoluteStoryPath } = inputParams;
298
+ const normalizedCwd = slash(process.cwd());
299
+ const normalizedAbsolutePath = slash(absoluteStoryPath);
300
+ const relativePath = `./${path.posix.relative(normalizedCwd, normalizedAbsolutePath)}`;
301
+ logger.debug("Searching for:");
302
+ logger.debug({
303
+ exportName,
304
+ explicitStoryName,
305
+ absoluteStoryPath,
306
+ relativePath
307
+ });
308
+ const foundStory = entriesList.find((entry) => entry.importPath === relativePath && [explicitStoryName, storyNameFromExport(exportName)].includes(entry.name));
309
+ if (foundStory) {
310
+ logger.debug(`Found story ID: ${foundStory.id}`);
311
+ let previewUrl = `${origin$1}/?path=/story/${foundStory.id}`;
312
+ const argsParam = buildArgsParam(inputParams.props ?? {});
313
+ if (argsParam) previewUrl += `&args=${argsParam}`;
314
+ const globalsParam = buildArgsParam(inputParams.globals ?? {});
315
+ if (globalsParam) previewUrl += `&globals=${globalsParam}`;
316
+ structuredResult.push({
317
+ title: foundStory.title,
318
+ name: foundStory.name,
319
+ previewUrl
320
+ });
321
+ textResult.push(previewUrl);
322
+ } else {
323
+ logger.debug("No story found");
324
+ let errorMessage = `No story found for export name "${exportName}" with absolute file path "${absoluteStoryPath}"`;
325
+ if (!explicitStoryName) errorMessage += ` (did you forget to pass the explicit story name?)`;
326
+ structuredResult.push({
327
+ input: inputParams,
328
+ error: errorMessage
329
+ });
330
+ textResult.push(errorMessage);
331
+ }
332
+ }
333
+ if (!disableTelemetry$1) await collectTelemetry({
334
+ event: "tool:previewStories",
335
+ server,
336
+ toolset: "dev",
337
+ inputStoryCount: input.stories.length,
338
+ outputStoryCount: structuredResult.length
339
+ });
340
+ return {
341
+ content: textResult.map((text) => ({
342
+ type: "text",
343
+ text
344
+ })),
345
+ structuredContent: { stories: structuredResult }
346
+ };
347
+ } catch (error) {
348
+ return errorToMCPContent(error);
349
+ }
350
+ });
351
+ }
352
+
224
353
  //#endregion
225
354
  //#region src/tools/is-manifest-available.ts
226
355
  const getManifestStatus = async (options) => {
@@ -306,7 +435,10 @@ const initializeMCPServer = async (options) => {
306
435
  description
307
436
  }, {
308
437
  adapter: new ValibotJsonSchemaAdapter(),
309
- capabilities: { tools: { listChanged: true } }
438
+ capabilities: {
439
+ tools: { listChanged: true },
440
+ resources: { listChanged: true }
441
+ }
310
442
  }).withContext();
311
443
  if (!disableTelemetry) server.on("initialize", async () => {
312
444
  await collectTelemetry({
@@ -314,7 +446,7 @@ const initializeMCPServer = async (options) => {
314
446
  server
315
447
  });
316
448
  });
317
- await addGetStoryUrlsTool(server);
449
+ await addPreviewStoriesTool(server);
318
450
  await addGetUIBuildingInstructionsTool(server);
319
451
  if ((await getManifestStatus(options)).available) {
320
452
  logger.info("Experimental components manifest feature detected - registering component tools");
@@ -370,9 +502,9 @@ const mcpServerHandler = async ({ req, res, options, addonOptions }) => {
370
502
  async function incomingMessageToWebRequest(req) {
371
503
  const host = req.headers.host || "localhost";
372
504
  const protocol = "encrypted" in req.socket && req.socket.encrypted ? "https" : "http";
373
- const url = new URL(req.url || "/", `${protocol}://${host}`);
505
+ const url$1 = new URL(req.url || "/", `${protocol}://${host}`);
374
506
  const bodyBuffer = await buffer(req);
375
- return new Request(url, {
507
+ return new Request(url$1, {
376
508
  method: req.method,
377
509
  headers: req.headers,
378
510
  body: bodyBuffer.length > 0 ? new Uint8Array(bodyBuffer) : void 0
@@ -417,10 +549,13 @@ function getToolsets(request, addonOptions) {
417
549
 
418
550
  //#endregion
419
551
  //#region src/template.html
420
- 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>get-story-urls</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-component-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";
552
+ 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-component-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";
421
553
 
422
554
  //#endregion
423
555
  //#region src/preset.ts
556
+ const previewAnnotations = async (existingAnnotations = []) => {
557
+ return [...existingAnnotations, path.join(import.meta.dirname, "preview.js")];
558
+ };
424
559
  const experimental_devServer = async (app, options) => {
425
560
  const addonOptions = v.parse(AddonOptions, {
426
561
  toolsets: "toolsets" in options ? options.toolsets : {},
@@ -458,4 +593,4 @@ const experimental_devServer = async (app, options) => {
458
593
  };
459
594
 
460
595
  //#endregion
461
- export { experimental_devServer };
596
+ export { experimental_devServer, previewAnnotations };
@@ -0,0 +1,142 @@
1
+ //#region src/constants.ts
2
+ const MCP_APP_PARAM = "mcp-app";
3
+ const MCP_APP_SIZE_CHANGED_EVENT = "storybook-mcp:size-changed";
4
+
5
+ //#endregion
6
+ //#region package.json
7
+ var version = "0.2.2";
8
+
9
+ //#endregion
10
+ //#region src/tools/preview-stories/preview-stories-app-script.ts
11
+ /**
12
+ * Current protocol version - must match LATEST_PROTOCOL_VERSION from ext-apps
13
+ * @see https://github.com/modelcontextprotocol/ext-apps
14
+ */
15
+ const LATEST_PROTOCOL_VERSION = "2025-11-21";
16
+ /**
17
+ * MCP Apps SEP protocol method constants
18
+ * These match the `method` field values from @modelcontextprotocol/ext-apps type definitions:
19
+ * - McpUiInitializeRequest: "ui/initialize"
20
+ * - McpUiInitializedNotification: "ui/notifications/initialized"
21
+ * - McpUiToolInputNotification: "ui/notifications/tool-input"
22
+ * - McpUiToolInputPartialNotification: "ui/notifications/tool-input-partial"
23
+ * - McpUiToolResultNotification: "ui/notifications/tool-result"
24
+ * - McpUiHostContextChangedNotification: "ui/notifications/host-context-changed"
25
+ * - McpUiSizeChangedNotification: "ui/notifications/size-changed"
26
+ * - McpUiResourceTeardownRequest: "ui/resource-teardown"
27
+ *
28
+ * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/src/spec.types.ts
29
+ */
30
+ const METHODS = {
31
+ INITIALIZE: "ui/initialize",
32
+ INITIALIZED: "ui/notifications/initialized",
33
+ TOOL_INPUT: "ui/notifications/tool-input",
34
+ TOOL_INPUT_PARTIAL: "ui/notifications/tool-input-partial",
35
+ TOOL_RESULT: "ui/notifications/tool-result",
36
+ TOOL_CANCELLED: "ui/notifications/tool-cancelled",
37
+ HOST_CONTEXT_CHANGED: "ui/notifications/host-context-changed",
38
+ SIZE_CHANGED: "ui/notifications/size-changed",
39
+ RESOURCE_TEARDOWN: "ui/resource-teardown",
40
+ TOOLS_CALL: "tools/call",
41
+ NOTIFICATIONS_MESSAGE: "notifications/message",
42
+ OPEN_LINK: "ui/open-link",
43
+ MESSAGE: "ui/message"
44
+ };
45
+ let nextId = 1;
46
+ function sendHostRequest(method, params) {
47
+ const id = nextId++;
48
+ const { promise, resolve, reject } = Promise.withResolvers();
49
+ window.parent.postMessage({
50
+ jsonrpc: "2.0",
51
+ id,
52
+ method,
53
+ params
54
+ }, "*");
55
+ window.addEventListener("message", function listener(event) {
56
+ if (event.data?.id !== id) return;
57
+ window.removeEventListener("message", listener);
58
+ if (event.data?.result) resolve(event.data.result);
59
+ else if (event.data?.error) reject(new Error(String(event.data.error)));
60
+ });
61
+ return promise;
62
+ }
63
+ function sendHostNotification(method, params) {
64
+ window.parent.postMessage({
65
+ jsonrpc: "2.0",
66
+ method,
67
+ params
68
+ }, "*");
69
+ }
70
+ function onHostNotification(method, handler) {
71
+ window.addEventListener("message", function listener(event) {
72
+ if (event.data?.method === method) handler(event.data.params);
73
+ });
74
+ }
75
+ applyHostStyles((await sendHostRequest(METHODS.INITIALIZE, {
76
+ appInfo: {
77
+ name: "storybook-story-preview",
78
+ version
79
+ },
80
+ appCapabilities: {},
81
+ protocolVersion: LATEST_PROTOCOL_VERSION
82
+ }))?.hostContext);
83
+ onHostNotification(METHODS.TOOL_RESULT, loadStoryIframes);
84
+ onHostNotification(METHODS.HOST_CONTEXT_CHANGED, applyHostStyles);
85
+ sendHostNotification(METHODS.INITIALIZED, {});
86
+ window.addEventListener("message", function(event) {
87
+ if (event.data?.type !== MCP_APP_SIZE_CHANGED_EVENT) return;
88
+ const iframes = document.querySelectorAll(".story-iframe");
89
+ let hasResizedIframes = false;
90
+ for (const iframe of iframes) if (iframe.contentWindow === event.source) {
91
+ iframe.style.height = (event.data.height ?? 0) + "px";
92
+ hasResizedIframes = true;
93
+ break;
94
+ }
95
+ if (hasResizedIframes) resizeApp();
96
+ });
97
+ function applyHostStyles(hostContext) {
98
+ if (hostContext?.theme) document.documentElement.setAttribute("data-theme", hostContext.theme);
99
+ if (!hostContext?.styles?.variables) return;
100
+ for (const [key, value] of Object.entries(hostContext.styles.variables)) if (value) document.documentElement.style.setProperty(key, value);
101
+ resizeApp();
102
+ }
103
+ function resizeApp() {
104
+ console.log("Resizing app to fit content", {
105
+ width: document.body.scrollWidth,
106
+ height: document.body.scrollHeight
107
+ });
108
+ sendHostNotification(METHODS.SIZE_CHANGED, {
109
+ width: document.body.scrollWidth,
110
+ height: document.body.scrollHeight
111
+ });
112
+ }
113
+ function loadStoryIframes(params) {
114
+ const stories = params.structuredContent?.stories;
115
+ if (!stories || stories.length === 0) {
116
+ console.warn("No preview URLs found in tool result.");
117
+ return;
118
+ }
119
+ const template = document.getElementById("preview-template");
120
+ for (const storyResult of stories) {
121
+ if ("error" in storyResult) {
122
+ console.warn("Skipping story with error:", storyResult.error);
123
+ continue;
124
+ }
125
+ const clone = template.content.cloneNode(true);
126
+ const article = clone.querySelector("article");
127
+ const heading = clone.querySelector("h1");
128
+ const iframe = clone.querySelector("iframe");
129
+ heading.textContent = `${storyResult.title} - ${storyResult.name}`;
130
+ iframe.style.width = "100%";
131
+ iframe.style.height = "0";
132
+ const iframeSrc = storyResult.previewUrl.replace("/?path=/story/", "/iframe.html?id=");
133
+ const url = new URL(iframeSrc);
134
+ url.searchParams.set(MCP_APP_PARAM, "true");
135
+ iframe.src = url.toString();
136
+ document.body.appendChild(article);
137
+ }
138
+ resizeApp();
139
+ }
140
+
141
+ //#endregion
142
+ export { };
@@ -0,0 +1,46 @@
1
+ //#region src/constants.ts
2
+ const MCP_APP_PARAM = "mcp-app";
3
+ const MCP_APP_SIZE_CHANGED_EVENT = "storybook-mcp:size-changed";
4
+
5
+ //#endregion
6
+ //#region src/preview.ts
7
+ /**
8
+ * Storybook MCP App Script
9
+ *
10
+ * This script runs inside Storybook's iframe and communicates dimensions
11
+ * to the parent preview.html frame via postMessage (cross-origin safe).
12
+ *
13
+ * Only activates when the iframe is loaded with `mcp-app=true` query parameter,
14
+ * which is set by the MCP Apps preview.html wrapper.
15
+ */
16
+ if (new URLSearchParams(window.location.search).has(MCP_APP_PARAM)) {
17
+ const SIZE_CHANGE_THRESHOLD = 2;
18
+ let debounceTimer = null;
19
+ let lastSentHeight = 0;
20
+ const DEBOUNCE_MS = 100;
21
+ function sendSizeToParent() {
22
+ const height = document.body.scrollHeight;
23
+ if (Math.abs(height - lastSentHeight) <= SIZE_CHANGE_THRESHOLD) return;
24
+ lastSentHeight = height;
25
+ window.parent.postMessage({
26
+ type: MCP_APP_SIZE_CHANGED_EVENT,
27
+ height
28
+ }, "*");
29
+ }
30
+ function debouncedSendSize() {
31
+ if (debounceTimer) clearTimeout(debounceTimer);
32
+ debounceTimer = setTimeout(sendSizeToParent, DEBOUNCE_MS);
33
+ }
34
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", sendSizeToParent);
35
+ else sendSizeToParent();
36
+ window.addEventListener("load", sendSizeToParent);
37
+ new ResizeObserver(debouncedSendSize).observe(document.body);
38
+ new MutationObserver(debouncedSendSize).observe(document.body, {
39
+ childList: true,
40
+ subtree: true,
41
+ attributes: true
42
+ });
43
+ }
44
+
45
+ //#endregion
46
+ export { };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@storybook/addon-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Help agents automatically write and test stories for your UI components",
5
5
  "keywords": [
6
6
  "storybook-addon",
@@ -16,6 +16,7 @@
16
16
  "type": "module",
17
17
  "exports": {
18
18
  "./preset": "./dist/preset.js",
19
+ "./internal/preview-stories-app-script": "./dist/preview-stories-app-script.js",
19
20
  "./package.json": "./package.json"
20
21
  },
21
22
  "files": [
@@ -27,12 +28,13 @@
27
28
  "dependencies": {
28
29
  "@tmcp/adapter-valibot": "^0.1.4",
29
30
  "@tmcp/transport-http": "^0.8.0",
31
+ "picoquery": "^2.5.0",
30
32
  "tmcp": "^1.16.0",
31
33
  "valibot": "1.2.0",
32
34
  "@storybook/mcp": "0.2.1"
33
35
  },
34
36
  "devDependencies": {
35
- "storybook": "10.2.0-alpha.14"
37
+ "storybook": "10.3.0-alpha.0"
36
38
  },
37
39
  "peerDependencies": {
38
40
  "storybook": "^9.1.16 || ^10.0.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0"