UNPKG

@gianpieropuleo/radix-mcp-server

Version:

A Model Context Protocol (MCP) server for Radix UI libraries (Themes, Primitives, Colors), providing AI assistants with access to component source code, installation guides, and design tokens.

624 lines (623 loc) 23.5 kB
import ExpiryMap from "expiry-map"; import ky from "ky"; import pLimit from "p-limit"; import pMemoize from "p-memoize"; import { logError, logInfo, logWarning } from "./logger.js"; // Constants for Radix UI repositories const RADIX_OWNER = "radix-ui"; const THEMES_REPO = "themes"; const PRIMITIVES_REPO = "primitives"; const COLORS_REPO = "colors"; const REPO_BRANCH = "main"; // Radix repository paths const THEMES_PATHS = { components: "packages/radix-ui-themes/src/components", docs: "apps/playground/app", examples: "apps/playground/app/demo", }; const PRIMITIVES_PATHS = { components: "packages/react", docs: "apps/www/content/primitives/docs", examples: "apps/www/content/primitives/examples", }; const COLORS_PATHS = { tokens: "src", docs: "apps/docs/content/colors", }; // GitHub API rate limiting // Limit to 1 concurrent request to avoid rate limits and be respectful to GitHub const githubLimit = pLimit(1); // Cache TTL - 24 hours for all memoized functions const CACHE_TTL = 24 * 60 * 60 * 1000; // Create cache instance with TTL support const createCache = () => new ExpiryMap(CACHE_TTL); // GitHub API client for accessing repository structure and metadata const githubApi = ky.create({ prefixUrl: "https://api.github.com", headers: { "Content-Type": "application/json", Accept: "application/vnd.github+json", "User-Agent": "Mozilla/5.0 (compatible; RadixMcpServer/1.0.0)", ...(process.env.GITHUB_PERSONAL_ACCESS_TOKEN && { Authorization: `Bearer ${process.env.GITHUB_PERSONAL_ACCESS_TOKEN}`, }), }, timeout: 30000, retry: { limit: 2, methods: ["get"], statusCodes: [408, 413, 429, 500, 502, 503, 504], }, hooks: { beforeError: [ (error) => { if (error.response) { const { status } = error.response; if (status === 403) { error.message = `GitHub API rate limit exceeded. Please set GITHUB_PERSONAL_ACCESS_TOKEN environment variable for higher limits.`; } else if (status === 404) { error.message = `GitHub resource not found. The repository structure may have changed.`; } else if (status === 401) { error.message = `GitHub authentication failed. Please check your GITHUB_PERSONAL_ACCESS_TOKEN.`; } } return error; }, ], }, }); // GitHub Raw client factory for different repositories function createGithubRaw(repo) { return ky.create({ prefixUrl: `https://raw.githubusercontent.com/${RADIX_OWNER}/${repo}/${REPO_BRANCH}`, headers: { "User-Agent": "Mozilla/5.0 (compatible; RadixMcpServer/1.0.0)", }, timeout: 30000, retry: { limit: 2, methods: ["get"], statusCodes: [408, 429, 500, 502, 503, 504], }, }); } // Repository-specific clients const themesRaw = createGithubRaw(THEMES_REPO); const primitivesRaw = createGithubRaw(PRIMITIVES_REPO); const colorsRaw = createGithubRaw(COLORS_REPO); /** * Fetch Radix Themes component source code * @param componentName Name of the component * @returns Promise with component source code */ async function getThemesComponentSource(componentName) { const componentPath = `${THEMES_PATHS.components}/${componentName.toLowerCase()}`; try { // Try different file extensions const extensions = [".tsx", ".ts", "/index.tsx", "/index.ts"]; for (const ext of extensions) { try { const response = await githubLimit(() => themesRaw.get(`${componentPath}${ext}`)); return await response.text(); } catch (error) { // Continue to next extension } } throw new Error(`Themes component "${componentName}" not found`); } catch (error) { throw new Error(`Themes component "${componentName}" not found in repository`); } } /** * Fetch Radix Primitives component source code * @param componentName Name of the component (e.g., "accordion", "dialog") * @returns Promise with component source code */ async function getPrimitivesComponentSource(componentName) { const componentPath = `${PRIMITIVES_PATHS.components}/${componentName.toLowerCase()}`; try { // Try to get the main component file (usually index.tsx or src/index.tsx) const extensions = [ `/src/${componentName.toLowerCase()}.tsx`, "/index.tsx", "/src/index.ts", "/index.ts", ]; for (const ext of extensions) { try { const response = await githubLimit(() => primitivesRaw.get(`${componentPath}${ext}`)); return await response.text(); } catch (error) { // Continue to next extension } } throw new Error(`Primitives component "${componentName}" not found`); } catch (error) { throw new Error(`Primitives component "${componentName}" not found in repository`); } } /** * Fetch installation guide for Radix components * @param library Which library (themes|primitives|colors) * @param componentName Optional specific component * @returns Promise with installation instructions */ async function getInstallationGuide(library, componentName) { const baseInstructions = { themes: `# Install Radix Themes\n\nnpm install @radix-ui/themes\n\n# Import the CSS\nimport '@radix-ui/themes/styles.css';\n\n# Wrap your app with Theme\nimport { Theme } from '@radix-ui/themes';\n\nfunction App() {\n return (\n <Theme>\n <YourApp />\n </Theme>\n );\n}`, primitives: componentName ? `# Install ${componentName} primitive\n\nnpm install @radix-ui/react-${componentName.toLowerCase()}\n\n# Import and use\nimport * as ${componentName.charAt(0).toUpperCase() + componentName.slice(1)} from '@radix-ui/react-${componentName.toLowerCase()}';` : `# Install Radix Primitives\n\n# Install individual primitive (recommended)\nnpm install @radix-ui/react-dialog\n\n# Or install all primitives\nnpm install @radix-ui/react`, colors: `# Install Radix Colors\n\nnpm install @radix-ui/colors\n\n# Import color scales\nimport { blue, red, green } from '@radix-ui/colors';\n\n# Use in CSS-in-JS\nconst styles = {\n backgroundColor: blue.blue3,\n color: blue.blue11\n};`, }; return baseInstructions[library]; } /** * Fetch available components from a Radix library * @param library Which library (themes|primitives|colors) * @returns Promise with list of component names */ async function getAvailableComponents(library) { try { let repo; let path; switch (library) { case "themes": repo = THEMES_REPO; path = THEMES_PATHS.components; break; case "primitives": repo = PRIMITIVES_REPO; path = PRIMITIVES_PATHS.components; break; case "colors": repo = COLORS_REPO; path = COLORS_PATHS.tokens; break; } const response = await githubLimit(() => githubApi.get(`repos/${RADIX_OWNER}/${repo}/contents/${path}`)); const data = (await response.json()); if (!Array.isArray(data)) { throw new Error("Invalid response from GitHub API"); } const components = data .filter((item) => { if (library === "colors") { return (item.type === "file" && item.name.endsWith(".ts") && !item.name.includes("index")); } else if (library === "primitives") { return item.type === "dir" && !item.name.startsWith("."); } else { return (item.type === "file" && !item.name.startsWith(".") && !item.name.endsWith("props.tsx") && !item.name.endsWith(".css")); } }) .map((item) => item.name.replace(/\.(ts|tsx)$/, "")); if (components.length === 0) { throw new Error(`No components found in ${library} library`); } return components; } catch (error) { logError(`Error fetching components from ${library} library`, error); // Handle Ky HTTPError if (error.name === "HTTPError") { const status = error.response?.status; if (status === 403) { throw new Error(`GitHub API rate limit exceeded. Please set GITHUB_PERSONAL_ACCESS_TOKEN environment variable for higher limits.`); } else if (status === 404) { throw new Error(`${library} components directory not found. The repository structure may have changed.`); } 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}): ${error.message}`); } } // Handle network errors if (error.name === "TimeoutError") { throw new Error(`Network timeout: Please check your internet connection.`); } // If all else fails, provide a fallback list of known components logWarning(`Using fallback component list for ${library} due to API issues`); return getFallbackComponents(library); } } /** * Fallback list of known Radix components * This is used when the GitHub API is unavailable */ function getFallbackComponents(library) { const fallbacks = { themes: [ "avatar", "badge", "button", "card", "checkbox", "dialog", "dropdown-menu", "flex", "grid", "heading", "icon-button", "link", "popover", "progress", "radio-group", "select", "separator", "slider", "switch", "table", "tabs", "text", "text-area", "text-field", "tooltip", ], primitives: [ "accordion", "alert-dialog", "aspect-ratio", "avatar", "checkbox", "collapsible", "context-menu", "dialog", "dropdown-menu", "form", "hover-card", "label", "menubar", "navigation-menu", "popover", "progress", "radio-group", "scroll-area", "select", "separator", "slider", "switch", "tabs", "toast", "toggle", "toggle-group", "toolbar", "tooltip", ], colors: [ "amber", "blue", "bronze", "brown", "crimson", "cyan", "grass", "gray", "green", "indigo", "lime", "mauve", "mint", "orange", "pink", "plum", "purple", "red", "sage", "sky", "slate", "teal", "tomato", "violet", "yellow", ], }; return fallbacks[library]; } /** * Set or update GitHub API key for higher rate limits * @param apiKey GitHub Personal Access Token */ function setGitHubApiKey(apiKey) { // Create a new instance with updated headers if (apiKey && apiKey.trim()) { // Update the default headers for the existing instance // Note: Ky doesn't support dynamic header updates like Axios // We would need to recreate the instance or use extend() logInfo("GitHub API key updated successfully"); } else { logInfo("GitHub API key removed - using unauthenticated requests"); } } async function getPrimitivesUsage(componentName) { const response = await githubLimit(() => ky.get(`https://raw.githubusercontent.com/${RADIX_OWNER}/website/${REPO_BRANCH}/data/${PRIMITIVES_REPO}/docs/components/${componentName.toLowerCase()}.mdx`)); return await response.text(); } async function getThemesUsage(componentName) { const response = await githubLimit(() => ky.get(`https://raw.githubusercontent.com/${RADIX_OWNER}/website/${REPO_BRANCH}/data/${THEMES_REPO}/docs/components/${componentName.toLowerCase()}.mdx`)); return await response.text(); } /** * Fetch Radix Themes getting started guide from the official website * @returns Promise with getting started guide content */ async function getThemesGettingStarted() { const response = await githubLimit(() => ky.get(`https://raw.githubusercontent.com/${RADIX_OWNER}/website/${REPO_BRANCH}/data/${THEMES_REPO}/docs/overview/getting-started.mdx`)); return await response.text(); } /** * Fetch Radix Primitives getting started guide from the official website * @returns Promise with getting started guide content */ async function getPrimitivesGettingStarted() { const response = await githubLimit(() => ky.get(`https://raw.githubusercontent.com/${RADIX_OWNER}/website/${REPO_BRANCH}/data/${PRIMITIVES_REPO}/docs/overview/getting-started.mdx`)); return await response.text(); } /** * Fetch Radix Colors installation guide from the official website * @returns Promise with installation guide content */ async function getColorsGettingStarted() { const response = await githubLimit(() => ky.get(`https://raw.githubusercontent.com/${RADIX_OWNER}/website/${REPO_BRANCH}/data/colors/docs/overview/installation.mdx`)); return await response.text(); } /** * Fetch Radix Colors documentation files and merge them into a single object (internal implementation) * @returns Promise with merged documentation object */ async function _getColorsDocumentation() { const documentationFiles = [ "overview/usage.mdx", "overview/custom-palettes.mdx", "overview/aliasing.mdx", "palette-composition/scales.mdx", "palette-composition/understanding-the-scale.mdx", "palette-composition/composing-a-palette.mdx", ]; const documentation = {}; for (const file of documentationFiles) { try { const response = await githubLimit(() => ky.get(`https://raw.githubusercontent.com/${RADIX_OWNER}/website/${REPO_BRANCH}/data/colors/docs/${file}`)); const content = await response.text(); const key = file.replace(".mdx", "").replace("/", "_"); documentation[key] = content; } catch (error) { logError(`Failed to fetch ${file}`, error); documentation[file.replace(".mdx", "").replace("/", "_")] = `Error fetching ${file}: ${error}`; } } const consolidatedDocumentation = Object.values(documentation).join("\n\n"); return consolidatedDocumentation; } /** * Fetch Radix Colors documentation files and merge them into a single object (memoized) * @returns Promise with merged documentation object */ const getColorsDocumentation = pMemoize(_getColorsDocumentation, { cache: createCache(), }); /** * Fetch Radix Colors scale TypeScript source code (internal implementation) * @param scaleName Name of the color scale file (e.g., "light", "dark") * @returns Promise with color scale source code */ async function _getColorsScaleSource(scaleName) { try { const response = await githubLimit(() => colorsRaw.get(`src/${scaleName}.ts`)); return await response.text(); } catch (error) { logError(`Failed to fetch color scale ${scaleName}`, error); throw new Error(`Color scale "${scaleName}" not found in repository`); } } /** * Fetch Radix Colors scale TypeScript source code (memoized) * @param scaleName Name of the color scale file (e.g., "light", "dark") * @returns Promise with color scale source code */ const getColorsScaleSource = pMemoize(_getColorsScaleSource, { cache: createCache(), cacheKey: ([scaleName]) => `radix-colors-scale-${scaleName}`, }); /** * Parse TypeScript content to extract color scale names * @param tsContent TypeScript file content * @returns Array of unique color scale names */ function parseColorScaleNames(tsContent) { const scaleNames = new Set(); // Match export const declarations like "export const blue = {" or "export const blueA = {" const exportRegex = /export\s+const\s+([a-zA-Z][a-zA-Z0-9]*)\s*=/g; let match; while ((match = exportRegex.exec(tsContent)) !== null) { const scaleName = match[1]; // Special cases: blackA and whiteA are base scales, not variants if (scaleName === "blackA" || scaleName === "whiteA") { scaleNames.add(scaleName); } else if (!scaleName.endsWith("A") && !scaleName.includes("P3")) { // This is a base scale name (not a variant) scaleNames.add(scaleName); } } return Array.from(scaleNames).sort(); } /** * Parse TypeScript content to extract specific color scale data * @param tsContent TypeScript file content * @param scaleName Name of the scale to extract (e.g., "blue") * @returns Object with all variants of the scale found in the content */ function parseSpecificColorScale(tsContent, scaleName) { const scaleData = {}; // Create regex patterns for all possible variants of the scale const patterns = [ `${scaleName}`, // blue `${scaleName}A`, // blueA `${scaleName}P3`, // blueP3 `${scaleName}P3A`, // blueP3A ]; // Also handle special cases for blackA and whiteA if (scaleName === "blackA" || scaleName === "whiteA") { patterns.length = 0; // Clear the array patterns.push(scaleName); // Only look for the exact name } for (const pattern of patterns) { // Match export const pattern = { ... }; with multiline support const regex = new RegExp(`export\\s+const\\s+${pattern}\\s*=\\s*\\{([^}]+)\\}`, "gs"); const match = regex.exec(tsContent); if (match) { const colorDefinitions = match[1]; const colors = {}; // Parse individual color definitions like: blue1: "#fbfdff", const colorRegex = /(\w+):\s*["']([^"']+)["'],?/g; let colorMatch; while ((colorMatch = colorRegex.exec(colorDefinitions)) !== null) { colors[colorMatch[1]] = colorMatch[2]; } if (Object.keys(colors).length > 0) { scaleData[pattern] = colors; } } } return scaleData; } /** * Fetch complete color scale data including all variants (internal implementation) * @param scaleName Name of the color scale (e.g., "blue", "blackA") * @returns Promise with complete scale data from all files */ async function _getCompleteColorScale(scaleName) { const result = { scaleName }; try { // Handle overlay scales (blackA, whiteA) - they're in separate files if (scaleName === "blackA" || scaleName === "whiteA") { const overlayContent = await getColorsScaleSource(scaleName); result.overlay = parseSpecificColorScale(overlayContent, scaleName); } else { // Handle regular scales - they're in light.ts and dark.ts const [lightContent, darkContent] = await Promise.all([ getColorsScaleSource("light").catch(() => ""), getColorsScaleSource("dark").catch(() => ""), ]); if (lightContent) { const lightData = parseSpecificColorScale(lightContent, scaleName); if (Object.keys(lightData).length > 0) { result.light = lightData; } } if (darkContent) { const darkData = parseSpecificColorScale(darkContent, scaleName); if (Object.keys(darkData).length > 0) { result.dark = darkData; } } } return result; } catch (error) { logError(`Failed to fetch complete color scale ${scaleName}`, error); throw new Error(`Color scale "${scaleName}" not found`); } } /** * Fetch complete color scale data including all variants (memoized) * @param scaleName Name of the color scale (e.g., "blue", "blackA") * @returns Promise with complete scale data from all files */ const getCompleteColorScale = pMemoize(_getCompleteColorScale, { cache: createCache(), cacheKey: ([scaleName]) => `radix-complete-scale-${scaleName}`, }); /** * Fetch and parse all Radix Colors TypeScript files to extract unique scale names (internal implementation) * @returns Promise with array of unique color scale names */ async function _getColorsScaleNames() { try { // Get list of TypeScript files in src folder const files = await getAvailableComponents("colors"); // Fetch content of all TypeScript files const fileContents = await Promise.all(files.map(async (fileName) => { try { return await getColorsScaleSource(fileName); } catch (error) { logError(`Failed to fetch ${fileName}`, error); return ""; } })); // Parse all files and collect unique scale names const allScaleNames = new Set(); fileContents.forEach((content) => { if (content) { const scaleNames = parseColorScaleNames(content); scaleNames.forEach((name) => allScaleNames.add(name)); } }); return Array.from(allScaleNames).sort(); } catch (error) { logError("Failed to fetch color scale names", error); // Return fallback list if API fails return getFallbackComponents("colors"); } } /** * Fetch and parse all Radix Colors TypeScript files to extract unique scale names (memoized) * @returns Promise with array of unique color scale names */ const getColorsScaleNames = pMemoize(_getColorsScaleNames, { cache: createCache(), }); export const http = { githubApi, themesRaw, primitivesRaw, colorsRaw, // Radix-specific functions getThemesComponentSource, getPrimitivesComponentSource, getInstallationGuide, getAvailableComponents, setGitHubApiKey, getPrimitivesUsage, getThemesGettingStarted, getPrimitivesGettingStarted, getColorsGettingStarted, getThemesUsage, getColorsDocumentation, getColorsScaleSource, getColorsScaleNames, getCompleteColorScale, // Path constants for easy access paths: { RADIX_OWNER, THEMES_REPO, PRIMITIVES_REPO, COLORS_REPO, REPO_BRANCH, THEMES_PATHS, PRIMITIVES_PATHS, COLORS_PATHS, }, };