@tpitre/story-ui 3.2.0 → 3.3.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.
- package/dist/cli/index.js +0 -29
- package/dist/mcp-server/index.js +20 -65
- package/dist/mcp-server/mcp-stdio-server.js +40 -109
- package/dist/mcp-server/routes/generateStory.d.ts.map +1 -1
- package/dist/mcp-server/routes/generateStory.js +46 -117
- package/dist/mcp-server/routes/generateStoryStream.d.ts.map +1 -1
- package/dist/mcp-server/routes/generateStoryStream.js +30 -72
- package/dist/mcp-server/routes/mcpRemote.d.ts +7 -3
- package/dist/mcp-server/routes/mcpRemote.d.ts.map +1 -1
- package/dist/mcp-server/routes/mcpRemote.js +353 -254
- package/dist/story-generator/generateStory.d.ts.map +1 -1
- package/dist/story-generator/generateStory.js +25 -0
- package/package.json +2 -2
- package/dist/mcp-server/routes/hybridStories.d.ts +0 -18
- package/dist/mcp-server/routes/hybridStories.d.ts.map +0 -1
- package/dist/mcp-server/routes/hybridStories.js +0 -216
- package/dist/mcp-server/routes/memoryStories.d.ts +0 -26
- package/dist/mcp-server/routes/memoryStories.d.ts.map +0 -1
- package/dist/mcp-server/routes/memoryStories.js +0 -158
- package/dist/mcp-server/routes/storySync.d.ts +0 -26
- package/dist/mcp-server/routes/storySync.d.ts.map +0 -1
- package/dist/mcp-server/routes/storySync.js +0 -147
- package/dist/mcp-server/sessionManager.d.ts +0 -50
- package/dist/mcp-server/sessionManager.d.ts.map +0 -1
- package/dist/mcp-server/sessionManager.js +0 -125
- package/dist/story-generator/inMemoryStoryService.d.ts +0 -89
- package/dist/story-generator/inMemoryStoryService.d.ts.map +0 -1
- package/dist/story-generator/inMemoryStoryService.js +0 -128
- package/dist/story-generator/postgresStoryService.d.ts +0 -56
- package/dist/story-generator/postgresStoryService.d.ts.map +0 -1
- package/dist/story-generator/postgresStoryService.js +0 -240
- package/dist/story-generator/productionGitignoreManager.d.ts +0 -91
- package/dist/story-generator/productionGitignoreManager.d.ts.map +0 -1
- package/dist/story-generator/productionGitignoreManager.js +0 -340
- package/dist/story-generator/storyServiceFactory.d.ts +0 -22
- package/dist/story-generator/storyServiceFactory.d.ts.map +0 -1
- package/dist/story-generator/storyServiceFactory.js +0 -97
- package/dist/story-generator/storyServiceInterface.d.ts +0 -85
- package/dist/story-generator/storyServiceInterface.d.ts.map +0 -1
- package/dist/story-generator/storyServiceInterface.js +0 -5
- package/dist/story-generator/storySync.d.ts +0 -68
- package/dist/story-generator/storySync.d.ts.map +0 -1
- package/dist/story-generator/storySync.js +0 -201
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP Remote HTTP Transport Routes
|
|
3
3
|
*
|
|
4
|
-
* Implements
|
|
5
|
-
* remote connections from Claude Desktop
|
|
4
|
+
* Implements both Streamable HTTP (modern) and SSE (legacy) transports
|
|
5
|
+
* from the MCP SDK to enable remote connections from Claude Desktop
|
|
6
|
+
* and other MCP clients.
|
|
7
|
+
*
|
|
8
|
+
* - Streamable HTTP: Single POST endpoint at /mcp (recommended for Claude Desktop)
|
|
9
|
+
* - SSE: GET /sse + POST /messages (legacy, kept for backwards compatibility)
|
|
6
10
|
*
|
|
7
11
|
* This allows Story UI to be accessed from Claude Desktop without requiring
|
|
8
12
|
* a local process - useful for cloud deployments and shared Storybook instances.
|
|
9
13
|
*
|
|
10
|
-
* Uses
|
|
14
|
+
* Uses MCP SDK v1.23.0+ with Streamable HTTP transport
|
|
11
15
|
*/
|
|
12
16
|
import { Router } from 'express';
|
|
13
17
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
18
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
14
19
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
15
20
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
16
21
|
import fs from 'fs';
|
|
@@ -34,9 +39,9 @@ const HTTP_BASE_URL = `http://localhost:${HTTP_PORT}`;
|
|
|
34
39
|
// Load configuration
|
|
35
40
|
const config = loadUserConfig();
|
|
36
41
|
export const router = Router();
|
|
37
|
-
// Store SSE transports for session management
|
|
42
|
+
// Store SSE transports for legacy session management
|
|
38
43
|
const sseTransports = {};
|
|
39
|
-
// Define available tools
|
|
44
|
+
// Define available tools with JSON Schema (avoids Zod type recursion issues)
|
|
40
45
|
const TOOLS = [
|
|
41
46
|
{
|
|
42
47
|
name: 'test-connection',
|
|
@@ -146,8 +151,264 @@ const TOOLS = [
|
|
|
146
151
|
},
|
|
147
152
|
},
|
|
148
153
|
];
|
|
154
|
+
/**
|
|
155
|
+
* Handle tool execution
|
|
156
|
+
*/
|
|
157
|
+
async function handleToolCall(name, args) {
|
|
158
|
+
switch (name) {
|
|
159
|
+
case 'test-connection': {
|
|
160
|
+
return {
|
|
161
|
+
content: [{
|
|
162
|
+
type: 'text',
|
|
163
|
+
text: 'MCP remote connection is working! Story UI is connected via Streamable HTTP.',
|
|
164
|
+
}],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
case 'generate-story': {
|
|
168
|
+
const { prompt, chatId } = args;
|
|
169
|
+
const response = await fetch(`${HTTP_BASE_URL}/mcp/generate-story`, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { 'Content-Type': 'application/json' },
|
|
172
|
+
body: JSON.stringify({ prompt, chatId }),
|
|
173
|
+
});
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
const error = await response.text();
|
|
176
|
+
throw new Error(`Failed to generate story: ${error}`);
|
|
177
|
+
}
|
|
178
|
+
const result = await response.json();
|
|
179
|
+
return {
|
|
180
|
+
content: [{
|
|
181
|
+
type: 'text',
|
|
182
|
+
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}`,
|
|
183
|
+
}],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
case 'list-components': {
|
|
187
|
+
const { category } = args;
|
|
188
|
+
const discovery = new EnhancedComponentDiscovery(config);
|
|
189
|
+
const components = await discovery.discoverAll();
|
|
190
|
+
let filteredComponents = components;
|
|
191
|
+
if (category) {
|
|
192
|
+
filteredComponents = components.filter(comp => comp.category?.toLowerCase() === category.toLowerCase());
|
|
193
|
+
}
|
|
194
|
+
const maxComponents = 50;
|
|
195
|
+
const displayComponents = filteredComponents.slice(0, maxComponents);
|
|
196
|
+
const componentList = displayComponents.map(comp => `- ${comp.name} (${comp.category || 'Uncategorized'})`).join('\n');
|
|
197
|
+
const responseText = filteredComponents.length > maxComponents
|
|
198
|
+
? `Found ${filteredComponents.length} components (showing first ${maxComponents}):\n\n${componentList}\n\n...and ${filteredComponents.length - maxComponents} more components`
|
|
199
|
+
: `Found ${filteredComponents.length} components:\n\n${componentList}`;
|
|
200
|
+
return {
|
|
201
|
+
content: [{ type: 'text', text: responseText }],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
case 'list-stories': {
|
|
205
|
+
let fileStories = [];
|
|
206
|
+
if (config.generatedStoriesPath && fs.existsSync(config.generatedStoriesPath)) {
|
|
207
|
+
const files = fs.readdirSync(config.generatedStoriesPath);
|
|
208
|
+
fileStories = files
|
|
209
|
+
.filter(file => file.endsWith('.stories.tsx'))
|
|
210
|
+
.map(file => {
|
|
211
|
+
const hash = file.match(/-([a-f0-9]{8})\.stories\.tsx$/)?.[1] || '';
|
|
212
|
+
const storyId = hash ? `story-${hash}` : file.replace('.stories.tsx', '');
|
|
213
|
+
let title = file.replace('.stories.tsx', '').replace(/-/g, ' ');
|
|
214
|
+
try {
|
|
215
|
+
const filePath = path.join(config.generatedStoriesPath, file);
|
|
216
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
217
|
+
const titleMatch = content.match(/title:\s*['"]([^'"]+)['"]/);
|
|
218
|
+
if (titleMatch) {
|
|
219
|
+
title = titleMatch[1].replace('Generated/', '');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Use filename as fallback
|
|
224
|
+
}
|
|
225
|
+
return { id: storyId, fileName: file, title };
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
if (fileStories.length === 0) {
|
|
229
|
+
return {
|
|
230
|
+
content: [{
|
|
231
|
+
type: 'text',
|
|
232
|
+
text: 'No stories have been generated yet.\n\nGenerate your first story by describing what UI component you\'d like to create!',
|
|
233
|
+
}],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
let responseText = `**Available stories (${fileStories.length}):**\n`;
|
|
237
|
+
fileStories.forEach(story => {
|
|
238
|
+
responseText += `\n- ${story.title}\n ID: ${story.id}\n File: ${story.fileName}\n`;
|
|
239
|
+
});
|
|
240
|
+
return { content: [{ type: 'text', text: responseText }] };
|
|
241
|
+
}
|
|
242
|
+
case 'get-story': {
|
|
243
|
+
const { storyId } = args;
|
|
244
|
+
if (config.generatedStoriesPath && fs.existsSync(config.generatedStoriesPath)) {
|
|
245
|
+
const files = fs.readdirSync(config.generatedStoriesPath);
|
|
246
|
+
const hashMatch = storyId.match(/^story-([a-f0-9]{8})$/);
|
|
247
|
+
const hash = hashMatch ? hashMatch[1] : null;
|
|
248
|
+
const matchingFile = files.find(file => {
|
|
249
|
+
if (hash && file.includes(`-${hash}.stories.tsx`))
|
|
250
|
+
return true;
|
|
251
|
+
if (file === `${storyId}.stories.tsx`)
|
|
252
|
+
return true;
|
|
253
|
+
if (file === storyId)
|
|
254
|
+
return true;
|
|
255
|
+
return false;
|
|
256
|
+
});
|
|
257
|
+
if (matchingFile) {
|
|
258
|
+
const filePath = path.join(config.generatedStoriesPath, matchingFile);
|
|
259
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
260
|
+
const titleMatch = content.match(/title:\s*['"]([^'"]+)['"]/);
|
|
261
|
+
const title = titleMatch ? titleMatch[1] : 'Untitled';
|
|
262
|
+
return {
|
|
263
|
+
content: [{
|
|
264
|
+
type: 'text',
|
|
265
|
+
text: `# ${title}\n\nID: ${storyId}\nFile: ${matchingFile}\n\n## Story Code:\n\`\`\`tsx\n${content}\n\`\`\``,
|
|
266
|
+
}],
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
throw new Error(`Story with ID ${storyId} not found`);
|
|
271
|
+
}
|
|
272
|
+
case 'delete-story': {
|
|
273
|
+
const { storyId } = args;
|
|
274
|
+
if (config.generatedStoriesPath && fs.existsSync(config.generatedStoriesPath)) {
|
|
275
|
+
const files = fs.readdirSync(config.generatedStoriesPath);
|
|
276
|
+
const hashMatch = storyId.match(/^story-([a-f0-9]{8})$/);
|
|
277
|
+
const hash = hashMatch ? hashMatch[1] : null;
|
|
278
|
+
const matchingFile = files.find(file => {
|
|
279
|
+
if (hash && file.includes(`-${hash}.stories.tsx`))
|
|
280
|
+
return true;
|
|
281
|
+
if (file === `${storyId}.stories.tsx`)
|
|
282
|
+
return true;
|
|
283
|
+
if (file === storyId)
|
|
284
|
+
return true;
|
|
285
|
+
return false;
|
|
286
|
+
});
|
|
287
|
+
if (matchingFile) {
|
|
288
|
+
const filePath = path.join(config.generatedStoriesPath, matchingFile);
|
|
289
|
+
fs.unlinkSync(filePath);
|
|
290
|
+
return {
|
|
291
|
+
content: [{
|
|
292
|
+
type: 'text',
|
|
293
|
+
text: `Story "${matchingFile}" has been deleted successfully.`,
|
|
294
|
+
}],
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
throw new Error(`Story not found in file system`);
|
|
299
|
+
}
|
|
300
|
+
case 'get-component-props': {
|
|
301
|
+
const { componentName } = args;
|
|
302
|
+
const response = await fetch(`${HTTP_BASE_URL}/mcp/props?component=${encodeURIComponent(componentName)}`);
|
|
303
|
+
if (!response.ok) {
|
|
304
|
+
throw new Error(`Failed to get component props: ${response.statusText}`);
|
|
305
|
+
}
|
|
306
|
+
const props = await response.json();
|
|
307
|
+
if (!props || Object.keys(props).length === 0) {
|
|
308
|
+
return {
|
|
309
|
+
content: [{
|
|
310
|
+
type: 'text',
|
|
311
|
+
text: `No prop information found for component ${componentName}.`,
|
|
312
|
+
}],
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const propsList = Object.entries(props).map(([propName, info]) => `- ${propName}: ${info.type} ${info.required ? '(required)' : '(optional)'}${info.description ? ` - ${info.description}` : ''}`).join('\n');
|
|
316
|
+
return {
|
|
317
|
+
content: [{
|
|
318
|
+
type: 'text',
|
|
319
|
+
text: `Props for ${componentName}:\n\n${propsList}`,
|
|
320
|
+
}],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
case 'update-story': {
|
|
324
|
+
let { storyId, prompt } = args;
|
|
325
|
+
// Find the story to update if no ID provided
|
|
326
|
+
if (!storyId && config.generatedStoriesPath && fs.existsSync(config.generatedStoriesPath)) {
|
|
327
|
+
const files = fs.readdirSync(config.generatedStoriesPath)
|
|
328
|
+
.filter(f => f.endsWith('.stories.tsx'))
|
|
329
|
+
.sort((a, b) => {
|
|
330
|
+
const statA = fs.statSync(path.join(config.generatedStoriesPath, a));
|
|
331
|
+
const statB = fs.statSync(path.join(config.generatedStoriesPath, b));
|
|
332
|
+
return statB.mtime.getTime() - statA.mtime.getTime();
|
|
333
|
+
});
|
|
334
|
+
if (files.length > 0) {
|
|
335
|
+
const hash = files[0].match(/-([a-f0-9]{8})\.stories\.tsx$/)?.[1];
|
|
336
|
+
storyId = hash ? `story-${hash}` : files[0].replace('.stories.tsx', '');
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (!storyId) {
|
|
340
|
+
return {
|
|
341
|
+
content: [{
|
|
342
|
+
type: 'text',
|
|
343
|
+
text: 'No story found to update. Please generate a story first or specify which story you\'d like to update.',
|
|
344
|
+
}],
|
|
345
|
+
isError: true,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
// Get existing story content
|
|
349
|
+
let existingCode = '';
|
|
350
|
+
let storyMetadata = {};
|
|
351
|
+
if (config.generatedStoriesPath && fs.existsSync(config.generatedStoriesPath)) {
|
|
352
|
+
const files = fs.readdirSync(config.generatedStoriesPath);
|
|
353
|
+
const hashMatch = storyId.match(/^story-([a-f0-9]{8})$/);
|
|
354
|
+
const hash = hashMatch ? hashMatch[1] : null;
|
|
355
|
+
const matchingFile = files.find(file => {
|
|
356
|
+
if (hash && file.includes(`-${hash}.stories.tsx`))
|
|
357
|
+
return true;
|
|
358
|
+
return false;
|
|
359
|
+
});
|
|
360
|
+
if (matchingFile) {
|
|
361
|
+
const filePath = path.join(config.generatedStoriesPath, matchingFile);
|
|
362
|
+
existingCode = fs.readFileSync(filePath, 'utf-8');
|
|
363
|
+
const titleMatch = existingCode.match(/title:\s*['"]([^'"]+)['"]/);
|
|
364
|
+
storyMetadata = {
|
|
365
|
+
fileName: matchingFile,
|
|
366
|
+
title: titleMatch ? titleMatch[1] : 'Untitled',
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const conversation = [
|
|
371
|
+
{ role: 'user', content: storyMetadata.prompt || 'Generate a story' },
|
|
372
|
+
{ role: 'assistant', content: existingCode },
|
|
373
|
+
{ role: 'user', content: prompt },
|
|
374
|
+
];
|
|
375
|
+
const response = await fetch(`${HTTP_BASE_URL}/mcp/generate-story`, {
|
|
376
|
+
method: 'POST',
|
|
377
|
+
headers: { 'Content-Type': 'application/json' },
|
|
378
|
+
body: JSON.stringify({
|
|
379
|
+
prompt,
|
|
380
|
+
fileName: storyMetadata.fileName || storyId,
|
|
381
|
+
conversation,
|
|
382
|
+
isUpdate: true,
|
|
383
|
+
originalTitle: storyMetadata.title,
|
|
384
|
+
storyId,
|
|
385
|
+
}),
|
|
386
|
+
});
|
|
387
|
+
if (!response.ok) {
|
|
388
|
+
const error = await response.text();
|
|
389
|
+
throw new Error(`Failed to update story: ${error}`);
|
|
390
|
+
}
|
|
391
|
+
const result = await response.json();
|
|
392
|
+
return {
|
|
393
|
+
content: [{
|
|
394
|
+
type: 'text',
|
|
395
|
+
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.`,
|
|
396
|
+
}],
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
default:
|
|
400
|
+
return {
|
|
401
|
+
content: [{
|
|
402
|
+
type: 'text',
|
|
403
|
+
text: `Unknown tool: ${name}`,
|
|
404
|
+
}],
|
|
405
|
+
isError: true,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
}
|
|
149
409
|
/**
|
|
150
410
|
* Create a configured MCP server instance with Story UI tools
|
|
411
|
+
* Uses the low-level Server class with request handlers
|
|
151
412
|
*/
|
|
152
413
|
function createMcpServer() {
|
|
153
414
|
const server = new Server({
|
|
@@ -166,242 +427,7 @@ function createMcpServer() {
|
|
|
166
427
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
167
428
|
const { name, arguments: args } = request.params;
|
|
168
429
|
try {
|
|
169
|
-
|
|
170
|
-
case 'test-connection': {
|
|
171
|
-
return {
|
|
172
|
-
content: [{
|
|
173
|
-
type: 'text',
|
|
174
|
-
text: 'MCP remote connection is working! Story UI is connected via HTTP/SSE.',
|
|
175
|
-
}],
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
case 'generate-story': {
|
|
179
|
-
const { prompt, chatId } = args;
|
|
180
|
-
const response = await fetch(`${HTTP_BASE_URL}/mcp/generate-story`, {
|
|
181
|
-
method: 'POST',
|
|
182
|
-
headers: { 'Content-Type': 'application/json' },
|
|
183
|
-
body: JSON.stringify({ prompt, chatId }),
|
|
184
|
-
});
|
|
185
|
-
if (!response.ok) {
|
|
186
|
-
const error = await response.text();
|
|
187
|
-
throw new Error(`Failed to generate story: ${error}`);
|
|
188
|
-
}
|
|
189
|
-
const result = await response.json();
|
|
190
|
-
return {
|
|
191
|
-
content: [{
|
|
192
|
-
type: 'text',
|
|
193
|
-
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}`,
|
|
194
|
-
}],
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
case 'list-components': {
|
|
198
|
-
const { category } = args;
|
|
199
|
-
const discovery = new EnhancedComponentDiscovery(config);
|
|
200
|
-
const components = await discovery.discoverAll();
|
|
201
|
-
let filteredComponents = components;
|
|
202
|
-
if (category) {
|
|
203
|
-
filteredComponents = components.filter(comp => comp.category?.toLowerCase() === category.toLowerCase());
|
|
204
|
-
}
|
|
205
|
-
const maxComponents = 50;
|
|
206
|
-
const displayComponents = filteredComponents.slice(0, maxComponents);
|
|
207
|
-
const componentList = displayComponents.map(comp => `- ${comp.name} (${comp.category || 'Uncategorized'})`).join('\n');
|
|
208
|
-
const responseText = filteredComponents.length > maxComponents
|
|
209
|
-
? `Found ${filteredComponents.length} components (showing first ${maxComponents}):\n\n${componentList}\n\n...and ${filteredComponents.length - maxComponents} more components`
|
|
210
|
-
: `Found ${filteredComponents.length} components:\n\n${componentList}`;
|
|
211
|
-
return {
|
|
212
|
-
content: [{ type: 'text', text: responseText }],
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
case 'list-stories': {
|
|
216
|
-
let fileStories = [];
|
|
217
|
-
if (config.generatedStoriesPath && fs.existsSync(config.generatedStoriesPath)) {
|
|
218
|
-
const files = fs.readdirSync(config.generatedStoriesPath);
|
|
219
|
-
fileStories = files
|
|
220
|
-
.filter(file => file.endsWith('.stories.tsx'))
|
|
221
|
-
.map(file => {
|
|
222
|
-
const hash = file.match(/-([a-f0-9]{8})\.stories\.tsx$/)?.[1] || '';
|
|
223
|
-
const storyId = hash ? `story-${hash}` : file.replace('.stories.tsx', '');
|
|
224
|
-
let title = file.replace('.stories.tsx', '').replace(/-/g, ' ');
|
|
225
|
-
try {
|
|
226
|
-
const filePath = path.join(config.generatedStoriesPath, file);
|
|
227
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
228
|
-
const titleMatch = content.match(/title:\s*['"]([^'"]+)['"]/);
|
|
229
|
-
if (titleMatch) {
|
|
230
|
-
title = titleMatch[1].replace('Generated/', '');
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
catch {
|
|
234
|
-
// Use filename as fallback
|
|
235
|
-
}
|
|
236
|
-
return { id: storyId, fileName: file, title };
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
if (fileStories.length === 0) {
|
|
240
|
-
return {
|
|
241
|
-
content: [{
|
|
242
|
-
type: 'text',
|
|
243
|
-
text: 'No stories have been generated yet.\n\nGenerate your first story by describing what UI component you\'d like to create!',
|
|
244
|
-
}],
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
let responseText = `**Available stories (${fileStories.length}):**\n`;
|
|
248
|
-
fileStories.forEach(story => {
|
|
249
|
-
responseText += `\n- ${story.title}\n ID: ${story.id}\n File: ${story.fileName}\n`;
|
|
250
|
-
});
|
|
251
|
-
return { content: [{ type: 'text', text: responseText }] };
|
|
252
|
-
}
|
|
253
|
-
case 'get-story': {
|
|
254
|
-
const { storyId } = args;
|
|
255
|
-
const response = await fetch(`${HTTP_BASE_URL}/mcp/stories/${storyId}`);
|
|
256
|
-
if (!response.ok) {
|
|
257
|
-
throw new Error(`Story with ID ${storyId} not found`);
|
|
258
|
-
}
|
|
259
|
-
const story = await response.json();
|
|
260
|
-
const contentResponse = await fetch(`${HTTP_BASE_URL}/mcp/stories/${storyId}/content`);
|
|
261
|
-
const content = contentResponse.ok ? await contentResponse.text() : story.content || story.story || 'Content not available';
|
|
262
|
-
return {
|
|
263
|
-
content: [{
|
|
264
|
-
type: 'text',
|
|
265
|
-
text: `# ${story.title || story.fileName || 'Untitled'}\n\nID: ${story.id || story.storyId || storyId}\n\n## Story Code:\n\`\`\`tsx\n${content}\n\`\`\``,
|
|
266
|
-
}],
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
case 'delete-story': {
|
|
270
|
-
const { storyId } = args;
|
|
271
|
-
if (config.generatedStoriesPath && fs.existsSync(config.generatedStoriesPath)) {
|
|
272
|
-
const files = fs.readdirSync(config.generatedStoriesPath);
|
|
273
|
-
const hashMatch = storyId.match(/^story-([a-f0-9]{8})$/);
|
|
274
|
-
const hash = hashMatch ? hashMatch[1] : null;
|
|
275
|
-
const matchingFile = files.find(file => {
|
|
276
|
-
if (hash && file.includes(`-${hash}.stories.tsx`))
|
|
277
|
-
return true;
|
|
278
|
-
if (file === `${storyId}.stories.tsx`)
|
|
279
|
-
return true;
|
|
280
|
-
if (file === storyId)
|
|
281
|
-
return true;
|
|
282
|
-
return false;
|
|
283
|
-
});
|
|
284
|
-
if (matchingFile) {
|
|
285
|
-
const filePath = path.join(config.generatedStoriesPath, matchingFile);
|
|
286
|
-
fs.unlinkSync(filePath);
|
|
287
|
-
return {
|
|
288
|
-
content: [{
|
|
289
|
-
type: 'text',
|
|
290
|
-
text: `Story "${matchingFile}" has been deleted successfully.`,
|
|
291
|
-
}],
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
throw new Error(`Story not found in file system`);
|
|
296
|
-
}
|
|
297
|
-
case 'get-component-props': {
|
|
298
|
-
const { componentName } = args;
|
|
299
|
-
const response = await fetch(`${HTTP_BASE_URL}/mcp/props?component=${encodeURIComponent(componentName)}`);
|
|
300
|
-
if (!response.ok) {
|
|
301
|
-
throw new Error(`Failed to get component props: ${response.statusText}`);
|
|
302
|
-
}
|
|
303
|
-
const props = await response.json();
|
|
304
|
-
if (!props || Object.keys(props).length === 0) {
|
|
305
|
-
return {
|
|
306
|
-
content: [{
|
|
307
|
-
type: 'text',
|
|
308
|
-
text: `No prop information found for component ${componentName}.`,
|
|
309
|
-
}],
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
const propsList = Object.entries(props).map(([propName, info]) => `- ${propName}: ${info.type} ${info.required ? '(required)' : '(optional)'}${info.description ? ` - ${info.description}` : ''}`).join('\n');
|
|
313
|
-
return {
|
|
314
|
-
content: [{
|
|
315
|
-
type: 'text',
|
|
316
|
-
text: `Props for ${componentName}:\n\n${propsList}`,
|
|
317
|
-
}],
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
|
-
case 'update-story': {
|
|
321
|
-
let { storyId, prompt } = args;
|
|
322
|
-
// Find the story to update if no ID provided
|
|
323
|
-
if (!storyId && config.generatedStoriesPath && fs.existsSync(config.generatedStoriesPath)) {
|
|
324
|
-
const files = fs.readdirSync(config.generatedStoriesPath)
|
|
325
|
-
.filter(f => f.endsWith('.stories.tsx'))
|
|
326
|
-
.sort((a, b) => {
|
|
327
|
-
const statA = fs.statSync(path.join(config.generatedStoriesPath, a));
|
|
328
|
-
const statB = fs.statSync(path.join(config.generatedStoriesPath, b));
|
|
329
|
-
return statB.mtime.getTime() - statA.mtime.getTime();
|
|
330
|
-
});
|
|
331
|
-
if (files.length > 0) {
|
|
332
|
-
const hash = files[0].match(/-([a-f0-9]{8})\.stories\.tsx$/)?.[1];
|
|
333
|
-
storyId = hash ? `story-${hash}` : files[0].replace('.stories.tsx', '');
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
if (!storyId) {
|
|
337
|
-
return {
|
|
338
|
-
content: [{
|
|
339
|
-
type: 'text',
|
|
340
|
-
text: 'No story found to update. Please generate a story first or specify which story you\'d like to update.',
|
|
341
|
-
}],
|
|
342
|
-
isError: true,
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
// Get existing story content
|
|
346
|
-
let existingCode = '';
|
|
347
|
-
let storyMetadata = {};
|
|
348
|
-
if (config.generatedStoriesPath && fs.existsSync(config.generatedStoriesPath)) {
|
|
349
|
-
const files = fs.readdirSync(config.generatedStoriesPath);
|
|
350
|
-
const hashMatch = storyId.match(/^story-([a-f0-9]{8})$/);
|
|
351
|
-
const hash = hashMatch ? hashMatch[1] : null;
|
|
352
|
-
const matchingFile = files.find(file => {
|
|
353
|
-
if (hash && file.includes(`-${hash}.stories.tsx`))
|
|
354
|
-
return true;
|
|
355
|
-
return false;
|
|
356
|
-
});
|
|
357
|
-
if (matchingFile) {
|
|
358
|
-
const filePath = path.join(config.generatedStoriesPath, matchingFile);
|
|
359
|
-
existingCode = fs.readFileSync(filePath, 'utf-8');
|
|
360
|
-
const titleMatch = existingCode.match(/title:\s*['"]([^'"]+)['"]/);
|
|
361
|
-
storyMetadata = {
|
|
362
|
-
fileName: matchingFile,
|
|
363
|
-
title: titleMatch ? titleMatch[1] : 'Untitled',
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
const conversation = [
|
|
368
|
-
{ role: 'user', content: storyMetadata.prompt || 'Generate a story' },
|
|
369
|
-
{ role: 'assistant', content: existingCode },
|
|
370
|
-
{ role: 'user', content: prompt },
|
|
371
|
-
];
|
|
372
|
-
const response = await fetch(`${HTTP_BASE_URL}/mcp/generate-story`, {
|
|
373
|
-
method: 'POST',
|
|
374
|
-
headers: { 'Content-Type': 'application/json' },
|
|
375
|
-
body: JSON.stringify({
|
|
376
|
-
prompt,
|
|
377
|
-
fileName: storyMetadata.fileName || storyId,
|
|
378
|
-
conversation,
|
|
379
|
-
isUpdate: true,
|
|
380
|
-
originalTitle: storyMetadata.title,
|
|
381
|
-
storyId,
|
|
382
|
-
}),
|
|
383
|
-
});
|
|
384
|
-
if (!response.ok) {
|
|
385
|
-
const error = await response.text();
|
|
386
|
-
throw new Error(`Failed to update story: ${error}`);
|
|
387
|
-
}
|
|
388
|
-
const result = await response.json();
|
|
389
|
-
return {
|
|
390
|
-
content: [{
|
|
391
|
-
type: 'text',
|
|
392
|
-
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.`,
|
|
393
|
-
}],
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
default:
|
|
397
|
-
return {
|
|
398
|
-
content: [{
|
|
399
|
-
type: 'text',
|
|
400
|
-
text: `Unknown tool: ${name}`,
|
|
401
|
-
}],
|
|
402
|
-
isError: true,
|
|
403
|
-
};
|
|
404
|
-
}
|
|
430
|
+
return await handleToolCall(name, args);
|
|
405
431
|
}
|
|
406
432
|
catch (error) {
|
|
407
433
|
return {
|
|
@@ -416,12 +442,69 @@ function createMcpServer() {
|
|
|
416
442
|
return server;
|
|
417
443
|
}
|
|
418
444
|
/**
|
|
419
|
-
*
|
|
445
|
+
* Streamable HTTP endpoint for MCP (modern transport for Claude Desktop)
|
|
446
|
+
* POST /mcp-remote/mcp
|
|
447
|
+
*
|
|
448
|
+
* This is the recommended endpoint for Claude Desktop connections.
|
|
449
|
+
* Uses stateless mode - each request gets a fresh server and transport
|
|
450
|
+
* to prevent JSON-RPC request ID collisions between different clients.
|
|
451
|
+
*/
|
|
452
|
+
router.post('/mcp', async (req, res) => {
|
|
453
|
+
console.log('[MCP Remote] Streamable HTTP request received');
|
|
454
|
+
try {
|
|
455
|
+
// Create new server and transport for each request (stateless mode)
|
|
456
|
+
const server = createMcpServer();
|
|
457
|
+
const transport = new StreamableHTTPServerTransport({
|
|
458
|
+
sessionIdGenerator: undefined, // Stateless mode
|
|
459
|
+
});
|
|
460
|
+
// Clean up transport when response closes
|
|
461
|
+
res.on('close', () => {
|
|
462
|
+
transport.close();
|
|
463
|
+
});
|
|
464
|
+
// Connect server to this transport and handle the request
|
|
465
|
+
await server.connect(transport);
|
|
466
|
+
await transport.handleRequest(req, res, req.body);
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
console.error('[MCP Remote] Error handling Streamable HTTP request:', error);
|
|
470
|
+
if (!res.headersSent) {
|
|
471
|
+
res.status(500).json({
|
|
472
|
+
jsonrpc: '2.0',
|
|
473
|
+
error: {
|
|
474
|
+
code: -32603,
|
|
475
|
+
message: 'Internal server error',
|
|
476
|
+
},
|
|
477
|
+
id: null,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
/**
|
|
483
|
+
* Also handle GET and DELETE for the /mcp endpoint
|
|
484
|
+
* Some MCP clients may probe with GET first
|
|
485
|
+
*/
|
|
486
|
+
router.get('/mcp', (req, res) => {
|
|
487
|
+
res.json({
|
|
488
|
+
jsonrpc: '2.0',
|
|
489
|
+
result: {
|
|
490
|
+
name: 'story-ui-remote',
|
|
491
|
+
version: PACKAGE_VERSION,
|
|
492
|
+
transport: 'streamable-http',
|
|
493
|
+
description: 'Story UI MCP Server - Use POST for MCP requests',
|
|
494
|
+
},
|
|
495
|
+
id: null,
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
/**
|
|
499
|
+
* Legacy SSE endpoint for establishing the MCP connection
|
|
420
500
|
* GET /mcp-remote/sse
|
|
501
|
+
*
|
|
502
|
+
* Kept for backwards compatibility with older clients.
|
|
503
|
+
* New clients should use the Streamable HTTP endpoint at /mcp-remote/mcp
|
|
421
504
|
*/
|
|
422
505
|
router.get('/sse', async (req, res) => {
|
|
423
506
|
try {
|
|
424
|
-
console.log('[MCP Remote] New SSE connection request');
|
|
507
|
+
console.log('[MCP Remote] New SSE connection request (legacy)');
|
|
425
508
|
// Create SSE transport
|
|
426
509
|
const transport = new SSEServerTransport('/mcp-remote/messages', res);
|
|
427
510
|
sseTransports[transport.sessionId] = transport;
|
|
@@ -431,10 +514,9 @@ router.get('/sse', async (req, res) => {
|
|
|
431
514
|
delete sseTransports[transport.sessionId];
|
|
432
515
|
console.log(`[MCP Remote] SSE session closed: ${transport.sessionId}`);
|
|
433
516
|
});
|
|
434
|
-
// Create
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
await server.connect(transport);
|
|
517
|
+
// Create a new server for this SSE session and connect
|
|
518
|
+
const legacyServer = createMcpServer();
|
|
519
|
+
await legacyServer.connect(transport);
|
|
438
520
|
}
|
|
439
521
|
catch (error) {
|
|
440
522
|
console.error('[MCP Remote] Error creating SSE session:', error);
|
|
@@ -444,7 +526,7 @@ router.get('/sse', async (req, res) => {
|
|
|
444
526
|
}
|
|
445
527
|
});
|
|
446
528
|
/**
|
|
447
|
-
*
|
|
529
|
+
* Legacy message endpoint for SSE transport
|
|
448
530
|
* POST /mcp-remote/messages
|
|
449
531
|
*/
|
|
450
532
|
router.post('/messages', async (req, res) => {
|
|
@@ -459,7 +541,6 @@ router.post('/messages', async (req, res) => {
|
|
|
459
541
|
return;
|
|
460
542
|
}
|
|
461
543
|
try {
|
|
462
|
-
// Use the raw node request/response for handlePostMessage
|
|
463
544
|
await transport.handlePostMessage(req, res);
|
|
464
545
|
}
|
|
465
546
|
catch (error) {
|
|
@@ -478,12 +559,30 @@ router.get('/health', (req, res) => {
|
|
|
478
559
|
status: 'ok',
|
|
479
560
|
service: 'story-ui-mcp-remote',
|
|
480
561
|
version: PACKAGE_VERSION,
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
562
|
+
transports: {
|
|
563
|
+
streamableHttp: {
|
|
564
|
+
enabled: true,
|
|
565
|
+
endpoint: '/mcp-remote/mcp',
|
|
566
|
+
description: 'Modern transport for Claude Desktop (recommended)',
|
|
567
|
+
},
|
|
568
|
+
sse: {
|
|
569
|
+
enabled: true,
|
|
570
|
+
endpoint: '/mcp-remote/sse',
|
|
571
|
+
messagesEndpoint: '/mcp-remote/messages',
|
|
572
|
+
description: 'Legacy transport (deprecated)',
|
|
573
|
+
activeSessions: Object.keys(sseTransports).length,
|
|
574
|
+
},
|
|
486
575
|
},
|
|
576
|
+
tools: [
|
|
577
|
+
'test-connection',
|
|
578
|
+
'generate-story',
|
|
579
|
+
'list-components',
|
|
580
|
+
'list-stories',
|
|
581
|
+
'get-story',
|
|
582
|
+
'delete-story',
|
|
583
|
+
'get-component-props',
|
|
584
|
+
'update-story',
|
|
585
|
+
],
|
|
487
586
|
});
|
|
488
587
|
});
|
|
489
588
|
export default router;
|