UNPKG

rnr-mcp-server

Version:

A Model Context Protocol (MCP) server for React Native Reusables components, providing AI assistants with access to component source code, demos, and metadata for React Native development.

807 lines (806 loc) 33 kB
import { Axios } from "axios"; import { logError, logWarning, logInfo } from "./logger.js"; // Constants for the React Native Reusables repository structure const REPO_OWNER = "mrzachnugent"; const REPO_NAME = "react-native-reusables"; const REPO_BRANCH = "main"; const BASE_PATH = "packages/ui/src"; const COMPONENTS_PATH = `${BASE_PATH}/components`; // 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; ReactNativeReusablesMcpServer/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; ReactNativeReusablesMcpServer/1.0.0)", }, timeout: 30000, // Increased from 15000 to 30000 (30 seconds) transformResponse: [(data) => data], // Return raw data }); /** * Fetch component source code from the React Native Reusables repository * @param componentName Name of the component * @returns Promise with component source code */ async function getComponentSource(componentName) { const componentPath = `${COMPONENTS_PATH}/${componentName.toLowerCase()}.tsx`; try { const response = await githubRaw.get(`/${componentPath}`); return response.data; } catch (error) { throw new Error(`Component "${componentName}" not found in React Native Reusables repository`); } } /** * Fetch component demo/example from the React Native Reusables repository * @param componentName Name of the component * @returns Promise with component demo code */ async function getComponentDemo(componentName) { const demoPath = `${COMPONENTS_PATH}/${componentName.toLowerCase()}/demo.tsx`; try { const response = await githubRaw.get(`/${demoPath}`); return response.data; } catch (error) { // Try alternative demo path const altDemoPath = `${COMPONENTS_PATH}/${componentName.toLowerCase()}/example.tsx`; try { const response = await githubRaw.get(`/${altDemoPath}`); return response.data; } catch (altError) { throw new Error(`Demo for component "${componentName}" not found in React Native Reusables repository`); } } } /** * Fetch all available components from the repository * @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/${COMPONENTS_PATH}`); if (!response.data || !Array.isArray(response.data)) { throw new Error("Invalid response from GitHub API"); } const components = response.data .filter((item) => item.type === "file" && item.name.endsWith(".tsx")) .map((item) => item.name.replace(".tsx", "")); if (components.length === 0) { throw new Error("No components found in the repository"); } 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 ${COMPONENTS_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 components logWarning("Using fallback component list due to API issues"); return getFallbackComponents(); } } /** * Fallback list of known React Native Reusables components * This is used when the GitHub API is unavailable */ function getFallbackComponents() { return [ "accordion", "alert", "alert-dialog", "aspect-ratio", "avatar", "badge", "button", "card", "checkbox", "collapsible", "context-menu", "dialog", "dropdown-menu", "hover-card", "input", "label", "menubar", "navigation-menu", "popover", "progress", "radio-group", "select", "separator", "skeleton", "switch", "table", "tabs", "text", "textarea", "toggle", "toggle-group", "tooltip", "typography", ]; } /** * Fetch component metadata from the repository * @param componentName Name of the component * @returns Promise with component metadata */ async function getComponentMetadata(componentName) { try { // Try to get component index file const indexPath = `${COMPONENTS_PATH}/index.ts`; const response = await githubRaw.get(`/${indexPath}`); const indexContent = response.data; // Parse component metadata from index file const componentRegex = new RegExp(`export\\s+\\*\\s+from\\s+['"]\\./${componentName}['"]`, "g"); const match = indexContent.match(componentRegex); if (!match) { return null; } // Get component source to extract more metadata const componentSource = await getComponentSource(componentName); // Extract dependencies from component source const dependencies = extractDependencies(componentSource); return { name: componentName, type: "component", dependencies: dependencies, platform: "react-native", framework: "react-native-reusables", description: `React Native Reusables ${componentName} component`, }; } 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 = BASE_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 React Native Reusables without API calls * This is used as a fallback when API rate limits are hit */ function getBasicStructure() { return { path: BASE_PATH, type: "directory", note: "Basic structure provided due to API limitations", children: { components: { path: `${BASE_PATH}/components`, type: "directory", description: "Contains all React Native Reusables UI components", note: "Component files (.tsx) are located here", }, hooks: { path: `${BASE_PATH}/hooks`, type: "directory", description: "Contains custom React Native hooks", note: "Hook files for component functionality", }, lib: { path: `${BASE_PATH}/lib`, type: "directory", description: "Contains utility libraries and functions", }, utils: { path: `${BASE_PATH}/utils`, type: "directory", description: "Contains utility functions and helpers", }, }, }; } /** * Extract description from component 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 React Native Reusables 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 = BASE_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 === BASE_PATH) { logWarning("Using fallback directory structure due to rate limit"); return getBasicStructure(); } // Re-throw other errors throw error; } } /** * Fetch block code from the React Native Reusables repository * @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 = `${BASE_PATH}/blocks`; try { // First, check if it's a simple block file (.tsx) try { const simpleBlockResponse = await githubRaw.get(`/${blocksPath}/${blockName}.tsx`); 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 React Native 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 React Native Reusables repository.`); } 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 = `${BASE_PATH}/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(".tsx", ""), 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 React Native 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 React Native Reusables`, 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 React Native Reusables repository"); } 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/danieltgfischer/rnr-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, BASE_PATH, COMPONENTS_PATH, }, };