@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.
Files changed (43) hide show
  1. package/dist/cli/index.js +0 -29
  2. package/dist/mcp-server/index.js +20 -65
  3. package/dist/mcp-server/mcp-stdio-server.js +40 -109
  4. package/dist/mcp-server/routes/generateStory.d.ts.map +1 -1
  5. package/dist/mcp-server/routes/generateStory.js +46 -117
  6. package/dist/mcp-server/routes/generateStoryStream.d.ts.map +1 -1
  7. package/dist/mcp-server/routes/generateStoryStream.js +30 -72
  8. package/dist/mcp-server/routes/mcpRemote.d.ts +7 -3
  9. package/dist/mcp-server/routes/mcpRemote.d.ts.map +1 -1
  10. package/dist/mcp-server/routes/mcpRemote.js +353 -254
  11. package/dist/story-generator/generateStory.d.ts.map +1 -1
  12. package/dist/story-generator/generateStory.js +25 -0
  13. package/package.json +2 -2
  14. package/dist/mcp-server/routes/hybridStories.d.ts +0 -18
  15. package/dist/mcp-server/routes/hybridStories.d.ts.map +0 -1
  16. package/dist/mcp-server/routes/hybridStories.js +0 -216
  17. package/dist/mcp-server/routes/memoryStories.d.ts +0 -26
  18. package/dist/mcp-server/routes/memoryStories.d.ts.map +0 -1
  19. package/dist/mcp-server/routes/memoryStories.js +0 -158
  20. package/dist/mcp-server/routes/storySync.d.ts +0 -26
  21. package/dist/mcp-server/routes/storySync.d.ts.map +0 -1
  22. package/dist/mcp-server/routes/storySync.js +0 -147
  23. package/dist/mcp-server/sessionManager.d.ts +0 -50
  24. package/dist/mcp-server/sessionManager.d.ts.map +0 -1
  25. package/dist/mcp-server/sessionManager.js +0 -125
  26. package/dist/story-generator/inMemoryStoryService.d.ts +0 -89
  27. package/dist/story-generator/inMemoryStoryService.d.ts.map +0 -1
  28. package/dist/story-generator/inMemoryStoryService.js +0 -128
  29. package/dist/story-generator/postgresStoryService.d.ts +0 -56
  30. package/dist/story-generator/postgresStoryService.d.ts.map +0 -1
  31. package/dist/story-generator/postgresStoryService.js +0 -240
  32. package/dist/story-generator/productionGitignoreManager.d.ts +0 -91
  33. package/dist/story-generator/productionGitignoreManager.d.ts.map +0 -1
  34. package/dist/story-generator/productionGitignoreManager.js +0 -340
  35. package/dist/story-generator/storyServiceFactory.d.ts +0 -22
  36. package/dist/story-generator/storyServiceFactory.d.ts.map +0 -1
  37. package/dist/story-generator/storyServiceFactory.js +0 -97
  38. package/dist/story-generator/storyServiceInterface.d.ts +0 -85
  39. package/dist/story-generator/storyServiceInterface.d.ts.map +0 -1
  40. package/dist/story-generator/storyServiceInterface.js +0 -5
  41. package/dist/story-generator/storySync.d.ts +0 -68
  42. package/dist/story-generator/storySync.d.ts.map +0 -1
  43. 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 the SSE transport from the MCP SDK to enable
5
- * remote connections from Claude Desktop and other MCP clients.
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 the SSE transport available in MCP SDK v0.5.0
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 - same as the stdio server
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
- switch (name) {
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
- * SSE endpoint for establishing the MCP connection
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 and connect the MCP server
435
- // Note: server.connect() calls transport.start() internally
436
- const server = createMcpServer();
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
- * Message endpoint for SSE transport
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
- transport: 'SSE',
482
- activeSessions: Object.keys(sseTransports).length,
483
- endpoints: {
484
- sse: '/mcp-remote/sse',
485
- messages: '/mcp-remote/messages',
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;