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.

787 lines (786 loc) 33.3 kB
import { Axios } from "axios"; import { logError, logWarning, logInfo } from './logger.js'; // Constants for the React Native Reusables repository structure (Expo variant) 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, 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, transformResponse: [(data) => data], // Return raw data }); /** * Fetch component source code from the React Native Reusables repository (Expo variant) * @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 (Expo variant)`); } } /** * Fetch component demo/example from the React Native Reusables repository (Expo variant) * @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 (Expo variant)`); } } } /** * Fetch all available components from the repository (Expo variant) * @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 (Expo variant) * 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 (Expo variant) * @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: 'expo', framework: 'react-native-reusables', description: `React Native Reusables ${componentName} component (Expo variant)` }; } catch (error) { logError(`Error getting metadata for ${componentName}`, error); 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 } /** * Recursively builds a directory tree structure from a GitHub repository (Expo variant) * @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 (Expo variant) * 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 (Expo variant)', children: { 'components': { path: `${BASE_PATH}/components`, type: 'directory', description: 'Contains all React Native Reusables UI components (Expo compatible)', note: 'Component files (.tsx) are located here' }, 'hooks': { path: `${BASE_PATH}/hooks`, type: 'directory', description: 'Contains custom React Native hooks (Expo compatible)', note: 'Hook files for component functionality' }, 'lib': { path: `${BASE_PATH}/lib`, type: 'directory', description: 'Contains utility libraries and functions (Expo compatible)' }, 'utils': { path: `${BASE_PATH}/utils`, type: 'directory', description: 'Contains utility functions and helpers (Expo compatible)' } } }; } /** * Enhanced buildDirectoryTree with fallback for rate limits (Expo variant) */ 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 (Expo variant)'); return getBasicStructure(); } // Re-throw other errors throw error; } } /** * Fetch block code from the React Native Reusables repository (Expo variant) * @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} (Expo variant)`, code: code, dependencies: dependencies, componentsUsed: components, size: code.length, lines: code.split('\n').length, usage: `Import and use directly in your Expo 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} (Expo variant)`, 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 (Expo variant)` }); // Use the first file's description as the block description if available if (description && blockStructure.description === `Complex block: ${blockName} (Expo variant)`) { 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; } } /** * 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 (Expo variant)`; } return null; } /** * 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 (Expo variant) * @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 in your Expo application:\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`; usage += `5. Make sure you have Expo SDK configured properly\n`; } else { usage += `\n2. Update import paths as needed\n`; usage += `3. Ensure all dependencies are installed\n`; usage += `4. Make sure you have Expo SDK configured properly\n`; } return usage; } /** * Get all available blocks with categorization (Expo variant) * @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 (Expo compatible)'; blocks.calendar.push(blockInfo); } else if (item.name.includes('dashboard')) { blockInfo.description = 'Dashboard layout with charts, metrics, and data display (Expo compatible)'; blocks.dashboard.push(blockInfo); } else if (item.name.includes('login') || item.name.includes('signin')) { blockInfo.description = 'Authentication and login interface (Expo compatible)'; blocks.login.push(blockInfo); } else if (item.name.includes('sidebar')) { blockInfo.description = 'Navigation sidebar component (Expo compatible)'; blocks.sidebar.push(blockInfo); } else if (item.name.includes('products') || item.name.includes('ecommerce')) { blockInfo.description = 'Product listing and e-commerce components (Expo compatible)'; blocks.products.push(blockInfo); } else if (item.name.includes('auth')) { blockInfo.description = 'Authentication related components (Expo compatible)'; blocks.authentication.push(blockInfo); } else if (item.name.includes('chart') || item.name.includes('graph')) { blockInfo.description = 'Data visualization and chart components (Expo compatible)'; blocks.charts.push(blockInfo); } else if (item.name.includes('mail') || item.name.includes('email')) { blockInfo.description = 'Email and mail interface components (Expo compatible)'; blocks.mail.push(blockInfo); } else if (item.name.includes('music') || item.name.includes('player')) { blockInfo.description = 'Music player and media components (Expo compatible)'; blocks.music.push(blockInfo); } else { blockInfo.description = `${item.name} - Custom React Native UI block (Expo compatible)`; 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 (Expo variant)`, 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 (Expo variant)'); console.error('GitHub API key updated successfully (Expo variant)'); } else { // Remove authorization header if empty key provided delete githubApi.defaults.headers['Authorization']; console.error('GitHub API key removed - using unauthenticated requests (Expo variant)'); 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 } };