@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 +2 -1
- package/dist/preset.js +230 -95
- package/dist/preview-stories-app-script.js +142 -0
- package/dist/preview.js +46 -0
- package/package.json +4 -2
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.
|
|
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.
|
|
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 {{
|
|
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("{{
|
|
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: {
|
|
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
|
|
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>
|
|
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 { };
|
package/dist/preview.js
ADDED
|
@@ -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.
|
|
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.
|
|
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"
|