Skip to main content
Glama
cross-client-sync.js•14.3 kB
/** * Cross-Client Synchronizer * Enables seamless context sharing across different MCP clients */ import { promises as fs } from 'node:fs'; import path from 'path'; import os from 'os'; export class CrossClientSynchronizer { constructor() { this.syncDir = path.join(os.homedir(), '.moidvk', 'sync'); this.clientStates = new Map(); this.syncInterval = null; this.syncIntervalMs = 5000; // 5 seconds this.maxSyncAge = 30 * 60 * 1000; // 30 minutes // Client type mappings this.clientTypes = { 'claude-desktop': { name: 'Claude Desktop', capabilities: ['chat', 'code', 'analysis'], priority: 1, }, 'cursor': { name: 'Cursor', capabilities: ['code', 'edit', 'debug'], priority: 2, }, 'vscode': { name: 'VS Code', capabilities: ['code', 'edit', 'debug', 'terminal'], priority: 3, }, 'cli': { name: 'CLI', capabilities: ['terminal', 'batch', 'script'], priority: 4, }, 'unknown': { name: 'Unknown Client', capabilities: [], priority: 10, }, }; } async initialize() { // Ensure sync directory exists await fs.mkdir(this.syncDir, { recursive: true }); // Clean up old sync files await this.cleanupOldSyncFiles(); // Start sync monitoring this.startSyncMonitoring(); } /** * Register a client and its current state */ async registerClient(clientId, clientType, state) { const clientInfo = { id: clientId, type: clientType, info: this.clientTypes[clientType] || this.clientTypes.unknown, state, lastUpdate: Date.now(), capabilities: this.clientTypes[clientType]?.capabilities || [], }; this.clientStates.set(clientId, clientInfo); // Persist to sync file const syncFile = path.join(this.syncDir, `${clientId}.json`); await fs.writeFile(syncFile, JSON.stringify(clientInfo, null, 2)); return clientInfo; } /** * Update client state */ async updateClientState(clientId, updates) { const client = this.clientStates.get(clientId); if (!client) { throw new Error(`Client ${clientId} not registered`); } // Merge updates client.state = { ...client.state, ...updates }; client.lastUpdate = Date.now(); // Persist changes const syncFile = path.join(this.syncDir, `${clientId}.json`); await fs.writeFile(syncFile, JSON.stringify(client, null, 2)); // Notify other clients of state change await this.notifyStateChange(clientId, updates); return client; } /** * Get all active clients */ async getActiveClients() { await this.refreshClientStates(); const now = Date.now(); const activeClients = []; for (const [clientId, client] of this.clientStates) { if (now - client.lastUpdate < this.maxSyncAge) { activeClients.push(client); } } // Sort by priority return activeClients.sort((a, b) => (a.info.priority || 10) - (b.info.priority || 10) ); } /** * Get synchronized context across all clients */ async getSynchronizedContext() { const clients = await this.getActiveClients(); const context = { activeClients: clients.length, clients: clients.map(c => ({ id: c.id, type: c.type, name: c.info.name, capabilities: c.capabilities, })), sharedState: {}, recentActions: [], activeWorkflows: new Set(), openFiles: new Set(), currentFocus: null, }; // Merge states from all clients for (const client of clients) { // Merge shared state Object.assign(context.sharedState, client.state.sharedState || {}); // Collect recent actions if (client.state.recentActions) { context.recentActions.push(...client.state.recentActions); } // Collect active workflows if (client.state.activeWorkflow) { context.activeWorkflows.add(client.state.activeWorkflow); } // Collect open files if (client.state.openFiles) { client.state.openFiles.forEach(f => context.openFiles.add(f)); } // Determine current focus (most recent update wins) if (client.state.currentFocus) { if (!context.currentFocus || client.lastUpdate > context.currentFocus.timestamp) { context.currentFocus = { file: client.state.currentFocus, client: client.id, timestamp: client.lastUpdate, }; } } } // Sort recent actions by timestamp context.recentActions.sort((a, b) => b.timestamp - a.timestamp); context.recentActions = context.recentActions.slice(0, 50); // Keep last 50 // Convert sets to arrays context.activeWorkflows = Array.from(context.activeWorkflows); context.openFiles = Array.from(context.openFiles); return context; } /** * Share a development artifact across clients */ async shareArtifact(artifact) { const { type, // 'code', 'analysis', 'suggestion', 'error', 'result' source, // Client ID that created the artifact content, metadata = {}, } = artifact; const sharedArtifact = { id: this.generateId(), type, source, content, metadata, timestamp: Date.now(), }; // Save to shared artifacts const artifactFile = path.join( this.syncDir, 'artifacts', `${sharedArtifact.id}.json` ); await fs.mkdir(path.dirname(artifactFile), { recursive: true }); await fs.writeFile(artifactFile, JSON.stringify(sharedArtifact, null, 2)); // Notify clients await this.notifyArtifactShared(sharedArtifact); return sharedArtifact; } /** * Get shared artifacts */ async getSharedArtifacts(options = {}) { const { type, source, since, limit = 20 } = options; const artifactsDir = path.join(this.syncDir, 'artifacts'); try { const files = await fs.readdir(artifactsDir); const artifacts = []; for (const file of files) { if (!file.endsWith('.json')) continue; const content = await fs.readFile( path.join(artifactsDir, file), 'utf-8' ); const artifact = JSON.parse(content); // Apply filters if (type && artifact.type !== type) continue; if (source && artifact.source !== source) continue; if (since && artifact.timestamp < since) continue; artifacts.push(artifact); } // Sort by timestamp descending artifacts.sort((a, b) => b.timestamp - a.timestamp); return artifacts.slice(0, limit); } catch (error) { if (error.code === 'ENOENT') { return []; } throw error; } } /** * Coordinate action across clients */ async coordinateAction(action) { const { type, // 'file_edit', 'tool_run', 'analysis', etc. initiator, // Client ID initiating the action target, // Target client IDs (optional, defaults to all) payload, } = action; const coordination = { id: this.generateId(), type, initiator, target: target || 'all', payload, status: 'pending', timestamp: Date.now(), responses: [], }; // Save coordination request const coordFile = path.join( this.syncDir, 'coordinations', `${coordination.id}.json` ); await fs.mkdir(path.dirname(coordFile), { recursive: true }); await fs.writeFile(coordFile, JSON.stringify(coordination, null, 2)); // Notify target clients await this.notifyCoordinationRequest(coordination); return coordination; } /** * Respond to coordination request */ async respondToCoordination(coordinationId, clientId, response) { const coordFile = path.join( this.syncDir, 'coordinations', `${coordinationId}.json` ); try { const content = await fs.readFile(coordFile, 'utf-8'); const coordination = JSON.parse(content); // Add response coordination.responses.push({ clientId, response, timestamp: Date.now(), }); // Update status if all expected responses received const activeClients = await this.getActiveClients(); const expectedResponses = coordination.target === 'all' ? activeClients.filter(c => c.id !== coordination.initiator).length : coordination.target.length; if (coordination.responses.length >= expectedResponses) { coordination.status = 'completed'; } // Save updated coordination await fs.writeFile(coordFile, JSON.stringify(coordination, null, 2)); return coordination; } catch (error) { if (error.code === 'ENOENT') { throw new Error(`Coordination ${coordinationId} not found`); } throw error; } } /** * Start monitoring for sync updates */ startSyncMonitoring() { if (this.syncInterval) return; this.syncInterval = setInterval(async () => { try { await this.refreshClientStates(); await this.cleanupOldSyncFiles(); } catch (error) { console.warn('Sync monitoring error:', error.message); } }, this.syncIntervalMs); } /** * Stop sync monitoring */ stopSyncMonitoring() { if (this.syncInterval) { clearInterval(this.syncInterval); this.syncInterval = null; } } /** * Refresh client states from disk */ async refreshClientStates() { try { const files = await fs.readdir(this.syncDir); for (const file of files) { if (!file.endsWith('.json')) continue; const clientId = file.replace('.json', ''); const content = await fs.readFile( path.join(this.syncDir, file), 'utf-8' ); const clientInfo = JSON.parse(content); // Only include if not too old if (Date.now() - clientInfo.lastUpdate < this.maxSyncAge) { this.clientStates.set(clientId, clientInfo); } } } catch (error) { if (error.code !== 'ENOENT') { console.warn('Error refreshing client states:', error.message); } } } /** * Clean up old sync files */ async cleanupOldSyncFiles() { const now = Date.now(); const dirs = [this.syncDir, path.join(this.syncDir, 'artifacts'), path.join(this.syncDir, 'coordinations')]; for (const dir of dirs) { try { const files = await fs.readdir(dir); for (const file of files) { if (!file.endsWith('.json')) continue; const filePath = path.join(dir, file); const stats = await fs.stat(filePath); if (now - stats.mtime.getTime() > this.maxSyncAge) { await fs.unlink(filePath); } } } catch (error) { if (error.code !== 'ENOENT') { console.warn(`Error cleaning up ${dir}:`, error.message); } } } } /** * Notify clients of state change */ async notifyStateChange(clientId, changes) { // In a real implementation, this could use IPC, webhooks, or file watching // For now, we'll create a notification file const notification = { type: 'state_change', source: clientId, changes, timestamp: Date.now(), }; const notifFile = path.join( this.syncDir, 'notifications', `${Date.now()}-${clientId}.json` ); await fs.mkdir(path.dirname(notifFile), { recursive: true }); await fs.writeFile(notifFile, JSON.stringify(notification, null, 2)); } /** * Notify clients of shared artifact */ async notifyArtifactShared(artifact) { const notification = { type: 'artifact_shared', artifact, timestamp: Date.now(), }; const notifFile = path.join( this.syncDir, 'notifications', `${Date.now()}-artifact.json` ); await fs.mkdir(path.dirname(notifFile), { recursive: true }); await fs.writeFile(notifFile, JSON.stringify(notification, null, 2)); } /** * Notify clients of coordination request */ async notifyCoordinationRequest(coordination) { const notification = { type: 'coordination_request', coordination, timestamp: Date.now(), }; const notifFile = path.join( this.syncDir, 'notifications', `${Date.now()}-coord.json` ); await fs.mkdir(path.dirname(notifFile), { recursive: true }); await fs.writeFile(notifFile, JSON.stringify(notification, null, 2)); } /** * Get pending notifications for a client */ async getNotifications(clientId, since = 0) { const notifDir = path.join(this.syncDir, 'notifications'); const notifications = []; try { const files = await fs.readdir(notifDir); for (const file of files) { if (!file.endsWith('.json')) continue; const timestamp = parseInt(file.split('-')[0]); if (timestamp <= since) continue; const content = await fs.readFile( path.join(notifDir, file), 'utf-8' ); const notification = JSON.parse(content); // Filter by relevance to client if (notification.source && notification.source === clientId) continue; if (notification.coordination?.initiator === clientId) continue; notifications.push(notification); } return notifications.sort((a, b) => a.timestamp - b.timestamp); } catch (error) { if (error.code === 'ENOENT') { return []; } throw error; } } /** * Generate unique ID */ generateId() { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } /** * Clean up and close */ async close() { this.stopSyncMonitoring(); this.clientStates.clear(); } }

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/moikas-code/moidvk'

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