Skip to main content
Glama

XC-MCP: XCode CLI wrapper

by conorluddy
project-cache.ts11.2 kB
import { XcodeProject } from '../types/xcode.js'; import { executeCommand, buildXcodebuildCommand } from '../utils/command.js'; import { promises as fs } from 'fs'; import { join, dirname } from 'path'; import { persistenceManager } from '../utils/persistence.js'; export interface BuildConfig { scheme: string; configuration: string; destination?: string; sdk?: string; derivedDataPath?: string; } export interface BuildMetrics { timestamp: Date; config: BuildConfig; success: boolean; duration?: number; errorCount: number; warningCount: number; buildSizeBytes: number; } export interface ProjectInfo { path: string; lastModified: Date; projectData: XcodeProject; preferredScheme?: string; lastSuccessfulConfig?: BuildConfig; } export interface DependencyInfo { lastChecked: Date; packageResolved?: Record<string, unknown>; podfileLock?: string; carthageResolved?: string; } export class ProjectCache { private projectConfigs: Map<string, ProjectInfo> = new Map(); private buildHistory: Map<string, BuildMetrics[]> = new Map(); private dependencyCache: Map<string, DependencyInfo> = new Map(); private cacheMaxAge = 60 * 60 * 1000; // 1 hour default constructor() { // Load persisted state asynchronously without blocking initialization this.loadPersistedState().catch(error => { console.warn('Failed to load project cache state:', error); }); } // Cache management methods setCacheMaxAge(milliseconds: number): void { this.cacheMaxAge = milliseconds; } getCacheMaxAge(): number { return this.cacheMaxAge; } clearCache(): void { this.projectConfigs.clear(); this.buildHistory.clear(); this.dependencyCache.clear(); } async getProjectInfo(projectPath: string, force = false): Promise<ProjectInfo> { const normalizedPath = this.normalizePath(projectPath); // Check if we need to refresh const existing = this.projectConfigs.get(normalizedPath); if (!force && existing && (await this.isProjectCacheValid(existing))) { return existing; } // Get file modification time const stats = await fs.stat(projectPath); const lastModified = stats.mtime; // Fetch project data const command = projectPath.endsWith('.xcworkspace') ? buildXcodebuildCommand('-list', projectPath, { workspace: true, json: true }) : buildXcodebuildCommand('-list', projectPath, { json: true }); const result = await executeCommand(command); if (result.code !== 0) { throw new Error(`Failed to get project info: ${result.stderr}`); } const projectData: XcodeProject = JSON.parse(result.stdout); const projectInfo: ProjectInfo = { path: normalizedPath, lastModified, projectData, preferredScheme: existing?.preferredScheme, lastSuccessfulConfig: existing?.lastSuccessfulConfig, }; this.projectConfigs.set(normalizedPath, projectInfo); return projectInfo; } async getPreferredBuildConfig(projectPath: string): Promise<BuildConfig | null> { const projectInfo = await this.getProjectInfo(projectPath); // Return last successful config if available if (projectInfo.lastSuccessfulConfig) { return projectInfo.lastSuccessfulConfig; } // Generate smart defaults const schemes = projectInfo.projectData.project?.schemes || projectInfo.projectData.workspace?.schemes || []; if (schemes.length === 0) { return null; } // Prefer scheme that matches project name or first scheme const projectName = projectInfo.projectData.project?.name || projectInfo.projectData.workspace?.name; const preferredScheme = projectName && schemes.includes(projectName) ? projectName : schemes[0]; return { scheme: preferredScheme, configuration: 'Debug', // Safe default }; } recordBuildResult( projectPath: string, config: BuildConfig, metrics: Omit<BuildMetrics, 'config'> ): void { const normalizedPath = this.normalizePath(projectPath); const buildMetric: BuildMetrics = { ...metrics, config, }; // Update build history const history = this.buildHistory.get(normalizedPath) || []; history.push(buildMetric); // Keep only last 20 builds if (history.length > 20) { history.splice(0, history.length - 20); } this.buildHistory.set(normalizedPath, history); // Update last successful config if build succeeded if (metrics.success) { const projectInfo = this.projectConfigs.get(normalizedPath); if (projectInfo) { projectInfo.lastSuccessfulConfig = config; projectInfo.preferredScheme = config.scheme; } } // Persist state changes this.persistStateDebounced(); } getBuildHistory(projectPath: string, limit = 10): BuildMetrics[] { const normalizedPath = this.normalizePath(projectPath); const history = this.buildHistory.get(normalizedPath) || []; return history.slice(-limit).reverse(); // Most recent first } getPerformanceTrends(projectPath: string): { avgBuildTime?: number; successRate: number; recentErrorCount: number; buildTimeImprovement?: number; } { const history = this.getBuildHistory(projectPath, 20); if (history.length === 0) { return { successRate: 0, recentErrorCount: 0 }; } const successful = history.filter(h => h.success); const successRate = successful.length / history.length; const durations = successful.map(h => h.duration).filter((d): d is number => d !== undefined); const avgBuildTime = durations.length > 0 ? durations.reduce((sum, d) => sum + d, 0) / durations.length : undefined; const recentErrorCount = history .slice(0, 5) // Last 5 builds .reduce((sum, h) => sum + h.errorCount, 0); // Calculate build time trend (recent vs older builds) let buildTimeImprovement: number | undefined; if (durations.length >= 6) { const recent = durations.slice(0, 3); const older = durations.slice(3, 6); const recentAvg = recent.reduce((s, d) => s + d, 0) / recent.length; const olderAvg = older.reduce((s, d) => s + d, 0) / older.length; buildTimeImprovement = ((olderAvg - recentAvg) / olderAvg) * 100; } return { avgBuildTime, successRate, recentErrorCount, buildTimeImprovement, }; } async getDependencyInfo(projectPath: string): Promise<DependencyInfo | null> { const normalizedPath = this.normalizePath(projectPath); const projectDir = dirname(projectPath); // Check cache first const existing = this.dependencyCache.get(normalizedPath); if (existing && this.isDependencyCacheValid(existing)) { return existing; } const depInfo: DependencyInfo = { lastChecked: new Date(), }; try { // Check for Package.resolved (SPM) const packageResolvedPath = join(projectDir, 'Package.resolved'); try { const packageResolved = JSON.parse(await fs.readFile(packageResolvedPath, 'utf8')); depInfo.packageResolved = packageResolved; } catch { // File doesn't exist or invalid JSON } // Check for Podfile.lock (CocoaPods) const podfileLockPath = join(projectDir, 'Podfile.lock'); try { const podfileLock = await fs.readFile(podfileLockPath, 'utf8'); depInfo.podfileLock = podfileLock; } catch { // File doesn't exist } // Check for Cartfile.resolved (Carthage) const carthagePath = join(projectDir, 'Cartfile.resolved'); try { const carthageResolved = await fs.readFile(carthagePath, 'utf8'); depInfo.carthageResolved = carthageResolved; } catch { // File doesn't exist } this.dependencyCache.set(normalizedPath, depInfo); return depInfo; } catch { return null; } } getCacheStats(): { projectCount: number; buildHistoryCount: number; dependencyCount: number; cacheMaxAgeMs: number; cacheMaxAgeHuman: string; } { return { projectCount: this.projectConfigs.size, buildHistoryCount: Array.from(this.buildHistory.values()).reduce( (sum, history) => sum + history.length, 0 ), dependencyCount: this.dependencyCache.size, cacheMaxAgeMs: this.cacheMaxAge, cacheMaxAgeHuman: this.formatDuration(this.cacheMaxAge), }; } private async isProjectCacheValid(projectInfo: ProjectInfo): Promise<boolean> { try { const stats = await fs.stat(projectInfo.path); return stats.mtime.getTime() === projectInfo.lastModified.getTime(); } catch { return false; } } private isDependencyCacheValid(depInfo: DependencyInfo): boolean { const age = Date.now() - depInfo.lastChecked.getTime(); return age < this.cacheMaxAge; } private normalizePath(path: string): string { return path.replace(/\/$/, ''); // Remove trailing slash } private formatDuration(ms: number): string { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) return `${days}d ${hours % 24}h`; if (hours > 0) return `${hours}h ${minutes % 60}m`; if (minutes > 0) return `${minutes}m ${seconds % 60}s`; return `${seconds}s`; } /** * Load persisted state from disk */ private async loadPersistedState(): Promise<void> { if (!persistenceManager.isEnabled()) return; try { const data = await persistenceManager.loadState<{ projectConfigs: Array<[string, ProjectInfo]>; buildHistory: Array<[string, BuildMetrics[]]>; dependencyCache: Array<[string, DependencyInfo]>; }>('projects'); if (data) { // Merge with existing state, preserving in-memory updates this.projectConfigs = new Map(data.projectConfigs || []); this.buildHistory = new Map(data.buildHistory || []); this.dependencyCache = new Map(data.dependencyCache || []); } } catch (error) { console.warn('Failed to load project cache state:', error); // Continue with empty state - graceful degradation } } /** * Persist state to disk with debouncing */ private saveStateTimeout: NodeJS.Timeout | null = null; private persistStateDebounced(): void { if (!persistenceManager.isEnabled()) return; // Clear existing timeout if (this.saveStateTimeout) { clearTimeout(this.saveStateTimeout); } // Debounce saves to avoid excessive disk I/O this.saveStateTimeout = setTimeout(async () => { try { await persistenceManager.saveState('projects', { projectConfigs: Array.from(this.projectConfigs.entries()), buildHistory: Array.from(this.buildHistory.entries()), dependencyCache: Array.from(this.dependencyCache.entries()), }); this.saveStateTimeout = null; } catch (error) { console.warn('Failed to persist project cache state:', error); } }, 1000); } } // Global project cache instance export const projectCache = new ProjectCache();

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/conorluddy/xc-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server