Skip to main content
Glama

MCP Design System Extractor

storybook-client.ts6.73 kB
import { parse } from 'node-html-parser'; import { StorybookIndex, ComponentHTML } from '../types/storybook.js'; import { Cache } from './cache.js'; import { PuppeteerClient } from './puppeteer-client.js'; import { extractStyles, extractClasses } from './html-css-parser.js'; import { createConnectionError, createNotFoundError, createTimeoutError, } from './error-formatter.js'; import { OPERATION_TIMEOUTS, getEnvironmentTimeout } from './timeout-constants.js'; export class StorybookClient { private baseUrl: string; private cache: Cache; private puppeteerClient: PuppeteerClient | null = null; constructor(baseUrl?: string) { this.baseUrl = baseUrl || process.env.STORYBOOK_URL || 'http://localhost:6006'; // Validate URL try { new URL(this.baseUrl); } catch (error) { throw new Error('STORYBOOK_URL must be a valid URL starting with http:// or https://'); } if (!this.baseUrl.startsWith('http')) { throw new Error('STORYBOOK_URL must start with http:// or https://'); } // Remove trailing slash this.baseUrl = this.baseUrl.replace(/\/$/, ''); // Initialize cache this.cache = new Cache(300000); // 5 minutes } getStorybookUrl(): string { return this.baseUrl; } private async getPuppeteerClient(): Promise<PuppeteerClient> { if (!this.puppeteerClient) { this.puppeteerClient = new PuppeteerClient(); await this.puppeteerClient.launch(); } return this.puppeteerClient; } async close(): Promise<void> { if (this.puppeteerClient) { await this.puppeteerClient.close(); this.puppeteerClient = null; } } async fetchStoriesIndex(): Promise<StorybookIndex> { const cacheKey = 'stories-index'; const cached = this.cache.get<StorybookIndex>(cacheKey); if (cached) { return cached; } const urls = [`${this.baseUrl}/index.json`, `${this.baseUrl}/stories.json`]; let lastError: Error | null = null; for (const url of urls) { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); const response = await fetch(url, { signal: controller.signal, }); clearTimeout(timeoutId); if (response.ok) { const data = (await response.json()) as StorybookIndex; this.cache.set(cacheKey, data); return data; } lastError = new Error(`HTTP ${response.status}: ${response.statusText}`); } catch (error: any) { if (error.name === 'AbortError') { lastError = new Error(`Request timeout while fetching ${url}`); } else { lastError = error; } } } throw new Error( `Failed to fetch Storybook index from ${this.baseUrl}. ${lastError?.message || 'Unknown error'}\n` + `Troubleshooting:\n` + `1. Make sure Storybook is running\n` + `2. Verify the URL is correct: ${this.baseUrl}\n` + `3. Check if CORS is properly configured\n` + `4. Try accessing ${this.baseUrl}/index.json directly in your browser` ); } async fetchComponentHTML(storyId: string): Promise<ComponentHTML> { const cacheKey = `component-html-${storyId}`; const cached = this.cache.get<ComponentHTML>(cacheKey); if (cached) { return cached; } const timeoutMs = getEnvironmentTimeout(OPERATION_TIMEOUTS.fetchComponentHTML); try { // First, validate that the story ID exists in the stories index const storiesIndex = await this.fetchStoriesIndex(); const stories = storiesIndex.stories || storiesIndex.entries || {}; if (!stories[storyId]) { const notFoundError = createNotFoundError( 'fetch component HTML', 'story', 'Use list_components or get_component_variants tools to find available stories', storyId ); throw new Error(notFoundError.message); } const url = `${this.baseUrl}/iframe.html?id=${encodeURIComponent(storyId)}`; // Try static HTML parsing first (faster) const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const response = await fetch(url, { signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { const connectionError = createConnectionError( 'fetch component HTML', url, `HTTP ${response.status}: ${response.statusText}`, response.status ); throw new Error(connectionError.message); } const html = await response.text(); // Parse with node-html-parser first to check for static content const root = parse(html); const storyRoot = root.querySelector('#storybook-root'); if ( storyRoot?.innerHTML.trim() && !storyRoot.innerHTML.includes('sb-nopreview') && !storyRoot.innerHTML.includes('No Preview') ) { // Static content found, use it const componentHTML = storyRoot.innerHTML; const styles = extractStyles(root); const classes = extractClasses(componentHTML); const result = { storyId, html: componentHTML, styles, classes, }; this.cache.set(cacheKey, result); return result; } // Static content not available, use Puppeteer for dynamic rendering const puppeteerClient = await this.getPuppeteerClient(); const result = await puppeteerClient.fetchComponentHTML(url, storyId); this.cache.set(cacheKey, result); return result; } catch (error: any) { if (error.name === 'AbortError') { const timeoutError = createTimeoutError( 'fetch component HTML', timeoutMs, `${this.baseUrl}/iframe.html?id=${encodeURIComponent(storyId)}`, `story ${storyId}` ); throw new Error(timeoutError.message); } // Re-throw formatted errors, wrap others if ( error.message.includes('[CONNECTION_ERROR]') || error.message.includes('[NOT_FOUND_ERROR]') || error.message.includes('[TIMEOUT_ERROR]') ) { throw error; } const connectionError = createConnectionError( 'fetch component HTML', `${this.baseUrl}/iframe.html?id=${encodeURIComponent(storyId)}`, error ); throw new Error(connectionError.message); } } async testConnection(): Promise<boolean> { try { const response = await fetch(`${this.baseUrl}/iframe.html`); return response.ok; } catch (error) { return false; } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/freema/mcp-design-system-extractor'

If you have feedback or need assistance with the MCP directory API, please join our Discord server