UNPKG

agent-hub-mcp

Version:

Universal AI agent coordination platform based on Model Context Protocol (MCP)

733 lines (583 loc) 22 kB
import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import { AgentFeatureWork, AgentRegistration, AgentWorkload, Delegation, Feature, FeatureData, FeatureFilters, FeatureStatus, Message, ParentTask, PRIORITY_ORDER, StorageAdapter, Subtask, } from '~/types'; /** * File-based storage implementation for Agent Hub MCP. * Provides persistent storage using JSON files with security validation. */ export class FileStorage implements StorageAdapter { private readonly dataDirectory: string; constructor(dataDirectory = '.agent-hub') { // Expand ~ to home directory if (dataDirectory.startsWith('~/')) { // eslint-disable-next-line no-param-reassign dataDirectory = path.join(os.homedir(), dataDirectory.slice(2)); } this.dataDirectory = path.resolve(dataDirectory); } async init(): Promise<void> { const directories = ['messages', 'agents', 'features']; for (const directory of directories) { await fs.mkdir(path.join(this.dataDirectory, directory), { recursive: true }); } } /** * Validates and sanitizes file path components to prevent directory traversal */ private validatePathComponent(component: string, maxLength = 255): string { if (!component || typeof component !== 'string') { throw new Error('Invalid path component: must be a non-empty string'); } // Remove any path traversal sequences and directory separators const sanitized = component.replace(/\.\./g, '').replace(/[/\\]/g, '').replace(/\0/g, ''); // Remove null bytes // Only allow alphanumeric, dash, underscore, dot (for extensions), and colon (for namespacing) if (!/^[\w.:-]+$/.test(sanitized)) { throw new Error(`Invalid characters in path component: ${component}`); } if (sanitized.length > maxLength) { throw new Error(`Path component too long: ${sanitized.length} > ${maxLength}`); } return sanitized; } /** * Ensures a file path is within the allowed data directory */ private validateFullPath(filePath: string): string { const resolved = path.resolve(filePath); if (!resolved.startsWith(this.dataDirectory)) { throw new Error('Path traversal attempt detected'); } return resolved; } private async readJsonFile<T>(filePath: string): Promise<T | null> { try { const validPath = this.validateFullPath(filePath); const content = await fs.readFile(validPath, 'utf-8'); return JSON.parse(content); } catch { return null; } } private async writeJsonFile(filePath: string, data: any): Promise<void> { const validPath = this.validateFullPath(filePath); // Sanitize the data to prevent prototype pollution const sanitizedData = JSON.parse(JSON.stringify(data)); await fs.writeFile(validPath, JSON.stringify(sanitizedData, null, 2)); } async saveMessage(message: Message): Promise<void> { const safeId = this.validatePathComponent(message.id); const filePath = path.join(this.dataDirectory, 'messages', `${safeId}.json`); await this.writeJsonFile(filePath, message); } async getMessages(filter?: { agent?: string; limit?: number; offset?: number; since?: number; type?: string; }): Promise<Message[]> { const messagesDirectory = path.join(this.dataDirectory, 'messages'); const files = await fs.readdir(messagesDirectory); const messages: Message[] = []; // Try to sort files by modification time for better performance with recent messages // If stat fails (e.g., in tests), fall back to filename order const jsonFiles = files.filter(file => file.endsWith('.json')); let sortedFiles = jsonFiles; try { const fileStats = await Promise.all( jsonFiles.map(async file => { try { const stats = await fs.stat(path.join(messagesDirectory, file)); return { file, stats }; } catch { return { file, stats: null }; } }), ); const validStats = fileStats.filter(item => item.stats); if (validStats.length > 0) { sortedFiles = validStats .toSorted((a, b) => b.stats!.mtime.getTime() - a.stats!.mtime.getTime()) .map(item => item.file); } } catch { // Fall back to original order if stat operations fail sortedFiles = jsonFiles; } let matchingCount = 0; const limit = filter?.limit; const offset = filter?.offset || 0; for (const file of sortedFiles) { const message = await this.readJsonFile<Message>(path.join(messagesDirectory, file)); if (message) { // Apply filters first let passesFilters = true; if (filter) { if (filter.agent && message.to !== filter.agent && message.to !== 'all') { passesFilters = false; } // Exclude broadcast messages sent by the requesting agent (sender shouldn't see their own broadcasts) if (filter.agent && message.to === 'all' && message.from === filter.agent) { passesFilters = false; } if (filter.type && message.type !== filter.type) { passesFilters = false; } if (filter.since && message.timestamp < filter.since) { passesFilters = false; } } // If message passes filters, handle pagination if (passesFilters) { // Skip messages before offset if (matchingCount < offset) { matchingCount++; continue; } messages.push(message); matchingCount++; // Stop if we've reached the limit if (limit && messages.length >= limit) { break; } } } } return messages.sort((a, b) => a.timestamp - b.timestamp); } async getMessage(messageId: string): Promise<Message | undefined> { const safeId = this.validatePathComponent(messageId); const filePath = path.join(this.dataDirectory, 'messages', `${safeId}.json`); const message = await this.readJsonFile<Message>(filePath); return message ?? undefined; } async markMessageAsRead(messageId: string): Promise<void> { const safeId = this.validatePathComponent(messageId); const filePath = path.join(this.dataDirectory, 'messages', `${safeId}.json`); try { const message = await this.readJsonFile<Message>(filePath); if (message && !message.read) { // Only update if message exists and is not already read (idempotent) message.read = true; await this.writeJsonFile(filePath, message); } } catch (error) { // Re-throw with more context for debugging throw new Error( `Failed to mark message ${messageId} as read: ${error instanceof Error ? error.message : String(error)}`, ); } } async saveAgent(agent: AgentRegistration): Promise<void> { const safeId = this.validatePathComponent(agent.id); const filePath = path.join(this.dataDirectory, 'agents', `${safeId}.json`); await this.writeJsonFile(filePath, agent); } async saveAllAgents(agents: AgentRegistration[]): Promise<void> { const agentsDirectory = path.join(this.dataDirectory, 'agents'); // Clear existing agent files const files = await fs.readdir(agentsDirectory); for (const file of files) { if (file.endsWith('.json')) { await fs.unlink(this.validateFullPath(path.join(agentsDirectory, file))); } } // Save new agent list for (const agent of agents) { await this.saveAgent(agent); } } async getAgents(agentId?: string): Promise<AgentRegistration[]> { const agentsDirectory = path.join(this.dataDirectory, 'agents'); const files = await fs.readdir(agentsDirectory); const agents: AgentRegistration[] = []; for (const file of files) { if (file.endsWith('.json')) { const agent = await this.readJsonFile<AgentRegistration>(path.join(agentsDirectory, file)); if (agent) { if (agentId && agent.id !== agentId) { continue; } agents.push(agent); } } } return agents; } async findAgentById(agentId: string): Promise<AgentRegistration | undefined> { const safeId = this.validatePathComponent(agentId); const filePath = path.join(this.dataDirectory, 'agents', `${safeId}.json`); return (await this.readJsonFile<AgentRegistration>(filePath)) || undefined; } async findAgentByProjectPath(projectPath: string): Promise<AgentRegistration | undefined> { const agentsDirectory = path.join(this.dataDirectory, 'agents'); const files = await fs.readdir(agentsDirectory); for (const file of files) { if (file.endsWith('.json')) { const agent = await this.readJsonFile<AgentRegistration>(path.join(agentsDirectory, file)); if (agent && agent.projectPath === projectPath) { return agent; } } } return undefined; } async updateAgent(agentId: string, updates: Partial<AgentRegistration>): Promise<void> { const safeId = this.validatePathComponent(agentId); const filePath = path.join(this.dataDirectory, 'agents', `${safeId}.json`); const agent = await this.readJsonFile<AgentRegistration>(filePath); if (agent) { const updatedAgent = { ...agent, ...updates }; await this.writeJsonFile(filePath, updatedAgent); } } async cleanup(olderThanDays = 7): Promise<void> { const cutoffTime = Date.now() - olderThanDays * 24 * 60 * 60 * 1000; const messagesDirectory = path.join(this.dataDirectory, 'messages'); const files = await fs.readdir(messagesDirectory); for (const file of files) { if (file.endsWith('.json')) { const message = await this.readJsonFile<Message>(path.join(messagesDirectory, file)); if (message && message.timestamp < cutoffTime) { await fs.unlink(this.validateFullPath(path.join(messagesDirectory, file))); } } } } // Features system implementation async createFeature(feature: Feature): Promise<void> { const safeId = this.validatePathComponent(feature.id); const featureDirectory = path.join(this.dataDirectory, 'features', safeId); // Create feature directory structure await fs.mkdir(featureDirectory, { recursive: true }); await fs.mkdir(path.join(featureDirectory, 'tasks'), { recursive: true }); await fs.mkdir(path.join(featureDirectory, 'delegations'), { recursive: true }); await fs.mkdir(path.join(featureDirectory, 'subtasks'), { recursive: true }); const featureFile = path.join(featureDirectory, 'feature.json'); await this.writeJsonFile(featureFile, feature); } async getFeatures(filters?: FeatureFilters): Promise<Feature[]> { const featuresDirectory = path.join(this.dataDirectory, 'features'); try { const featureIds = await fs.readdir(featuresDirectory); const features: Feature[] = []; for (const featureId of featureIds) { const featureFile = path.join(featuresDirectory, featureId, 'feature.json'); const feature = await this.readJsonFile<Feature>(featureFile); if (feature) { // Apply filters if (filters?.status && feature.status !== filters.status) { continue; } if (filters?.priority && feature.priority !== filters.priority) { continue; } if (filters?.createdBy && feature.createdBy !== filters.createdBy) { continue; } if (filters?.agent && !feature.assignedAgents?.includes(filters.agent)) { continue; } features.push(feature); } } // Sort by priority and then by creation time return features.sort((a, b) => { const priorityDiff = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]; if (priorityDiff !== 0) { return priorityDiff; } return b.createdAt - a.createdAt; }); } catch { return []; } } async getFeature(featureId: string): Promise<Feature | undefined> { const safeId = this.validatePathComponent(featureId); const featureFile = path.join(this.dataDirectory, 'features', safeId, 'feature.json'); const result = await this.readJsonFile<Feature>(featureFile); return result || undefined; } async updateFeature(featureId: string, updates: Partial<Feature>): Promise<void> { const feature = await this.getFeature(featureId); if (!feature) { throw new Error(`Feature not found: ${featureId}`); } const updatedFeature = { ...feature, ...updates, updatedAt: Date.now() }; const safeId = this.validatePathComponent(featureId); const featureFile = path.join(this.dataDirectory, 'features', safeId, 'feature.json'); await this.writeJsonFile(featureFile, updatedFeature); } async createTask(featureId: string, task: ParentTask): Promise<void> { const safeFeatureId = this.validatePathComponent(featureId); const safeTaskId = this.validatePathComponent(task.id); const taskFile = path.join( this.dataDirectory, 'features', safeFeatureId, 'tasks', `${safeTaskId}.json`, ); await this.writeJsonFile(taskFile, task); } async getTasksInFeature(featureId: string): Promise<ParentTask[]> { const safeFeatureId = this.validatePathComponent(featureId); const tasksDirectory = path.join(this.dataDirectory, 'features', safeFeatureId, 'tasks'); try { const files = await fs.readdir(tasksDirectory); const tasks: ParentTask[] = []; for (const file of files) { if (file.endsWith('.json')) { const task = await this.readJsonFile<ParentTask>(path.join(tasksDirectory, file)); if (task) { tasks.push(task); } } } return tasks.sort((a, b) => a.createdAt - b.createdAt); } catch { return []; } } async getTask(featureId: string, taskId: string): Promise<ParentTask | undefined> { const safeFeatureId = this.validatePathComponent(featureId); const safeTaskId = this.validatePathComponent(taskId); const taskFile = path.join( this.dataDirectory, 'features', safeFeatureId, 'tasks', `${safeTaskId}.json`, ); const result = await this.readJsonFile<ParentTask>(taskFile); return result || undefined; } async updateTask(featureId: string, taskId: string, updates: Partial<ParentTask>): Promise<void> { const task = await this.getTask(featureId, taskId); if (!task) { throw new Error(`Task not found: ${taskId} in feature ${featureId}`); } const updatedTask = { ...task, ...updates, updatedAt: Date.now() }; const safeFeatureId = this.validatePathComponent(featureId); const safeTaskId = this.validatePathComponent(taskId); const taskFile = path.join( this.dataDirectory, 'features', safeFeatureId, 'tasks', `${safeTaskId}.json`, ); await this.writeJsonFile(taskFile, updatedTask); } async createDelegation(featureId: string, delegation: Delegation): Promise<void> { const safeFeatureId = this.validatePathComponent(featureId); const safeDelegationId = this.validatePathComponent(delegation.id); const delegationFile = path.join( this.dataDirectory, 'features', safeFeatureId, 'delegations', `${safeDelegationId}.json`, ); await this.writeJsonFile(delegationFile, delegation); } async getDelegations(featureId: string, agent?: string): Promise<Delegation[]> { const safeFeatureId = this.validatePathComponent(featureId); const delegationsDirectory = path.join( this.dataDirectory, 'features', safeFeatureId, 'delegations', ); try { const files = await fs.readdir(delegationsDirectory); const delegations: Delegation[] = []; for (const file of files) { if (file.endsWith('.json')) { const delegation = await this.readJsonFile<Delegation>( path.join(delegationsDirectory, file), ); if (delegation) { if (agent && delegation.agent !== agent) { continue; } delegations.push(delegation); } } } return delegations.sort((a, b) => a.createdAt - b.createdAt); } catch { return []; } } async getDelegation(featureId: string, delegationId: string): Promise<Delegation | undefined> { const safeFeatureId = this.validatePathComponent(featureId); const safeDelegationId = this.validatePathComponent(delegationId); const delegationFile = path.join( this.dataDirectory, 'features', safeFeatureId, 'delegations', `${safeDelegationId}.json`, ); const result = await this.readJsonFile<Delegation>(delegationFile); return result || undefined; } async updateDelegation( featureId: string, delegationId: string, updates: Partial<Delegation>, ): Promise<void> { const delegation = await this.getDelegation(featureId, delegationId); if (!delegation) { throw new Error(`Delegation not found: ${delegationId} in feature ${featureId}`); } const updatedDelegation = { ...delegation, ...updates, updatedAt: Date.now() }; const safeFeatureId = this.validatePathComponent(featureId); const safeDelegationId = this.validatePathComponent(delegationId); const delegationFile = path.join( this.dataDirectory, 'features', safeFeatureId, 'delegations', `${safeDelegationId}.json`, ); await this.writeJsonFile(delegationFile, updatedDelegation); } async createSubtask(featureId: string, subtask: Subtask): Promise<void> { const safeFeatureId = this.validatePathComponent(featureId); const safeSubtaskId = this.validatePathComponent(subtask.id); const subtaskFile = path.join( this.dataDirectory, 'features', safeFeatureId, 'subtasks', `${safeSubtaskId}.json`, ); await this.writeJsonFile(subtaskFile, subtask); } async getSubtasks(featureId: string, delegationId?: string): Promise<Subtask[]> { const safeFeatureId = this.validatePathComponent(featureId); const subtasksDirectory = path.join(this.dataDirectory, 'features', safeFeatureId, 'subtasks'); try { const files = await fs.readdir(subtasksDirectory); const subtasks: Subtask[] = []; for (const file of files) { if (file.endsWith('.json')) { const subtask = await this.readJsonFile<Subtask>(path.join(subtasksDirectory, file)); if (subtask) { if (delegationId && subtask.delegationId !== delegationId) { continue; } subtasks.push(subtask); } } } return subtasks.sort((a, b) => a.createdAt - b.createdAt); } catch { return []; } } async getSubtask(featureId: string, subtaskId: string): Promise<Subtask | undefined> { const safeFeatureId = this.validatePathComponent(featureId); const safeSubtaskId = this.validatePathComponent(subtaskId); const subtaskFile = path.join( this.dataDirectory, 'features', safeFeatureId, 'subtasks', `${safeSubtaskId}.json`, ); const result = await this.readJsonFile<Subtask>(subtaskFile); return result || undefined; } async updateSubtask( featureId: string, subtaskId: string, updates: Partial<Subtask>, ): Promise<void> { const subtask = await this.getSubtask(featureId, subtaskId); if (!subtask) { throw new Error(`Subtask not found: ${subtaskId} in feature ${featureId}`); } const updatedSubtask = { ...subtask, ...updates, updatedAt: Date.now() }; const safeFeatureId = this.validatePathComponent(featureId); const safeSubtaskId = this.validatePathComponent(subtaskId); const subtaskFile = path.join( this.dataDirectory, 'features', safeFeatureId, 'subtasks', `${safeSubtaskId}.json`, ); await this.writeJsonFile(subtaskFile, updatedSubtask); } async getAgentWorkload(agentId: string): Promise<AgentWorkload> { const featuresDirectory = path.join(this.dataDirectory, 'features'); const activeFeatures: AgentFeatureWork[] = []; try { const featureIds = await fs.readdir(featuresDirectory); for (const featureId of featureIds) { const feature = await this.getFeature(featureId); if (!feature || feature.status !== FeatureStatus.ACTIVE) { continue; } const myDelegations = await this.getDelegations(featureId, agentId); if (myDelegations.length === 0) { continue; } const mySubtasks = await this.getSubtasks(featureId).then(subtasks => subtasks.filter(s => s.createdBy === agentId), ); activeFeatures.push({ featureId, feature, myDelegations, mySubtasks, }); } // Sort by feature priority activeFeatures.sort((a, b) => { const aPriority = PRIORITY_ORDER[a.feature.priority]; const bPriority = PRIORITY_ORDER[b.feature.priority]; return aPriority - bPriority; }); return { activeFeatures }; } catch { return { activeFeatures: [] }; } } async getFeatureData(featureId: string): Promise<FeatureData | undefined> { const feature = await this.getFeature(featureId); if (!feature) { return undefined; } const [tasks, delegations, subtasks] = await Promise.all([ this.getTasksInFeature(featureId), this.getDelegations(featureId), this.getSubtasks(featureId), ]); return { feature, tasks, delegations, subtasks, }; } }