UNPKG

@jpisnice/shadcn-ui-mcp-server

Version:

A Model Context Protocol (MCP) server for shadcn/ui components, providing AI assistants with access to component source code, demos, blocks, and metadata.

786 lines (785 loc) 31.9 kB
import { Axios } from "axios"; import { logError, logWarning, logInfo } from './logger.js'; // Constants for the v4 repository structure const REPO_OWNER = 'huntabyte'; const REPO_NAME = 'shadcn-svelte'; const REPO_BRANCH = 'main'; const REGISTRY_PATH = `docs/src/lib/registry`; const BLOCKS = `${REGISTRY_PATH}/blocks`; // GitHub API for accessing repository structure and metadata const githubApi = new Axios({ baseURL: "https://api.github.com", headers: { "Content-Type": "application/json", "Accept": "application/vnd.github+json", "User-Agent": "Mozilla/5.0 (compatible; ShadcnUiMcpServer/1.0.0)", ...(process.env.GITHUB_PERSONAL_ACCESS_TOKEN && { "Authorization": `Bearer ${process.env.GITHUB_PERSONAL_ACCESS_TOKEN}` }) }, timeout: 30000, // Increased from 15000 to 30000 (30 seconds) transformResponse: [(data) => { try { return JSON.parse(data); } catch { return data; } }], }); // GitHub Raw for directly fetching file contents const githubRaw = new Axios({ baseURL: `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${REPO_BRANCH}`, headers: { "User-Agent": "Mozilla/5.0 (compatible; ShadcnUiMcpServer/1.0.0)", }, timeout: 30000, // Increased from 15000 to 30000 (30 seconds) transformResponse: [(data) => data], // Return raw data }); /** * Fetch component source code from the v4 registry * @param componentName Name of the component * @returns Promise with component source code */ async function getComponentSource(componentName) { const componentPath = `${REGISTRY_PATH}/ui/${componentName.toLowerCase()}/${componentName.toLowerCase()}.svelte`; try { const response = await githubRaw.get(`/${componentPath}`); return response.data; } catch (error) { throw new Error(`Component "${componentName}" not found in v4 registry`); } } /** * Fetch component demo/example from the v4 registry * @param componentName Name of the component * @returns Promise with component demo code */ async function getComponentDemo(componentName) { const demoPath = `${REGISTRY_PATH}/examples/${componentName.toLowerCase()}-demo.svelte`; try { const response = await githubRaw.get(`/${demoPath}`); return response.data; } catch (error) { throw new Error(`Demo for component "${componentName}" not found in v4 registry`); } } /** * Fetch all available components from the registry * @returns Promise with list of component names */ async function getAvailableComponents() { try { // First try the GitHub API const response = await githubApi.get(`/repos/${REPO_OWNER}/${REPO_NAME}/contents/${REGISTRY_PATH}/ui`); if (!response.data || !Array.isArray(response.data)) { throw new Error('Invalid response from GitHub API'); } const components = response.data.map((item) => item.name); if (components.length === 0) { throw new Error('No components found in the registry'); } return components; } catch (error) { logError('Error fetching components from GitHub API', error); // Check for specific error types if (error.response) { const status = error.response.status; const message = error.response.data?.message || 'Unknown error'; if (status === 403 && message.includes('rate limit')) { throw new Error(`GitHub API rate limit exceeded. Please set GITHUB_PERSONAL_ACCESS_TOKEN environment variable for higher limits. Error: ${message}`); } else if (status === 404) { throw new Error(`Components directory not found. The path ${BLOCKS}/ui may not exist in the repository.`); } else if (status === 401) { throw new Error(`Authentication failed. Please check your GITHUB_PERSONAL_ACCESS_TOKEN if provided.`); } else { throw new Error(`GitHub API error (${status}): ${message}`); } } // If it's a network error or other issue, provide a fallback if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND' || error.code === 'ETIMEDOUT') { throw new Error(`Network error: ${error.message}. Please check your internet connection.`); } // If all else fails, provide a fallback list of known components logWarning('Using fallback component list due to API issues'); return getFallbackComponents(); } } /** * Fallback list of known shadcn/ui v4 components * This is used when the GitHub API is unavailable */ function getFallbackComponents() { return [ 'accordion', 'alert', 'alert-dialog', 'aspect-ratio', 'avatar', 'badge', 'breadcrumb', 'button', 'calendar', 'card', 'carousel', 'chart', 'checkbox', 'collapsible', 'command', 'context-menu', 'dialog', 'drawer', 'dropdown-menu', 'form', 'hover-card', 'input', 'input-otp', 'label', 'menubar', 'navigation-menu', 'pagination', 'popover', 'progress', 'radio-group', 'resizable', 'scroll-area', 'select', 'separator', 'sheet', 'sidebar', 'skeleton', 'slider', 'sonner', 'switch', 'table', 'tabs', 'textarea', 'toggle', 'toggle-group', 'tooltip' ]; } /** * Fetch component metadata from the registry * @param componentName Name of the component * @returns Promise with component metadata */ async function getComponentMetadata(componentName) { try { const response = await githubRaw.get(`/docs/registry.json`); const registryContent = JSON.parse(response.data); const metadata = registryContent.items.map((item) => { return { name: item.name, type: item.type, dependencies: item.dependencies, registryDependencies: item.registryDependencies }; }); const component = metadata.find((item) => item.name === componentName.toLowerCase()); if (!component) { throw new Error(`Component "${componentName}" not found in registry`); } return { name: component.name, type: component.type, dependencies: component.dependencies, registryDependencies: component.registryDependencies }; } catch (error) { logError(`Error getting metadata for ${componentName}`, error); return null; } } /** * Recursively builds a directory tree structure from a GitHub repository * @param owner Repository owner * @param repo Repository name * @param path Path within the repository to start building the tree from * @param branch Branch name * @returns Promise resolving to the directory tree structure */ async function buildDirectoryTree(owner = REPO_OWNER, repo = REPO_NAME, path = BLOCKS, branch = REPO_BRANCH) { try { const response = await githubApi.get(`/repos/${owner}/${repo}/contents/${path}?ref=${branch}`); if (!response.data) { throw new Error('No data received from GitHub API'); } const contents = response.data; // Handle different response types from GitHub API if (!Array.isArray(contents)) { // Check if it's an error response (like rate limit) if (contents.message) { const message = contents.message; if (message.includes('rate limit exceeded')) { throw new Error(`GitHub API rate limit exceeded. ${message} Consider setting GITHUB_PERSONAL_ACCESS_TOKEN environment variable for higher rate limits.`); } else if (message.includes('Not Found')) { throw new Error(`Path not found: ${path}. The path may not exist in the repository.`); } else { throw new Error(`GitHub API error: ${message}`); } } // If contents is not an array, it might be a single file if (contents.type === 'file') { return { path: contents.path, type: 'file', name: contents.name, url: contents.download_url, sha: contents.sha, }; } else { throw new Error(`Unexpected response type from GitHub API: ${JSON.stringify(contents)}`); } } // Build tree node for this level (directory with multiple items) const result = { path, type: 'directory', children: {}, }; // Process each item for (const item of contents) { if (item.type === 'file') { // Add file to this directory's children result.children[item.name] = { path: item.path, type: 'file', name: item.name, url: item.download_url, sha: item.sha, }; } else if (item.type === 'dir') { // Recursively process subdirectory (limit depth to avoid infinite recursion) if (path.split('/').length < 8) { try { const subTree = await buildDirectoryTree(owner, repo, item.path, branch); result.children[item.name] = subTree; } catch (error) { logWarning(`Failed to fetch subdirectory ${item.path}: ${error instanceof Error ? error.message : String(error)}`); result.children[item.name] = { path: item.path, type: 'directory', error: 'Failed to fetch contents' }; } } } } return result; } catch (error) { logError(`Error building directory tree for ${path}`, error); // Check if it's already a well-formatted error from above if (error.message && (error.message.includes('rate limit') || error.message.includes('GitHub API error'))) { throw error; } // Provide more specific error messages for HTTP errors if (error.response) { const status = error.response.status; const responseData = error.response.data; const message = responseData?.message || 'Unknown error'; if (status === 404) { throw new Error(`Path not found: ${path}. The path may not exist in the repository.`); } else if (status === 403) { if (message.includes('rate limit')) { throw new Error(`GitHub API rate limit exceeded: ${message} Consider setting GITHUB_PERSONAL_ACCESS_TOKEN environment variable for higher rate limits.`); } else { throw new Error(`Access forbidden: ${message}`); } } else if (status === 401) { throw new Error(`Authentication failed. Please check your GITHUB_PERSONAL_ACCESS_TOKEN if provided.`); } else { throw new Error(`GitHub API error (${status}): ${message}`); } } throw error; } } /** * Provides a basic directory structure for v4 registry without API calls * This is used as a fallback when API rate limits are hit */ function getBasicV4Structure() { return { path: REGISTRY_PATH, type: 'directory', note: 'Basic structure provided due to API limitations', children: { 'ui': { path: `${REGISTRY_PATH}/ui`, type: 'directory', description: 'Contains all v4 UI components', note: 'Component files (.tsx) are located here' }, 'examples': { path: `${REGISTRY_PATH}/examples`, type: 'directory', description: 'Contains component demo examples', note: 'Demo files showing component usage' }, 'hooks': { path: `${REGISTRY_PATH}/hooks`, type: 'directory', description: 'Contains custom React hooks' }, 'lib': { path: `${REGISTRY_PATH}/lib`, type: 'directory', description: 'Contains utility libraries and functions' } } }; } /** * Extract description from block code comments * @param code The source code to analyze * @returns Extracted description or null */ function extractBlockDescription(code) { // Look for JSDoc comments or description comments const descriptionRegex = /\/\*\*[\s\S]*?\*\/|\/\/\s*(.+)/; const match = code.match(descriptionRegex); if (match) { // Clean up the comment const description = match[0] .replace(/\/\*\*|\*\/|\*|\/\//g, '') .trim() .split('\n')[0] .trim(); return description.length > 0 ? description : null; } // Look for component name as fallback const componentRegex = /export\s+(?:default\s+)?function\s+(\w+)/; const componentMatch = code.match(componentRegex); if (componentMatch) { return `${componentMatch[1]} - A reusable UI component`; } return null; } /** * Extract dependencies from import statements * @param code The source code to analyze * @returns Array of dependency names */ function extractDependencies(code) { const dependencies = []; // Match import statements const importRegex = /import\s+.*?\s+from\s+['"]([@\w\/\-\.]+)['"]/g; let match; match = importRegex.exec(code); while (match !== null) { const dep = match[1]; if (!dep.startsWith('./') && !dep.startsWith('../') && !dep.startsWith('@/')) { dependencies.push(dep); } match = importRegex.exec(code); } return [...new Set(dependencies)]; // Remove duplicates } /** * Extract component usage from code * @param code The source code to analyze * @returns Array of component names used */ function extractComponentUsage(code) { const components = []; // Extract from imports of components (assuming they start with capital letters) const importRegex = /import\s+\{([^}]+)\}\s+from/g; let match; match = importRegex.exec(code); while (match !== null) { const imports = match[1].split(',').map(imp => imp.trim()); imports.forEach(imp => { if (imp[0] && imp[0] === imp[0].toUpperCase()) { components.push(imp); } }); match = importRegex.exec(code); } // Also look for JSX components in the code const jsxRegex = /<([A-Z]\w+)/g; match = jsxRegex.exec(code); while (match !== null) { components.push(match[1]); match = jsxRegex.exec(code); } return [...new Set(components)]; // Remove duplicates } /** * Generate usage instructions for complex blocks * @param blockName Name of the block * @param structure Structure information * @returns Usage instructions string */ function generateComplexBlockUsage(blockName, structure) { const hasComponents = structure.some(item => item.name === 'components'); let usage = `To use the ${blockName} block:\n\n`; usage += `1. Copy the main files to your project:\n`; structure.forEach(item => { if (item.type === 'file') { usage += ` - ${item.name}\n`; } else if (item.type === 'directory' && item.name === 'components') { usage += ` - components/ directory (${item.count} files)\n`; } }); if (hasComponents) { usage += `\n2. Copy the components to your components directory\n`; usage += `3. Update import paths as needed\n`; usage += `4. Ensure all dependencies are installed\n`; } else { usage += `\n2. Update import paths as needed\n`; usage += `3. Ensure all dependencies are installed\n`; } return usage; } /** * Enhanced buildDirectoryTree with fallback for rate limits */ async function buildDirectoryTreeWithFallback(owner = REPO_OWNER, repo = REPO_NAME, path = BLOCKS, branch = REPO_BRANCH) { try { return await buildDirectoryTree(owner, repo, path, branch); } catch (error) { // If it's a rate limit error and we're asking for the default v4 path, provide fallback if (error.message && error.message.includes('rate limit') && path === BLOCKS) { logWarning('Using fallback directory structure due to rate limit'); return getBasicV4Structure(); } // Re-throw other errors throw error; } } /** * Fetch block code from the v4 blocks directory * @param blockName Name of the block (e.g., "calendar-01", "dashboard-01") * @param includeComponents Whether to include component files for complex blocks * @returns Promise with block code and structure */ async function getBlockCode(blockName, includeComponents = true) { const blocksPath = `${BLOCKS}`; try { // First, check if it's a simple block file (.tsx) try { const simpleBlockResponse = await githubRaw.get(`/${blocksPath}/${blockName}.svelte`); if (simpleBlockResponse.status === 200) { const code = simpleBlockResponse.data; // Extract useful information from the code const description = extractBlockDescription(code); const dependencies = extractDependencies(code); const components = extractComponentUsage(code); return { name: blockName, type: 'simple', description: description || `Simple block: ${blockName}`, code: code, dependencies: dependencies, componentsUsed: components, size: code.length, lines: code.split('\n').length, usage: `Import and use directly in your application:\n\nimport { ${blockName.charAt(0).toUpperCase() + blockName.slice(1).replace(/-/g, '')} } from './blocks/${blockName}'` }; } } catch (error) { // Continue to check for complex block directory } // Check if it's a complex block directory const directoryResponse = await githubApi.get(`/repos/${REPO_OWNER}/${REPO_NAME}/contents/${blocksPath}/${blockName}?ref=${REPO_BRANCH}`); if (!directoryResponse.data) { throw new Error(`Block "${blockName}" not found`); } const blockStructure = { name: blockName, type: 'complex', description: `Complex block: ${blockName}`, files: {}, structure: [], totalFiles: 0, dependencies: new Set(), componentsUsed: new Set() }; // Process the directory contents if (Array.isArray(directoryResponse.data)) { blockStructure.totalFiles = directoryResponse.data.length; for (const item of directoryResponse.data) { if (item.type === 'file') { // Get the main page file const fileResponse = await githubRaw.get(`/${item.path}`); const content = fileResponse.data; // Extract information from the file const description = extractBlockDescription(content); const dependencies = extractDependencies(content); const components = extractComponentUsage(content); blockStructure.files[item.name] = { path: item.name, content: content, size: content.length, lines: content.split('\n').length, description: description, dependencies: dependencies, componentsUsed: components }; // Add to overall dependencies and components dependencies.forEach((dep) => blockStructure.dependencies.add(dep)); components.forEach((comp) => blockStructure.componentsUsed.add(comp)); blockStructure.structure.push({ name: item.name, type: 'file', size: content.length, description: description || `${item.name} - Main block file` }); // Use the first file's description as the block description if available if (description && blockStructure.description === `Complex block: ${blockName}`) { blockStructure.description = description; } } else if (item.type === 'dir' && item.name === 'components' && includeComponents) { // Get component files const componentsResponse = await githubApi.get(`/repos/${REPO_OWNER}/${REPO_NAME}/contents/${item.path}?ref=${REPO_BRANCH}`); if (Array.isArray(componentsResponse.data)) { blockStructure.files.components = {}; const componentStructure = []; for (const componentItem of componentsResponse.data) { if (componentItem.type === 'file') { const componentResponse = await githubRaw.get(`/${componentItem.path}`); const content = componentResponse.data; const dependencies = extractDependencies(content); const components = extractComponentUsage(content); blockStructure.files.components[componentItem.name] = { path: `components/${componentItem.name}`, content: content, size: content.length, lines: content.split('\n').length, dependencies: dependencies, componentsUsed: components }; // Add to overall dependencies and components dependencies.forEach((dep) => blockStructure.dependencies.add(dep)); components.forEach((comp) => blockStructure.componentsUsed.add(comp)); componentStructure.push({ name: componentItem.name, type: 'component', size: content.length }); } } blockStructure.structure.push({ name: 'components', type: 'directory', files: componentStructure, count: componentStructure.length }); } } } } // Convert Sets to Arrays for JSON serialization blockStructure.dependencies = Array.from(blockStructure.dependencies); blockStructure.componentsUsed = Array.from(blockStructure.componentsUsed); // Add usage instructions blockStructure.usage = generateComplexBlockUsage(blockName, blockStructure.structure); return blockStructure; } catch (error) { if (error.response?.status === 404) { throw new Error(`Block "${blockName}" not found. Available blocks can be found in the v4 blocks directory.`); } throw error; } } /** * Get all available blocks with categorization * @param category Optional category filter * @returns Promise with categorized block list */ async function getAvailableBlocks(category) { const blocksPath = `${BLOCKS}`; try { const response = await githubApi.get(`/repos/${REPO_OWNER}/${REPO_NAME}/contents/${blocksPath}?ref=${REPO_BRANCH}`); if (!Array.isArray(response.data)) { throw new Error('Unexpected response from GitHub API'); } const blocks = { calendar: [], dashboard: [], login: [], sidebar: [], products: [], authentication: [], charts: [], mail: [], music: [], other: [] }; for (const item of response.data) { const blockInfo = { name: item.name.replace('.svelte', ''), type: item.type === 'file' ? 'simple' : 'complex', path: item.path, size: item.size || 0, lastModified: item.download_url ? 'Available' : 'Directory' }; // Add description based on name patterns if (item.name.includes('calendar')) { blockInfo.description = 'Calendar component for date selection and scheduling'; blocks.calendar.push(blockInfo); } else if (item.name.includes('dashboard')) { blockInfo.description = 'Dashboard layout with charts, metrics, and data display'; blocks.dashboard.push(blockInfo); } else if (item.name.includes('login') || item.name.includes('signin')) { blockInfo.description = 'Authentication and login interface'; blocks.login.push(blockInfo); } else if (item.name.includes('sidebar')) { blockInfo.description = 'Navigation sidebar component'; blocks.sidebar.push(blockInfo); } else if (item.name.includes('products') || item.name.includes('ecommerce')) { blockInfo.description = 'Product listing and e-commerce components'; blocks.products.push(blockInfo); } else if (item.name.includes('auth')) { blockInfo.description = 'Authentication related components'; blocks.authentication.push(blockInfo); } else if (item.name.includes('chart') || item.name.includes('graph')) { blockInfo.description = 'Data visualization and chart components'; blocks.charts.push(blockInfo); } else if (item.name.includes('mail') || item.name.includes('email')) { blockInfo.description = 'Email and mail interface components'; blocks.mail.push(blockInfo); } else if (item.name.includes('music') || item.name.includes('player')) { blockInfo.description = 'Music player and media components'; blocks.music.push(blockInfo); } else { blockInfo.description = `${item.name} - Custom UI block`; blocks.other.push(blockInfo); } } // Sort blocks within each category Object.keys(blocks).forEach(key => { blocks[key].sort((a, b) => a.name.localeCompare(b.name)); }); // Filter by category if specified if (category) { const categoryLower = category.toLowerCase(); if (blocks[categoryLower]) { return { category, blocks: blocks[categoryLower], total: blocks[categoryLower].length, description: `${category.charAt(0).toUpperCase() + category.slice(1)} blocks available in shadcn/ui v4`, usage: `Use 'get_block' tool with the block name to get the full source code and implementation details.` }; } else { return { category, blocks: [], total: 0, availableCategories: Object.keys(blocks).filter(key => blocks[key].length > 0), suggestion: `Category '${category}' not found. Available categories: ${Object.keys(blocks).filter(key => blocks[key].length > 0).join(', ')}` }; } } // Calculate totals const totalBlocks = Object.values(blocks).flat().length; const nonEmptyCategories = Object.keys(blocks).filter(key => blocks[key].length > 0); return { categories: blocks, totalBlocks, availableCategories: nonEmptyCategories, summary: Object.keys(blocks).reduce((acc, key) => { if (blocks[key].length > 0) { acc[key] = blocks[key].length; } return acc; }, {}), usage: "Use 'get_block' tool with a specific block name to get full source code and implementation details.", examples: nonEmptyCategories.slice(0, 3).map(cat => blocks[cat][0] ? `${cat}: ${blocks[cat][0].name}` : '').filter(Boolean) }; } catch (error) { if (error.response?.status === 404) { throw new Error('Blocks directory not found in the v4 registry'); } throw error; } } /** * Set or update GitHub API key for higher rate limits * @param apiKey GitHub Personal Access Token */ function setGitHubApiKey(apiKey) { // Update the Authorization header for the GitHub API instance if (apiKey && apiKey.trim()) { githubApi.defaults.headers['Authorization'] = `Bearer ${apiKey.trim()}`; logInfo('GitHub API key updated successfully'); console.error('GitHub API key updated successfully'); } else { // Remove authorization header if empty key provided delete githubApi.defaults.headers['Authorization']; console.error('GitHub API key removed - using unauthenticated requests'); console.error('For higher rate limits and reliability, provide a GitHub API token. See setup instructions: https://github.com/Jpisnice/shadcn-ui-mcp-server#readme'); } } /** * Get current GitHub API rate limit status * @returns Promise with rate limit information */ async function getGitHubRateLimit() { try { const response = await githubApi.get('/rate_limit'); return response.data; } catch (error) { throw new Error(`Failed to get rate limit info: ${error.message}`); } } export const axios = { githubRaw, githubApi, buildDirectoryTree: buildDirectoryTreeWithFallback, // Use fallback version by default buildDirectoryTreeWithFallback, getComponentSource, getComponentDemo, getAvailableComponents, getComponentMetadata, getBlockCode, getAvailableBlocks, setGitHubApiKey, getGitHubRateLimit, // Path constants for easy access paths: { REPO_OWNER, REPO_NAME, REPO_BRANCH, REGISTRY_PATH, BLOCKS } };