@miyucy/storybook-mcp 1.0.0

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.
Files changed (2) hide show
  1. package/dist/index.js +254 -0
  2. package/package.json +29 -0
package/dist/index.js ADDED
@@ -0,0 +1,254 @@
1
+ import { McpServer, ResourceTemplate, } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
4
+ import path from "node:path";
5
+ import fs from "node:fs/promises";
6
+ import process from "node:process";
7
+ import { exec } from "node:child_process";
8
+ import { z } from "zod";
9
+ const escapeRegExp = (str) => str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
10
+ class Storybook {
11
+ constructor(storybookRoot) {
12
+ this.CHUNK_SIZE = 6;
13
+ this.server = new McpServer({
14
+ name: "storybook",
15
+ description: "",
16
+ version: "0.1.0",
17
+ });
18
+ this.storybookRoot = storybookRoot;
19
+ this.componentInformation = [];
20
+ this.loadStorybookData();
21
+ this.registerStorybook();
22
+ }
23
+ async run() {
24
+ await this.server.connect(new StdioServerTransport());
25
+ }
26
+ registerStorybook() {
27
+ this.server.resource("Storybookの情報を返します", "storybook://stories", async (uri) => {
28
+ const inf = this.componentInformation;
29
+ if (inf.length === 0) {
30
+ return { contents: [{ uri: uri.href, text: "No stories found." }] };
31
+ }
32
+ return {
33
+ contents: [{ uri: uri.href, text: JSON.stringify(inf, null, 2) }],
34
+ };
35
+ });
36
+ this.server.tool("get_storybooks", "Storybookの情報を返します", {}, async () => {
37
+ const inf = this.componentInformation;
38
+ if (inf.length === 0) {
39
+ return {
40
+ content: [{ type: "text", text: "No stories found." }],
41
+ };
42
+ }
43
+ return {
44
+ content: inf.map((entry) => ({
45
+ type: "text",
46
+ text: JSON.stringify(entry, null, 2),
47
+ })),
48
+ };
49
+ });
50
+ this.server.resource("Storybookのタイトルや説明をクエリーします\nクエリーは大文字・小文字を区別しません", new ResourceTemplate("storybook://stories?{query}", { list: undefined }), async (uri, { query }) => {
51
+ const inf = this.componentInformation;
52
+ if (inf.length === 0) {
53
+ return { contents: [{ uri: uri.href, text: "No stories found." }] };
54
+ }
55
+ // queryがない場合はエラー
56
+ if (!query) {
57
+ throw new McpError(ErrorCode.InvalidParams, "Query is required.");
58
+ }
59
+ const filtered = this.queryComponentInformation(inf, query);
60
+ return {
61
+ contents: [
62
+ { uri: uri.href, text: JSON.stringify(filtered, null, 2) },
63
+ ],
64
+ };
65
+ });
66
+ this.server.tool("query_storybooks", "Storybookのタイトルや説明をクエリーします\nクエリーは大文字・小文字を区別しません", { query: z.string() }, async ({ query }) => {
67
+ const inf = this.componentInformation;
68
+ if (inf.length === 0) {
69
+ return {
70
+ content: [{ type: "text", text: "No stories found." }],
71
+ };
72
+ }
73
+ // queryがない場合はエラー
74
+ if (!query) {
75
+ throw new McpError(ErrorCode.InvalidParams, "Query is required.");
76
+ }
77
+ const filtered = this.queryComponentInformation(inf, query);
78
+ return {
79
+ content: filtered.map((entry) => ({
80
+ type: "text",
81
+ text: JSON.stringify(entry, null, 2),
82
+ })),
83
+ };
84
+ });
85
+ }
86
+ queryComponentInformation(inf, query) {
87
+ const isMatch = (entry, q) => {
88
+ const regex = new RegExp(escapeRegExp(q), "i");
89
+ return (entry.title.match(regex) ||
90
+ entry.name.match(regex) ||
91
+ entry.docgenInfo.some((info) => {
92
+ if (info.displayName.match(regex)) {
93
+ return true;
94
+ }
95
+ if (info.description?.match(regex)) {
96
+ return true;
97
+ }
98
+ return false;
99
+ }));
100
+ };
101
+ if (Array.isArray(query)) {
102
+ return inf.filter((entry) => query.some((q) => isMatch(entry, q)));
103
+ }
104
+ return inf.filter((entry) => isMatch(entry, query));
105
+ }
106
+ async loadStorybookData() {
107
+ // storybook-static/index.jsonを読み込む
108
+ console.debug("Loading storybook data...");
109
+ const content = await fs.readFile(path.resolve(this.storybookRoot, "storybook-static", "index.json"), "utf-8");
110
+ const data = JSON.parse(content);
111
+ console.debug("Loaded storybook data successfully.");
112
+ if (data.v !== 5) {
113
+ throw new Error("Invalid version (expected 5)");
114
+ }
115
+ const entries = [];
116
+ // entriesの中身を確認する
117
+ for (const entry of Object.values(data.entries)) {
118
+ // typeがstoryでないものは無視する
119
+ if (entry.type !== "story") {
120
+ continue;
121
+ }
122
+ // idがないものは無視する
123
+ if (!entry.id) {
124
+ continue;
125
+ }
126
+ // nameがないものは無視する
127
+ if (!entry.name) {
128
+ continue;
129
+ }
130
+ // titleがないものは無視する
131
+ if (!entry.title) {
132
+ continue;
133
+ }
134
+ // componentPathがないものは無視する
135
+ if (!entry.componentPath) {
136
+ continue;
137
+ }
138
+ // importPathがないものは無視する
139
+ if (!entry.importPath) {
140
+ continue;
141
+ }
142
+ entries.push(entry);
143
+ }
144
+ console.debug(`Found ${entries.length} entries.`);
145
+ {
146
+ const chunks = Array.from(new Set(entries.map((entry) => entry.componentPath))).reduce((r, e) => {
147
+ const last = r[r.length - 1];
148
+ if (last.length < this.CHUNK_SIZE) {
149
+ last.push(e);
150
+ }
151
+ else {
152
+ r.push([e]);
153
+ }
154
+ return r;
155
+ }, [[]]);
156
+ for await (const chunk of chunks) {
157
+ const docgenInfos = {};
158
+ // docgenInfoを取得する
159
+ const results = await Promise.allSettled(chunk.map((componentPath) => this.getComponentInfo(componentPath)));
160
+ for (let i = 0; i < results.length; i++) {
161
+ const result = results[i];
162
+ if (result.status === "fulfilled") {
163
+ docgenInfos[chunk[i]] = result.value;
164
+ }
165
+ else {
166
+ console.error(`Failed to get component info for ${chunk[i]}:`, result.reason);
167
+ }
168
+ }
169
+ // componentInformationを作成する(随時更新)
170
+ for (const entry of entries) {
171
+ for (const componentPath of chunk) {
172
+ if (entry.componentPath === componentPath) {
173
+ this.componentInformation.push({
174
+ title: entry.title,
175
+ id: entry.id,
176
+ name: entry.name,
177
+ importPath: entry.importPath,
178
+ componentPath: entry.componentPath,
179
+ docgenInfo: docgenInfos[entry.componentPath] || [],
180
+ });
181
+ }
182
+ }
183
+ }
184
+ }
185
+ }
186
+ // for await (const entry of entries) {
187
+ // if (docgenInfos[entry.componentPath]) {
188
+ // // すでに取得済みの場合はスキップ
189
+ // continue;
190
+ // }
191
+ // try {
192
+ // console.debug(`Getting component info for ${entry.componentPath}...`);
193
+ // const infos = await this.getComponentInfo(entry.componentPath);
194
+ // if (infos.length > 0) {
195
+ // console.debug(`Got ${infos.length} component info for ${entry.componentPath}.`);
196
+ // docgenInfos[entry.componentPath] = infos;
197
+ // }
198
+ // } catch (error) {
199
+ // console.error(`Failed to get component info for ${entry.componentPath}:`, error);
200
+ // }
201
+ // }
202
+ // componentInformationを作成する
203
+ // for (const entry of entries) {
204
+ // this.componentInformation.push({
205
+ // id: entry.id,
206
+ // name: entry.name,
207
+ // title: entry.title,
208
+ // importPath: entry.importPath,
209
+ // componentPath: entry.componentPath,
210
+ // docgenInfo: docgenInfos[entry.componentPath] || [],
211
+ // });
212
+ // }
213
+ console.debug("Component information loaded successfully.");
214
+ }
215
+ // npx -y @react-docgen/cli --resolver find-all-exported-components を実行して標準出力からコンポーネントの情報を取得する
216
+ async getComponentInfo(importPath) {
217
+ const regex = /\.[jt]sx$/;
218
+ const argImportPath = regex.test(importPath)
219
+ ? importPath
220
+ : `${importPath}/index.[jt]sx`;
221
+ return new Promise((resolve, reject) => {
222
+ console.debug(`Executing command to get component info for ${argImportPath}...`);
223
+ exec(`npx -y @react-docgen/cli --resolver find-all-exported-components '${argImportPath}'`, {
224
+ cwd: this.storybookRoot,
225
+ env: { ...process.env, NO_COLOR: "true" },
226
+ }, (error, stdout, _stderr) => {
227
+ if (error) {
228
+ reject(error);
229
+ return;
230
+ }
231
+ if (stdout === "") {
232
+ resolve([]);
233
+ return;
234
+ }
235
+ const data = JSON.parse(stdout);
236
+ resolve(Object.values(data).flat());
237
+ });
238
+ });
239
+ }
240
+ }
241
+ // --- サーバー起動 ---
242
+ async function main() {
243
+ const storybookRoot = process.env.STORYBOOK_ROOT;
244
+ if (!storybookRoot) {
245
+ console.error("STORYBOOK_ROOT environment variable is not set.");
246
+ process.exit(1);
247
+ }
248
+ const absStorybookRoot = path.resolve(storybookRoot);
249
+ await new Storybook(absStorybookRoot).run();
250
+ }
251
+ main().catch((error) => {
252
+ console.error("Error starting server:", error);
253
+ process.exit(1);
254
+ });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@miyucy/storybook-mcp",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "dist/index.js",
6
+ "scripts": {
7
+ "build": "rm -rf dist && tsc",
8
+ "start": "node dist/index.js"
9
+ },
10
+ "author": "miyucy",
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "license": "MIT",
15
+ "files": [
16
+ "dist",
17
+ "package.json"
18
+ ],
19
+ "type": "module",
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "1.10.1",
22
+ "zod": "3.24.3"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "22.14.1",
26
+ "typescript": "5.8.3"
27
+ },
28
+ "bin": "dist/index.js"
29
+ }