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.

845 lines (844 loc) 32.8 kB
import { Axios } from "axios"; import { logError, logWarning, logInfo } from "./logger.js"; // Constants for the Vue repository structure (v4) const REPO_OWNER = "unovue"; const REPO_NAME = "shadcn-vue"; const REPO_BRANCH = "dev"; // App paths const APPS_V4_PATH = `apps/v4`; const REGISTRY_PATH = `${APPS_V4_PATH}/registry`; const NEW_YORK_V4_PATH = `${REGISTRY_PATH}/new-york-v4`; const UI_PATH = `${NEW_YORK_V4_PATH}/ui`; const BLOCKS_PATH = `${NEW_YORK_V4_PATH}/blocks`; const DEMOS_PATH = `${APPS_V4_PATH}/components`; const formatComponentNameToCapital = (name) => { return name .split("-") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(""); }; const toKebabCase = (name) => name // Convert PascalCase or camelCase to kebab-case .replace(/([a-z0-9])([A-Z])/g, "$1-$2") .replace(/\s+/g, "-") .toLowerCase(); // Normalize block names like "login01", "Login01", or "login-01" -> "Login01" const normalizeBlockName = (name) => { const parts = (name || "").match(/[a-zA-Z]+|\d+/g) || []; return parts .map((p) => /[0-9]/.test(p) ? p : p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()) .join(""); }; // 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, // 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, // 30 seconds transformResponse: [(data) => data], // Return raw data }); /** * Fetch component source code from the Vue registry * @param componentName Name of the component * @param style Optional style variant ('new-york' or 'default') * @returns Promise with component source code */ async function getComponentSource(componentName, style = "new-york-v4") { const formattedComponentName = formatComponentNameToCapital(componentName); const componentFolder = componentName.toLowerCase(); const basePath = UI_PATH; // Only new-york-v4 is supported for v4 const componentPath = `${basePath}/${componentFolder}/${formattedComponentName}.vue`; try { const response = await githubRaw.get(`/${componentPath}`); return response.data; } catch (error) { // Try alternative paths if the primary fails const altPaths = [ `${basePath}/${componentFolder}/${formattedComponentName}.vue`, `${basePath}/${componentFolder}/index.ts`, `${basePath}/${formattedComponentName}.vue`, ]; for (const altPath of altPaths) { try { const response = await githubRaw.get(`/${altPath}`); return response.data; } catch { continue; } } throw new Error(`Component "${formattedComponentName}" not found in Vue registry (v4)`); } } /** * Fetch component demo/example from the Vue registry * @param componentName Name of the component * @param style Optional style variant ('new-york' or 'default') * @returns Promise with component demo code */ async function getComponentDemo(componentName, style = "new-york-v4") { const formattedComponentName = formatComponentNameToCapital(componentName); const demoPaths = [ `${DEMOS_PATH}/${formattedComponentName}Demo.vue`, `${DEMOS_PATH}/${formattedComponentName}.vue`, ]; for (const demoPath of demoPaths) { try { const response = await githubRaw.get(`/${demoPath}`); return response.data; } catch (error) { continue; } } throw new Error(`Demo for component "${formattedComponentName}" not found in Vue registry (v4)`); } /** * Fetch all available components from the Vue registry * @param style Optional style variant ('new-york' or 'default') * @returns Promise with list of component names */ async function getAvailableComponents(style = "new-york-v4") { try { // v4 components live under UI_PATH as directories const response = await githubApi.get(`/repos/${REPO_OWNER}/${REPO_NAME}/contents/${UI_PATH}`); if (!response.data || !Array.isArray(response.data)) { throw new Error("Invalid response from GitHub API"); } const components = response.data .filter((item) => item.type === "dir" || (item.type === "file" && item.name.endsWith(".vue"))) .map((item) => { if (item.type === "dir") { return item.name; } else { return item.name.replace(".vue", ""); } }); if (components.length === 0) { throw new Error("No components found in the Vue registry (v4)"); } 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 ${UI_PATH} 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 Vue components logWarning("Using fallback component list due to API issues"); return getFallbackComponents(); } } /** * Fallback list of known shadcn-vue 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", "checkbox", "collapsible", "command", "context-menu", "data-table", "date-picker", "dialog", "drawer", "dropdown-menu", "form", "hover-card", "input", "input-otp", "label", "menubar", "navigation-menu", "number-field", "pagination", "pin-input", "popover", "progress", "radio-group", "range-calendar", "resizable", "scroll-area", "select", "separator", "sheet", "skeleton", "slider", "sonner", "switch", "table", "tabs", "textarea", "toast", "toggle", "toggle-group", "tooltip", ]; } /** * Fetch component metadata from the Vue registry * @param componentName Name of the component * @returns Promise with component metadata */ async function getComponentMetadata(componentName) { try { const response = await githubRaw.get(`/${APPS_V4_PATH}/__registry__/index.ts`); const content = response.data; // Find the block for the component key const entryRegex = new RegExp(`"${componentName}"\s*:\\s*\{([\\s\\S]*?)\n\}`, "s"); const entryMatch = content.match(entryRegex); if (!entryMatch) { throw new Error(`Registry entry for ${componentName} not found`); } const entry = entryMatch[1]; // Extract simple fields const typeMatch = entry.match(/type:\s*"([^"]+)"/); const depsMatch = entry.match(/registryDependencies:\s*\[([^\]]*)\]/s); const filesMatch = entry.match(/files:\s*\[([\s\S]*?)\]/); const registryDependencies = depsMatch ? depsMatch[1] .split(",") .map((d) => d.replace(/["'\s]/g, "").trim()) .filter(Boolean) : []; // Parse file paths (best-effort) const filePathRegex = /path:\s*"([^"]+)"/g; const files = []; if (filesMatch) { let m; while ((m = filePathRegex.exec(filesMatch[1])) !== null) { files.push(m[1]); } } return { name: componentName, type: typeMatch?.[1] || "registry:ui", registryDependencies, files, }; } catch (error) { logError(`Error getting metadata for ${componentName}`, error); return null; } } /** * Recursively builds a directory tree structure from the Vue 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 = NEW_YORK_V4_PATH, 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 Vue registry without API calls * This is used as a fallback when API rate limits are hit */ function getBasicVueStructure() { return { path: NEW_YORK_V4_PATH, type: "directory", note: "Basic structure provided due to API limitations", children: { ui: { path: `${UI_PATH}`, type: "directory", description: "Contains all Vue UI components (v4)", note: "Component files (.vue) are located in subfolders", }, blocks: { path: `${BLOCKS_PATH}`, type: "directory", description: "Contains Vue blocks for v4", }, }, }; } /** * Extract description from Vue component comments * @param code The source code to analyze * @returns Extracted description or null */ function extractComponentDescription(code) { // Look for Vue component description in template comments or script comments const descriptionRegex = /<!--[\s\S]*?-->|\/\*\*[\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 in script setup const componentRegex = /<script.*setup.*>[\s\S]*?<\/script>/; const scriptMatch = code.match(componentRegex); if (scriptMatch) { const nameMatch = scriptMatch[0].match(/defineComponent\(\s*{[\s\S]*?name:\s*['"]([^'"]+)['"]/); if (nameMatch) { return `${nameMatch[1]} - A reusable Vue UI component`; } } return null; } /** * Extract dependencies from Vue component imports * @param code The source code to analyze * @returns Array of dependency names */ function extractVueDependencies(code) { const dependencies = []; // Match import statements in script blocks const scriptRegex = /<script.*?>([\s\S]*?)<\/script>/g; let scriptMatch; while ((scriptMatch = scriptRegex.exec(code)) !== null) { const scriptContent = scriptMatch[1]; const importRegex = /import\s+.*?\s+from\s+['"]([@\w\/\-\.]+)['"]/g; let importMatch; while ((importMatch = importRegex.exec(scriptContent)) !== null) { const dep = importMatch[1]; if (!dep.startsWith("./") && !dep.startsWith("../") && !dep.startsWith("@/")) { dependencies.push(dep); } } } return [...new Set(dependencies)]; // Remove duplicates } /** * Extract component usage from Vue template * @param code The source code to analyze * @returns Array of component names used */ function extractVueComponentUsage(code) { const components = []; // Extract from template const templateRegex = /<template.*?>([\s\S]*?)<\/template>/; const templateMatch = code.match(templateRegex); if (templateMatch) { const templateContent = templateMatch[1]; // Look for custom components (PascalCase or kebab-case) const componentRegex = /<([A-Z]\w+|[a-z]+-[a-z-]+)/g; let match; while ((match = componentRegex.exec(templateContent)) !== null) { components.push(match[1]); } } // Also extract from script imports const scriptRegex = /<script.*?>([\s\S]*?)<\/script>/; const scriptMatch = code.match(scriptRegex); if (scriptMatch) { const scriptContent = scriptMatch[1]; const importRegex = /import\s+\{([^}]+)\}\s+from/g; let match; while ((match = importRegex.exec(scriptContent)) !== null) { const imports = match[1].split(",").map((imp) => imp.trim()); imports.forEach((imp) => { if (imp[0] && imp[0] === imp[0].toUpperCase()) { components.push(imp); } }); } } return [...new Set(components)]; // Remove duplicates } /** * Enhanced buildDirectoryTree with fallback for rate limits */ async function buildDirectoryTreeWithFallback(owner = REPO_OWNER, repo = REPO_NAME, path = NEW_YORK_V4_PATH, 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 path, provide fallback if (error.message && error.message.includes("rate limit") && path === NEW_YORK_V4_PATH) { logWarning("Using fallback directory structure due to rate limit"); return getBasicVueStructure(); } // Re-throw other errors throw error; } } /** * Fetch block code from the Vue blocks directory * @param blockName Name of the block * @param includeComponents Whether to include component files for complex blocks * @returns Promise with block code and structure */ async function getBlockCode(blockName, includeComponents = true) { // Prefer v4 registry blocks (directories with page.vue) const blocksPath = BLOCKS_PATH; const normalized = normalizeBlockName(blockName); // Check for complex block directory const directoryResponse = await githubApi.get(`/repos/${REPO_OWNER}/${REPO_NAME}/contents/${blocksPath}/${normalized}?ref=${REPO_BRANCH}`); if (!directoryResponse.data) { throw new Error(`Block "${blockName}" not found`); } const blockStructure = { name: normalized, type: "complex", description: `Complex block: ${normalized}`, files: {}, structure: [], totalFiles: 0, dependencies: new Set(), componentsUsed: new Set(), code: undefined, }; if (Array.isArray(directoryResponse.data)) { blockStructure.totalFiles = directoryResponse.data.length; for (const item of directoryResponse.data) { if (item.type === "file") { const fileResponse = await githubRaw.get(`/${item.path}`); const content = fileResponse.data; const description = extractComponentDescription(content); const dependencies = extractVueDependencies(content); const components = extractVueComponentUsage(content); blockStructure.files[item.name] = { path: item.name, content: content, size: content.length, lines: content.split("\n").length, description: description, dependencies: dependencies, componentsUsed: 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`, }); // If this is the main page file, set it as the primary code if (item.name.toLowerCase() === "page.vue") { blockStructure.code = content; if (description && blockStructure.description === `Complex block: ${normalized}`) { blockStructure.description = description; } } } else if (item.type === "dir" && item.name === "components" && includeComponents) { 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 = extractVueDependencies(content); const components = extractVueComponentUsage(content); blockStructure.files.components[componentItem.name] = { path: `components/${componentItem.name}`, content: content, size: content.length, lines: content.split("\n").length, dependencies: dependencies, componentsUsed: 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, }); } } } } blockStructure.dependencies = Array.from(blockStructure.dependencies); blockStructure.componentsUsed = Array.from(blockStructure.componentsUsed); // Ensure we return page.vue as main code. If not loaded above, fetch explicitly if (!blockStructure.code) { try { const pageResponse = await githubRaw.get(`/${blocksPath}/${normalized}/page.vue`); blockStructure.code = pageResponse.data; } catch (e) { blockStructure.code = ""; } } blockStructure.usage = `To use the ${normalized} block, copy \`page.vue\` and any \`components\` into your project and update imports as needed.`; return blockStructure; } /** * Get all available blocks with categorization * @param category Optional category filter * @returns Promise with categorized block list */ async function getAvailableBlocks(category) { const blocksPath = BLOCKS_PATH; const allBlocks = []; try { const response = await githubApi.get(`/repos/${REPO_OWNER}/${REPO_NAME}/contents/${blocksPath}?ref=${REPO_BRANCH}`); if (Array.isArray(response.data)) { for (const item of response.data) { if (item.type === "file" && item.name.endsWith(".vue")) { allBlocks.push({ name: item.name.replace(".vue", ""), type: "simple", path: item.path, size: item.size || 0, lastModified: "Available", }); } else if (item.type === "dir") { allBlocks.push({ name: item.name, type: "complex", path: item.path, lastModified: "Directory", }); } } } } catch (error) { throw new Error("No blocks found in Vue registry (v4)"); } // Categorize blocks by simple heuristics const blocks = { calendar: [], dashboard: [], login: [], sidebar: [], products: [], authentication: [], charts: [], mail: [], music: [], other: [], }; for (const block of allBlocks) { if (block.name.includes("calendar")) { block.description = "Calendar component for date selection and scheduling"; blocks.calendar.push(block); } else if (block.name.includes("dashboard")) { block.description = "Dashboard layout with charts, metrics, and data display"; blocks.dashboard.push(block); } else if (block.name.includes("login") || block.name.includes("signin")) { block.description = "Authentication and login interface"; blocks.login.push(block); } else if (block.name.includes("sidebar")) { block.description = "Navigation sidebar component"; blocks.sidebar.push(block); } else if (block.name.includes("products") || block.name.includes("ecommerce")) { block.description = "Product listing and e-commerce components"; blocks.products.push(block); } else if (block.name.includes("auth")) { block.description = "Authentication related components"; blocks.authentication.push(block); } else if (block.name.includes("chart") || block.name.includes("graph")) { block.description = "Data visualization and chart components"; blocks.charts.push(block); } else if (block.name.includes("mail") || block.name.includes("email")) { block.description = "Email and mail interface components"; blocks.mail.push(block); } else if (block.name.includes("music") || block.name.includes("player")) { block.description = "Music player and media components"; blocks.music.push(block); } else { block.description = `${block.name} - Custom Vue UI block`; blocks.other.push(block); } } Object.keys(blocks).forEach((key) => { blocks[key].sort((a, b) => a.name.localeCompare(b.name)); }); 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-vue 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(", ")}`, }; } } 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), }; } /** * 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, APPS_V4_PATH, REGISTRY_PATH, NEW_YORK_V4_PATH, UI_PATH, BLOCKS_PATH, DEMOS_PATH, }, };