@tpitre/story-ui 2.1.5 → 2.2.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.
@@ -0,0 +1,631 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import fetch from 'node-fetch';
6
+ import { loadUserConfig } from '../story-generator/configLoader.js';
7
+ import { EnhancedComponentDiscovery } from '../story-generator/enhancedComponentDiscovery.js';
8
+ import { getInMemoryStoryService } from '../story-generator/inMemoryStoryService.js';
9
+ import { SessionManager } from './sessionManager.js';
10
+ import dotenv from 'dotenv';
11
+ import path from 'path';
12
+ import fs from 'fs';
13
+ import crypto from 'crypto';
14
+ // Check for working directory override from environment or command line
15
+ const workingDir = process.env.STORY_UI_CWD || process.argv.find(arg => arg.startsWith('--cwd='))?.split('=')[1];
16
+ if (workingDir) {
17
+ console.error(`Story UI MCP Server: Changing working directory to ${workingDir}`);
18
+ process.chdir(workingDir);
19
+ }
20
+ // Load environment variables
21
+ dotenv.config({ path: path.resolve(process.cwd(), '.env') });
22
+ // Set MCP mode to suppress emojis in logging
23
+ process.env.STORY_UI_MCP_MODE = 'true';
24
+ // Get HTTP server port from environment variables (check multiple possible names)
25
+ const HTTP_PORT = process.env.VITE_STORY_UI_PORT || process.env.STORY_UI_HTTP_PORT || process.env.PORT || '4001';
26
+ const HTTP_BASE_URL = `http://localhost:${HTTP_PORT}`;
27
+ // Initialize configuration
28
+ const config = loadUserConfig();
29
+ const storyService = getInMemoryStoryService(config);
30
+ const sessionManager = SessionManager.getInstance();
31
+ // Generate a session ID for this MCP connection
32
+ const sessionId = crypto.randomBytes(16).toString('hex');
33
+ console.error(`[MCP] Session ID: ${sessionId}`);
34
+ // Create MCP server instance
35
+ const server = new Server({
36
+ name: "story-ui",
37
+ version: "2.1.5",
38
+ }, {
39
+ capabilities: {
40
+ tools: {},
41
+ },
42
+ });
43
+ // Define available tools
44
+ const TOOLS = [
45
+ {
46
+ name: "test-connection",
47
+ description: "Test if MCP connection is working",
48
+ inputSchema: {
49
+ type: "object",
50
+ properties: {},
51
+ },
52
+ },
53
+ {
54
+ name: "generate-story",
55
+ description: "Generate a Storybook story from a natural language prompt",
56
+ inputSchema: {
57
+ type: "object",
58
+ properties: {
59
+ prompt: {
60
+ type: "string",
61
+ description: "The prompt describing what UI to generate",
62
+ },
63
+ chatId: {
64
+ type: "string",
65
+ description: "Optional chat ID for tracking",
66
+ },
67
+ },
68
+ required: ["prompt"],
69
+ },
70
+ },
71
+ {
72
+ name: "list-components",
73
+ description: "List all available components that can be used in stories",
74
+ inputSchema: {
75
+ type: "object",
76
+ properties: {
77
+ category: {
78
+ type: "string",
79
+ description: "Filter by category",
80
+ },
81
+ },
82
+ },
83
+ },
84
+ {
85
+ name: "list-stories",
86
+ description: "List all generated stories",
87
+ inputSchema: {
88
+ type: "object",
89
+ properties: {},
90
+ },
91
+ },
92
+ {
93
+ name: "get-story",
94
+ description: "Get the content of a specific generated story",
95
+ inputSchema: {
96
+ type: "object",
97
+ properties: {
98
+ storyId: {
99
+ type: "string",
100
+ description: "The ID of the story to retrieve",
101
+ },
102
+ },
103
+ required: ["storyId"],
104
+ },
105
+ },
106
+ {
107
+ name: "delete-story",
108
+ description: "Delete a generated story",
109
+ inputSchema: {
110
+ type: "object",
111
+ properties: {
112
+ storyId: {
113
+ type: "string",
114
+ description: "The ID of the story to delete",
115
+ },
116
+ },
117
+ required: ["storyId"],
118
+ },
119
+ },
120
+ {
121
+ name: "get-component-props",
122
+ description: "Get detailed prop information for a specific component",
123
+ inputSchema: {
124
+ type: "object",
125
+ properties: {
126
+ componentName: {
127
+ type: "string",
128
+ description: "The name of the component",
129
+ },
130
+ },
131
+ required: ["componentName"],
132
+ },
133
+ },
134
+ {
135
+ name: "update-story",
136
+ description: "Update an existing Storybook story with modifications. If no storyId is provided, I'll update the most recent story or find the right one based on context.",
137
+ inputSchema: {
138
+ type: "object",
139
+ properties: {
140
+ storyId: {
141
+ type: "string",
142
+ description: "Optional: The ID of the story to update. If not provided, will use context to find the right story.",
143
+ },
144
+ prompt: {
145
+ type: "string",
146
+ description: "Description of the changes to make to the story",
147
+ },
148
+ },
149
+ required: ["prompt"],
150
+ },
151
+ },
152
+ ];
153
+ // Set up request handlers
154
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
155
+ return { tools: TOOLS };
156
+ });
157
+ // Handle tool calls
158
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
159
+ const { name, arguments: args } = request.params;
160
+ try {
161
+ switch (name) {
162
+ case "test-connection": {
163
+ return {
164
+ content: [{
165
+ type: "text",
166
+ text: "MCP connection is working! Story UI is connected and ready."
167
+ }]
168
+ };
169
+ }
170
+ case "generate-story": {
171
+ const { prompt, chatId } = args;
172
+ // Use the HTTP server endpoint to generate the story
173
+ const response = await fetch(`${HTTP_BASE_URL}/mcp/generate-story`, {
174
+ method: 'POST',
175
+ headers: {
176
+ 'Content-Type': 'application/json',
177
+ },
178
+ body: JSON.stringify({ prompt, chatId }),
179
+ });
180
+ if (!response.ok) {
181
+ const error = await response.text();
182
+ throw new Error(`Failed to generate story: ${error}`);
183
+ }
184
+ const result = await response.json();
185
+ // Debug log to see what we're getting
186
+ console.error('Story generation result:', JSON.stringify(result, null, 2));
187
+ // Track the story in session
188
+ if (result.storyId && result.fileName && result.title) {
189
+ sessionManager.trackStory(sessionId, {
190
+ id: result.storyId,
191
+ fileName: result.fileName,
192
+ title: result.title,
193
+ prompt: prompt
194
+ });
195
+ }
196
+ return {
197
+ content: [{
198
+ type: "text",
199
+ text: `Story generated successfully!\n\nTitle: ${result.title || 'Untitled'}\nStory ID: ${result.storyId || 'Unknown'}\nFile Name: ${result.fileName || 'Unknown'}\n\nStory Code:\n\`\`\`tsx\n${result.story || 'Story code not available'}\n\`\`\`\n\nOpen your Storybook instance to see the generated story.\n\nTo update this story later, use the Story ID: ${result.storyId}`
200
+ }]
201
+ };
202
+ }
203
+ case "list-components": {
204
+ const { category } = args;
205
+ try {
206
+ const discovery = new EnhancedComponentDiscovery(config);
207
+ const components = await discovery.discoverAll();
208
+ let filteredComponents = components;
209
+ if (category) {
210
+ filteredComponents = components.filter(comp => comp.category?.toLowerCase() === category.toLowerCase());
211
+ }
212
+ // Limit response size for Claude Desktop
213
+ const maxComponents = 50;
214
+ const displayComponents = filteredComponents.slice(0, maxComponents);
215
+ const componentList = displayComponents.map(comp => `- ${comp.name} (${comp.category || 'Uncategorized'})`).join('\n');
216
+ const responseText = filteredComponents.length > maxComponents
217
+ ? `Found ${filteredComponents.length} components (showing first ${maxComponents}):\n\n${componentList}\n\n...and ${filteredComponents.length - maxComponents} more components`
218
+ : `Found ${filteredComponents.length} components:\n\n${componentList}`;
219
+ return {
220
+ content: [{
221
+ type: "text",
222
+ text: responseText
223
+ }]
224
+ };
225
+ }
226
+ catch (error) {
227
+ console.error('Error in list-components:', error);
228
+ return {
229
+ content: [{
230
+ type: "text",
231
+ text: `Error discovering components: ${error instanceof Error ? error.message : String(error)}`
232
+ }],
233
+ isError: true
234
+ };
235
+ }
236
+ }
237
+ case "list-stories": {
238
+ try {
239
+ // Get session stories
240
+ const sessionStories = sessionManager.getSessionStories(sessionId);
241
+ // Also try to get file system stories
242
+ let fileStories = [];
243
+ if (config.generatedStoriesPath && fs.existsSync(config.generatedStoriesPath)) {
244
+ const files = fs.readdirSync(config.generatedStoriesPath);
245
+ fileStories = files
246
+ .filter(file => file.endsWith('.stories.tsx'))
247
+ .map(file => {
248
+ const hash = file.match(/-([a-f0-9]{8})\.stories\.tsx$/)?.[1] || '';
249
+ const storyId = hash ? `story-${hash}` : file.replace('.stories.tsx', '');
250
+ // Try to read title from file
251
+ let title = file.replace('.stories.tsx', '').replace(/-/g, ' ');
252
+ try {
253
+ const filePath = path.join(config.generatedStoriesPath, file);
254
+ const content = fs.readFileSync(filePath, 'utf-8');
255
+ const titleMatch = content.match(/title:\s*['"]([^'"]+)['"]/);
256
+ if (titleMatch) {
257
+ title = titleMatch[1].replace('Generated/', '');
258
+ }
259
+ }
260
+ catch (e) {
261
+ // Use filename as fallback
262
+ }
263
+ return {
264
+ id: storyId,
265
+ fileName: file,
266
+ title,
267
+ source: 'file-system',
268
+ isInSession: sessionStories.some(s => s.id === storyId)
269
+ };
270
+ });
271
+ }
272
+ if (sessionStories.length === 0 && fileStories.length === 0) {
273
+ return {
274
+ content: [{
275
+ type: "text",
276
+ text: "No stories have been generated yet.\n\nGenerate your first story by describing what UI component you'd like to create!"
277
+ }]
278
+ };
279
+ }
280
+ let responseText = '';
281
+ // Show session stories first
282
+ if (sessionStories.length > 0) {
283
+ responseText += `**Stories in current session:**\n`;
284
+ const currentStory = sessionManager.getCurrentStory(sessionId);
285
+ sessionStories.forEach(story => {
286
+ const isCurrent = currentStory?.id === story.id;
287
+ responseText += `\n${isCurrent ? '→ ' : ' '}${story.title}\n`;
288
+ responseText += ` ID: ${story.id}\n`;
289
+ responseText += ` File: ${story.fileName}\n`;
290
+ if (isCurrent) {
291
+ responseText += ` (Currently discussing this story)\n`;
292
+ }
293
+ });
294
+ }
295
+ // Show other available stories
296
+ const nonSessionFiles = fileStories.filter(f => !f.isInSession);
297
+ if (nonSessionFiles.length > 0) {
298
+ responseText += `\n\n**Other available stories:**\n`;
299
+ nonSessionFiles.forEach(story => {
300
+ responseText += `\n- ${story.title}\n`;
301
+ responseText += ` ID: ${story.id}\n`;
302
+ responseText += ` File: ${story.fileName}\n`;
303
+ });
304
+ }
305
+ responseText += `\n\n**Tips:**\n`;
306
+ responseText += `- To update a story, just describe what changes you want\n`;
307
+ responseText += `- I'll automatically work with the most recent story or find the right one based on context\n`;
308
+ responseText += `- You can also specify a story ID directly if needed`;
309
+ return {
310
+ content: [{
311
+ type: "text",
312
+ text: responseText
313
+ }]
314
+ };
315
+ }
316
+ catch (error) {
317
+ console.error('Error listing stories:', error);
318
+ return {
319
+ content: [{
320
+ type: "text",
321
+ text: `Error listing stories: ${error instanceof Error ? error.message : String(error)}`
322
+ }],
323
+ isError: true
324
+ };
325
+ }
326
+ }
327
+ case "get-story": {
328
+ const { storyId } = args;
329
+ try {
330
+ // First try to get the story metadata
331
+ const response = await fetch(`${HTTP_BASE_URL}/mcp/stories/${storyId}`);
332
+ if (!response.ok) {
333
+ throw new Error(`Story with ID ${storyId} not found`);
334
+ }
335
+ const story = await response.json();
336
+ // Then get the story content
337
+ const contentResponse = await fetch(`${HTTP_BASE_URL}/mcp/stories/${storyId}/content`);
338
+ const content = contentResponse.ok ? await contentResponse.text() : story.content || story.story || 'Content not available';
339
+ return {
340
+ content: [{
341
+ type: "text",
342
+ text: `# ${story.title || story.fileName || 'Untitled'}\n\nID: ${story.id || story.storyId || storyId}\nCreated: ${story.timestamp || story.createdAt ? new Date(story.timestamp || story.createdAt).toLocaleString() : 'Unknown'}\n\n## Story Code:\n\`\`\`tsx\n${content}\n\`\`\``
343
+ }]
344
+ };
345
+ }
346
+ catch (error) {
347
+ console.error('Error getting story:', error);
348
+ return {
349
+ content: [{
350
+ type: "text",
351
+ text: `Story with ID ${storyId} not found or error retrieving it: ${error instanceof Error ? error.message : String(error)}`
352
+ }],
353
+ isError: true
354
+ };
355
+ }
356
+ }
357
+ case "delete-story": {
358
+ const { storyId } = args;
359
+ try {
360
+ // Try to delete from file system directly
361
+ if (config.generatedStoriesPath && fs.existsSync(config.generatedStoriesPath)) {
362
+ const files = fs.readdirSync(config.generatedStoriesPath);
363
+ // Extract hash from story ID
364
+ const hashMatch = storyId.match(/^story-([a-f0-9]{8})$/);
365
+ const hash = hashMatch ? hashMatch[1] : null;
366
+ // Find matching file
367
+ const matchingFile = files.find(file => {
368
+ if (hash && file.includes(`-${hash}.stories.tsx`))
369
+ return true;
370
+ if (file === `${storyId}.stories.tsx`)
371
+ return true;
372
+ if (file === storyId)
373
+ return true;
374
+ return false;
375
+ });
376
+ if (matchingFile) {
377
+ const filePath = path.join(config.generatedStoriesPath, matchingFile);
378
+ fs.unlinkSync(filePath);
379
+ console.error(`[MCP] Deleted story file: ${filePath}`);
380
+ // Also remove from session
381
+ const sessionStories = sessionManager.getSessionStories(sessionId);
382
+ const storyInSession = sessionStories.find(s => s.id === storyId);
383
+ if (storyInSession) {
384
+ // Note: SessionManager doesn't have a removeStory method yet
385
+ // For now, just clear current if it matches
386
+ const current = sessionManager.getCurrentStory(sessionId);
387
+ if (current?.id === storyId) {
388
+ sessionManager.setCurrentStory(sessionId, '');
389
+ }
390
+ }
391
+ return {
392
+ content: [{
393
+ type: "text",
394
+ text: `Story "${matchingFile}" has been deleted successfully.`
395
+ }]
396
+ };
397
+ }
398
+ }
399
+ // Fallback to HTTP endpoint
400
+ const response = await fetch(`${HTTP_BASE_URL}/mcp/stories/${storyId}`, {
401
+ method: 'DELETE'
402
+ });
403
+ if (!response.ok) {
404
+ throw new Error(`Story not found in file system or via HTTP endpoint`);
405
+ }
406
+ return {
407
+ content: [{
408
+ type: "text",
409
+ text: `Story ${storyId} has been deleted successfully.`
410
+ }]
411
+ };
412
+ }
413
+ catch (error) {
414
+ console.error('Error deleting story:', error);
415
+ return {
416
+ content: [{
417
+ type: "text",
418
+ text: `Failed to delete story ${storyId}: ${error instanceof Error ? error.message : String(error)}`
419
+ }],
420
+ isError: true
421
+ };
422
+ }
423
+ }
424
+ case "get-component-props": {
425
+ const { componentName } = args;
426
+ const response = await fetch(`${HTTP_BASE_URL}/mcp/props?component=${encodeURIComponent(componentName)}`);
427
+ if (!response.ok) {
428
+ throw new Error(`Failed to get component props: ${response.statusText}`);
429
+ }
430
+ const props = await response.json();
431
+ if (!props || Object.keys(props).length === 0) {
432
+ return {
433
+ content: [{
434
+ type: "text",
435
+ text: `No prop information found for component ${componentName}.`
436
+ }]
437
+ };
438
+ }
439
+ const propsList = Object.entries(props).map(([name, info]) => `- ${name}: ${info.type} ${info.required ? '(required)' : '(optional)'}${info.description ? ` - ${info.description}` : ''}`).join('\n');
440
+ return {
441
+ content: [{
442
+ type: "text",
443
+ text: `Props for ${componentName}:\n\n${propsList}`
444
+ }]
445
+ };
446
+ }
447
+ case "update-story": {
448
+ let { storyId, prompt } = args;
449
+ try {
450
+ // If no storyId provided, try to find the right story
451
+ if (!storyId) {
452
+ // First check current story in session
453
+ const currentStory = sessionManager.getCurrentStory(sessionId);
454
+ if (currentStory) {
455
+ storyId = currentStory.id;
456
+ console.error(`[MCP] Using current story: ${currentStory.title} (${storyId})`);
457
+ }
458
+ else {
459
+ // Try to find by context
460
+ const contextStory = sessionManager.findStoryByContext(sessionId, prompt);
461
+ if (contextStory) {
462
+ storyId = contextStory.id;
463
+ console.error(`[MCP] Found story by context: ${contextStory.title} (${storyId})`);
464
+ }
465
+ else {
466
+ // Use the most recent story
467
+ const sessionStories = sessionManager.getSessionStories(sessionId);
468
+ if (sessionStories.length > 0) {
469
+ const recentStory = sessionStories[sessionStories.length - 1];
470
+ storyId = recentStory.id;
471
+ console.error(`[MCP] Using most recent story: ${recentStory.title} (${storyId})`);
472
+ }
473
+ else {
474
+ return {
475
+ content: [{
476
+ type: "text",
477
+ text: "No story found to update. Please generate a story first or specify which story you'd like to update."
478
+ }],
479
+ isError: true
480
+ };
481
+ }
482
+ }
483
+ }
484
+ }
485
+ // Try to get story content directly from file system first
486
+ let existingCode = '';
487
+ let storyMetadata = {};
488
+ let foundLocally = false;
489
+ if (config.generatedStoriesPath && fs.existsSync(config.generatedStoriesPath)) {
490
+ const files = fs.readdirSync(config.generatedStoriesPath);
491
+ // Extract hash from story ID
492
+ const hashMatch = storyId.match(/^story-([a-f0-9]{8})$/);
493
+ const hash = hashMatch ? hashMatch[1] : null;
494
+ // Find matching file
495
+ const matchingFile = files.find(file => {
496
+ if (hash && file.includes(`-${hash}.stories.tsx`))
497
+ return true;
498
+ if (file === `${storyId}.stories.tsx`)
499
+ return true;
500
+ return false;
501
+ });
502
+ if (matchingFile) {
503
+ const filePath = path.join(config.generatedStoriesPath, matchingFile);
504
+ existingCode = fs.readFileSync(filePath, 'utf-8');
505
+ // Extract metadata from the story content
506
+ const titleMatch = existingCode.match(/title:\s*['"]([^'"]+)['"]/);
507
+ storyMetadata = {
508
+ fileName: matchingFile,
509
+ title: titleMatch ? titleMatch[1] : 'Untitled', // Keep the full title with prefix
510
+ prompt: prompt // Use the update prompt as context
511
+ };
512
+ foundLocally = true;
513
+ console.error(`[MCP] Found story locally: ${filePath}`);
514
+ }
515
+ }
516
+ // If not found locally, fall back to HTTP endpoint
517
+ if (!foundLocally) {
518
+ console.error(`[MCP] Story not found locally, trying HTTP endpoint`);
519
+ const storyResponse = await fetch(`${HTTP_BASE_URL}/mcp/stories/${storyId}`);
520
+ if (!storyResponse.ok) {
521
+ throw new Error(`Story with ID ${storyId} not found`);
522
+ }
523
+ storyMetadata = await storyResponse.json();
524
+ // Get the actual story content
525
+ const contentResponse = await fetch(`${HTTP_BASE_URL}/mcp/stories/${storyId}/content`);
526
+ if (!contentResponse.ok) {
527
+ throw new Error(`Could not retrieve content for story ${storyId}`);
528
+ }
529
+ existingCode = await contentResponse.text();
530
+ }
531
+ // Build conversation context for the update
532
+ const conversation = [
533
+ {
534
+ role: 'user',
535
+ content: storyMetadata.prompt || 'Generate a story'
536
+ },
537
+ {
538
+ role: 'assistant',
539
+ content: existingCode
540
+ },
541
+ {
542
+ role: 'user',
543
+ content: prompt
544
+ }
545
+ ];
546
+ // Send update request to the generation endpoint
547
+ const response = await fetch(`${HTTP_BASE_URL}/mcp/generate-story`, {
548
+ method: 'POST',
549
+ headers: {
550
+ 'Content-Type': 'application/json',
551
+ },
552
+ body: JSON.stringify({
553
+ prompt,
554
+ fileName: storyMetadata.fileName || storyId,
555
+ conversation,
556
+ isUpdate: true,
557
+ originalTitle: storyMetadata.title,
558
+ storyId: storyId
559
+ }),
560
+ });
561
+ if (!response.ok) {
562
+ const error = await response.text();
563
+ throw new Error(`Failed to update story: ${error}`);
564
+ }
565
+ const result = await response.json();
566
+ // Debug log to see what we're getting
567
+ console.error('Story update result:', JSON.stringify(result, null, 2));
568
+ // Update session tracking with preserved metadata
569
+ if (result.storyId && result.fileName && result.title) {
570
+ sessionManager.trackStory(sessionId, {
571
+ id: result.storyId,
572
+ fileName: result.fileName,
573
+ title: result.title,
574
+ prompt: prompt
575
+ });
576
+ }
577
+ return {
578
+ content: [{
579
+ type: "text",
580
+ text: `Story updated successfully!\n\nTitle: ${result.title || 'Untitled'}\nID: ${result.storyId || result.fileName || 'Unknown'}\n\nUpdated Story Code:\n\`\`\`tsx\n${result.story || 'Story code not available'}\n\`\`\`\n\nThe story has been updated in your Storybook instance.`
581
+ }]
582
+ };
583
+ }
584
+ catch (error) {
585
+ console.error('Error updating story:', error);
586
+ return {
587
+ content: [{
588
+ type: "text",
589
+ text: `Failed to update story: ${error instanceof Error ? error.message : String(error)}`
590
+ }],
591
+ isError: true
592
+ };
593
+ }
594
+ }
595
+ default:
596
+ return {
597
+ content: [{
598
+ type: "text",
599
+ text: `Unknown tool: ${name}`
600
+ }],
601
+ isError: true
602
+ };
603
+ }
604
+ }
605
+ catch (error) {
606
+ return {
607
+ content: [{
608
+ type: "text",
609
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`
610
+ }],
611
+ isError: true
612
+ };
613
+ }
614
+ });
615
+ // Main function to start the server
616
+ async function main() {
617
+ // Log to stderr so it doesn't interfere with stdio communication
618
+ console.error("Story UI MCP Server starting...");
619
+ console.error(`Note: This requires the Story UI HTTP server to be running on port ${HTTP_PORT}`);
620
+ console.error("Run 'story-ui start' in a separate terminal if not already running.\n");
621
+ // Create stdio transport
622
+ const transport = new StdioServerTransport();
623
+ // Connect the server to the transport
624
+ await server.connect(transport);
625
+ console.error("Story UI MCP Server is now running and ready to accept connections.");
626
+ }
627
+ // Run the server
628
+ main().catch((error) => {
629
+ console.error("Failed to start Story UI MCP Server:", error);
630
+ process.exit(1);
631
+ });