mcp-xcode
Version:
MCP server that wraps Xcode command-line tools for iOS/macOS development workflows
238 lines • 9.15 kB
JavaScript
import { randomUUID } from 'crypto';
class ResponseCache {
cache = new Map();
maxAge = 1000 * 60 * 30; // 30 minutes
maxEntries = 100;
store(data) {
const id = randomUUID();
const cached = {
...data,
id,
timestamp: new Date(),
};
this.cache.set(id, cached);
this.cleanup();
return id;
}
get(id) {
const cached = this.cache.get(id);
if (!cached)
return undefined;
// Check if expired
if (Date.now() - cached.timestamp.getTime() > this.maxAge) {
this.cache.delete(id);
return undefined;
}
return cached;
}
getRecentByTool(tool, limit = 5) {
return Array.from(this.cache.values())
.filter(c => c.tool === tool)
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, limit);
}
delete(id) {
return this.cache.delete(id);
}
clear() {
this.cache.clear();
}
cleanup() {
// Remove expired entries
const now = Date.now();
for (const [id, cached] of this.cache) {
if (now - cached.timestamp.getTime() > this.maxAge) {
this.cache.delete(id);
}
}
// Remove oldest entries if over limit
if (this.cache.size > this.maxEntries) {
const entries = Array.from(this.cache.entries()).sort(([, a], [, b]) => a.timestamp.getTime() - b.timestamp.getTime());
const toRemove = entries.slice(0, this.cache.size - this.maxEntries);
for (const [id] of toRemove) {
this.cache.delete(id);
}
}
}
getStats() {
const byTool = {};
for (const cached of this.cache.values()) {
byTool[cached.tool] = (byTool[cached.tool] || 0) + 1;
}
return {
totalEntries: this.cache.size,
byTool,
};
}
}
// Global cache instance
export const responseCache = new ResponseCache();
// Helper functions for common response patterns
export function extractBuildSummary(output, stderr, exitCode) {
const lines = (output + '\n' + stderr).split('\n');
// Extract key metrics
const errors = lines.filter(line => line.includes('error:') || line.includes('** BUILD FAILED **'));
const warnings = lines.filter(line => line.includes('warning:'));
// Look for build success indicator
const successIndicators = lines.filter(line => line.includes('** BUILD SUCCEEDED **') || line.includes('Build completed'));
// Extract timing info if available
const timingMatch = output.match(/Total time: (\d+\.\d+) seconds/);
const duration = timingMatch ? parseFloat(timingMatch[1]) : undefined;
// Extract target/scheme info
const targetMatch = output.match(/Building target (.+?) with configuration/);
const target = targetMatch ? targetMatch[1] : undefined;
return {
success: exitCode === 0 && successIndicators.length > 0,
exitCode,
errorCount: errors.length,
warningCount: warnings.length,
duration,
target,
hasErrors: errors.length > 0,
hasWarnings: warnings.length > 0,
firstError: errors[0]?.trim(),
buildSizeBytes: output.length + stderr.length,
};
}
export function extractTestSummary(output, stderr, exitCode) {
const lines = (output + '\n' + stderr).split('\n');
// Extract test results
const testResults = lines.filter(line => line.includes('Test Suite') ||
line.includes('executed') ||
line.includes('passed') ||
line.includes('failed'));
// Look for test completion
const completionMatch = output.match(/Test Suite .+ (passed|failed)/);
const passed = completionMatch?.[1] === 'passed';
// Extract test counts
const testsRun = (output.match(/(\d+) tests?/g) || [])
.map(match => parseInt(match.match(/(\d+)/)?.[1] || '0'))
.reduce((sum, count) => sum + count, 0);
return {
success: exitCode === 0 && passed,
exitCode,
testsRun,
passed: passed ?? false,
resultSummary: testResults.slice(-3), // Last few result lines
};
}
export function extractSimulatorSummary(cachedList) {
const allDevices = Object.values(cachedList.devices).flat();
const availableDevices = allDevices.filter(d => d.isAvailable);
const bootedDevices = availableDevices.filter(d => d.state === 'Booted');
// Extract device type distribution
const deviceTypeCounts = new Map();
availableDevices.forEach(device => {
const type = extractDeviceType(device.name);
deviceTypeCounts.set(type, (deviceTypeCounts.get(type) || 0) + 1);
});
// Get common runtimes (those with devices)
const activeRuntimes = Object.keys(cachedList.devices)
.filter(runtime => cachedList.devices[runtime].length > 0)
.map(runtime => formatRuntimeName(runtime))
.slice(0, 5); // Top 5 most common
return {
totalDevices: allDevices.length,
availableDevices: availableDevices.length,
bootedDevices: bootedDevices.length,
deviceTypes: Array.from(deviceTypeCounts.keys()).slice(0, 5),
commonRuntimes: activeRuntimes,
lastUpdated: cachedList.lastUpdated,
cacheAge: formatTimeAgo(cachedList.lastUpdated),
bootedList: bootedDevices.map(d => ({
name: d.name,
udid: d.udid,
state: d.state,
runtime: extractRuntimeFromDevice(d, cachedList),
})),
recentlyUsed: availableDevices
.filter(d => d.lastUsed)
.sort((a, b) => new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime())
.slice(0, 3)
.map(d => ({
name: d.name,
udid: d.udid,
lastUsed: formatTimeAgo(d.lastUsed),
})),
};
}
function extractDeviceType(deviceName) {
if (deviceName.includes('iPhone'))
return 'iPhone';
if (deviceName.includes('iPad'))
return 'iPad';
if (deviceName.includes('Apple Watch'))
return 'Apple Watch';
if (deviceName.includes('Apple TV'))
return 'Apple TV';
if (deviceName.includes('Vision'))
return 'Apple Vision Pro';
return 'Other';
}
function formatRuntimeName(runtime) {
// Convert "com.apple.CoreSimulator.SimRuntime.iOS-18-0" to "iOS 18.0"
const match = runtime.match(/iOS-(\d+)-(\d+)/);
if (match) {
return `iOS ${match[1]}.${match[2]}`;
}
// Handle other formats or return as-is
if (runtime.includes('iOS')) {
return runtime.replace('com.apple.CoreSimulator.SimRuntime.', '').replace(/-/g, ' ');
}
return runtime;
}
function extractRuntimeFromDevice(device, cachedList) {
// Find which runtime this device belongs to
for (const [runtimeKey, devices] of Object.entries(cachedList.devices)) {
if (devices.some(d => d.udid === device.udid)) {
return formatRuntimeName(runtimeKey);
}
}
return 'Unknown';
}
function formatTimeAgo(date) {
const now = new Date();
const target = new Date(date);
const diffMs = now.getTime() - target.getTime();
const minutes = Math.floor(diffMs / (1000 * 60));
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (days > 0)
return `${days} day${days > 1 ? 's' : ''} ago`;
if (hours > 0)
return `${hours} hour${hours > 1 ? 's' : ''} ago`;
if (minutes > 0)
return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
return 'Just now';
}
export function createProgressiveSimulatorResponse(summary, cacheId, filters) {
return {
cacheId,
summary: {
totalDevices: summary.totalDevices,
availableDevices: summary.availableDevices,
bootedDevices: summary.bootedDevices,
deviceTypes: summary.deviceTypes,
commonRuntimes: summary.commonRuntimes,
lastUpdated: summary.lastUpdated.toISOString(),
cacheAge: summary.cacheAge,
},
quickAccess: {
bootedDevices: summary.bootedList,
recentlyUsed: summary.recentlyUsed,
recommendedForBuild: summary.bootedList.length > 0 ? [summary.bootedList[0]] : summary.recentlyUsed.slice(0, 1),
},
nextSteps: [
`✅ Found ${summary.availableDevices} available simulators`,
`Use 'simctl-get-details' with cacheId for full device list`,
`Use filters: deviceType=${filters.deviceType || 'iPhone'}, runtime=${filters.runtime || 'iOS 18.5'}`,
],
availableDetails: ['full-list', 'devices-only', 'runtimes-only', 'available-only'],
smartFilters: {
commonDeviceTypes: ['iPhone', 'iPad'],
commonRuntimes: summary.commonRuntimes.slice(0, 2),
suggestedFilters: `deviceType=iPhone runtime='${summary.commonRuntimes[0] || 'iOS 18.5'}'`,
},
};
}
//# sourceMappingURL=response-cache.js.map