UNPKG

@pimzino/agentic-tools-mcp

Version:

A comprehensive MCP server for task management and agent memories with JSON file storage

401 lines (400 loc) 15 kB
import { promises as fs } from 'fs'; import { join } from 'path'; import { getVersion } from '../../../utils/version.js'; /** * File-based storage implementation using JSON with project-specific directories * Version 2.0: Updated for unified task model with migration support */ export class FileStorage { workingDirectory; storageDir; dataFile; data; constructor(workingDirectory) { this.workingDirectory = workingDirectory; this.storageDir = join(workingDirectory, '.agentic-tools-mcp', 'tasks'); this.dataFile = join(this.storageDir, 'tasks.json'); this.data = { projects: [], tasks: [], subtasks: [], migration: { version: getVersion() } }; } /** * Initialize storage by validating working directory and loading data from file */ async initialize() { try { // Validate that working directory exists await fs.access(this.workingDirectory); } catch (error) { throw new Error(`Working directory does not exist or is not accessible: ${this.workingDirectory}`); } try { // Ensure .agentic-tools-mcp/tasks directory exists await fs.mkdir(this.storageDir, { recursive: true }); // Try to load existing data const fileContent = await fs.readFile(this.dataFile, 'utf-8'); const loadedData = JSON.parse(fileContent); // Ensure migration metadata exists this.data = { projects: loadedData.projects || [], tasks: loadedData.tasks || [], subtasks: loadedData.subtasks || [], migration: loadedData.migration || { version: getVersion() } }; // Check if migration is needed const migrationStatus = await this.getMigrationStatus(); if (migrationStatus.needsMigration) { console.log(`Migration needed: ${migrationStatus.subtaskCount} subtasks to migrate`); // Auto-migrate on load const result = await this.migrateToUnifiedModel(); console.log(`Migration completed: ${result.migratedSubtasks} subtasks migrated`); if (result.errors.length > 0) { console.warn('Migration errors:', result.errors); } } } catch (error) { // File doesn't exist or is invalid, start with empty data await this.save(); } } /** * Save data to file */ async save() { await fs.writeFile(this.dataFile, JSON.stringify(this.data, null, 2)); } /** * Calculate task level in hierarchy */ calculateTaskLevel(task) { if (!task.parentId) return 0; let level = 0; let currentParentId = task.parentId; const visited = new Set(); while (currentParentId && !visited.has(currentParentId)) { visited.add(currentParentId); const parent = this.data.tasks.find(t => t.id === currentParentId); if (!parent) break; level++; currentParentId = parent.parentId; } return level; } /** * Update task levels for all tasks */ updateTaskLevels() { for (const task of this.data.tasks) { task.level = this.calculateTaskLevel(task); } } // Project operations async getProjects() { return [...this.data.projects]; } async getProject(id) { return this.data.projects.find(p => p.id === id) || null; } async createProject(project) { this.data.projects.push(project); await this.save(); return project; } async updateProject(id, updates) { const index = this.data.projects.findIndex(p => p.id === id); if (index === -1) return null; this.data.projects[index] = { ...this.data.projects[index], ...updates }; await this.save(); return this.data.projects[index]; } async deleteProject(id) { const index = this.data.projects.findIndex(p => p.id === id); if (index === -1) return false; this.data.projects.splice(index, 1); // Also delete all related tasks (including nested ones) await this.deleteTasksByProject(id); await this.save(); return true; } // Task operations (unified model) async getTasks(projectId, parentId) { let tasks = [...this.data.tasks]; if (projectId) { tasks = tasks.filter(t => t.projectId === projectId); } if (parentId !== undefined) { tasks = tasks.filter(t => t.parentId === parentId); } // Update levels before returning this.updateTaskLevels(); return tasks; } async getTask(id) { const task = this.data.tasks.find(t => t.id === id) || null; if (task) { task.level = this.calculateTaskLevel(task); } return task; } async createTask(task) { // Validate parent exists if specified if (task.parentId) { const parent = await this.getTask(task.parentId); if (!parent) { throw new Error(`Parent task with id ${task.parentId} not found`); } // Ensure task belongs to same project as parent if (parent.projectId !== task.projectId) { throw new Error(`Task must belong to same project as parent task`); } } task.level = this.calculateTaskLevel(task); this.data.tasks.push(task); await this.save(); return task; } async updateTask(id, updates) { const index = this.data.tasks.findIndex(t => t.id === id); if (index === -1) return null; const task = this.data.tasks[index]; // If updating parentId, validate the new parent if (updates.parentId !== undefined) { if (updates.parentId) { const parent = await this.getTask(updates.parentId); if (!parent) { throw new Error(`Parent task with id ${updates.parentId} not found`); } // Prevent circular references if (await this.wouldCreateCircularReference(id, updates.parentId)) { throw new Error(`Moving task would create a circular reference`); } } } this.data.tasks[index] = { ...task, ...updates }; this.data.tasks[index].level = this.calculateTaskLevel(this.data.tasks[index]); await this.save(); return this.data.tasks[index]; } async deleteTask(id) { const index = this.data.tasks.findIndex(t => t.id === id); if (index === -1) return false; // Delete all child tasks recursively await this.deleteTasksByParent(id); this.data.tasks.splice(index, 1); await this.save(); return true; } async deleteTasksByProject(projectId) { const tasksToDelete = this.data.tasks.filter(t => t.projectId === projectId); this.data.tasks = this.data.tasks.filter(t => t.projectId !== projectId); await this.save(); return tasksToDelete.length; } async deleteTasksByParent(parentId) { const childTasks = this.data.tasks.filter(t => t.parentId === parentId); let deletedCount = 0; // Recursively delete children first for (const child of childTasks) { deletedCount += await this.deleteTasksByParent(child.id); } // Delete direct children const directChildren = this.data.tasks.filter(t => t.parentId === parentId); this.data.tasks = this.data.tasks.filter(t => t.parentId !== parentId); deletedCount += directChildren.length; await this.save(); return deletedCount; } // Task hierarchy operations async getTaskHierarchy(projectId, parentId) { const tasks = await this.getTasks(projectId, parentId); const hierarchies = []; for (const task of tasks) { const children = await this.getTaskHierarchy(projectId, task.id); hierarchies.push({ task, children, depth: task.level || 0 }); } return hierarchies; } async getTaskChildren(taskId) { return this.data.tasks.filter(t => t.parentId === taskId); } async getTaskAncestors(taskId) { const ancestors = []; let currentTask = await this.getTask(taskId); while (currentTask?.parentId) { const parent = await this.getTask(currentTask.parentId); if (!parent) break; ancestors.unshift(parent); currentTask = parent; } return ancestors; } async moveTask(taskId, newParentId) { if (newParentId && await this.wouldCreateCircularReference(taskId, newParentId)) { throw new Error('Moving task would create a circular reference'); } return this.updateTask(taskId, { parentId: newParentId }); } /** * Check if moving a task would create a circular reference */ async wouldCreateCircularReference(taskId, newParentId) { let currentParentId = newParentId; const visited = new Set(); while (currentParentId && !visited.has(currentParentId)) { if (currentParentId === taskId) { return true; } visited.add(currentParentId); const parent = await this.getTask(currentParentId); currentParentId = parent?.parentId; } return false; } // Migration operations async migrateToUnifiedModel() { const errors = []; let migratedCount = 0; if (!this.data.subtasks || this.data.subtasks.length === 0) { // Update migration metadata this.data.migration = { version: getVersion(), migratedAt: new Date().toISOString(), subtasksMigrated: 0 }; await this.save(); return { migratedSubtasks: 0, errors: [] }; } for (const subtask of this.data.subtasks) { try { // Convert subtask to task const task = { id: subtask.id, name: subtask.name, details: subtask.details, projectId: subtask.projectId, parentId: subtask.taskId, // taskId becomes parentId completed: subtask.completed, createdAt: subtask.createdAt, updatedAt: subtask.updatedAt, // Set reasonable defaults for new fields priority: 5, complexity: 3, status: subtask.completed ? 'done' : 'pending' }; // Verify parent task exists const parentExists = this.data.tasks.find(t => t.id === task.parentId); if (!parentExists) { errors.push(`Parent task ${task.parentId} not found for subtask ${subtask.id}`); continue; } // Add to tasks array this.data.tasks.push(task); migratedCount++; } catch (error) { errors.push(`Failed to migrate subtask ${subtask.id}: ${error}`); } } // Clear subtasks array and update migration metadata this.data.subtasks = []; this.data.migration = { version: getVersion(), migratedAt: new Date().toISOString(), subtasksMigrated: migratedCount }; await this.save(); return { migratedSubtasks: migratedCount, errors }; } async getMigrationStatus() { const subtaskCount = this.data.subtasks?.length || 0; const needsMigration = subtaskCount > 0; const version = this.data.migration?.version || 'unknown'; return { needsMigration, subtaskCount, version }; } // Legacy subtask operations (deprecated, for backward compatibility) /** @deprecated Use getTasks with parentId instead */ async getSubtasks(taskId, projectId) { if (!this.data.subtasks) return []; let subtasks = [...this.data.subtasks]; if (taskId) { subtasks = subtasks.filter(s => s.taskId === taskId); } if (projectId) { subtasks = subtasks.filter(s => s.projectId === projectId); } return subtasks; } /** @deprecated Use getTask instead */ async getSubtask(id) { if (!this.data.subtasks) return null; return this.data.subtasks.find(s => s.id === id) || null; } /** @deprecated Use createTask instead */ async createSubtask(subtask) { if (!this.data.subtasks) this.data.subtasks = []; this.data.subtasks.push(subtask); await this.save(); return subtask; } /** @deprecated Use updateTask instead */ async updateSubtask(id, updates) { if (!this.data.subtasks) return null; const index = this.data.subtasks.findIndex(s => s.id === id); if (index === -1) return null; this.data.subtasks[index] = { ...this.data.subtasks[index], ...updates }; await this.save(); return this.data.subtasks[index]; } /** @deprecated Use deleteTask instead */ async deleteSubtask(id) { if (!this.data.subtasks) return false; const index = this.data.subtasks.findIndex(s => s.id === id); if (index === -1) return false; this.data.subtasks.splice(index, 1); await this.save(); return true; } /** @deprecated Use deleteTasksByParent instead */ async deleteSubtasksByTask(taskId) { if (!this.data.subtasks) return 0; const subtasksToDelete = this.data.subtasks.filter(s => s.taskId === taskId); this.data.subtasks = this.data.subtasks.filter(s => s.taskId !== taskId); await this.save(); return subtasksToDelete.length; } /** @deprecated Use deleteTasksByProject instead */ async deleteSubtasksByProject(projectId) { if (!this.data.subtasks) return 0; const subtasksToDelete = this.data.subtasks.filter(s => s.projectId === projectId); this.data.subtasks = this.data.subtasks.filter(s => s.projectId !== projectId); await this.save(); return subtasksToDelete.length; } }