Skip to main content
Glama

MCP Task Manager Server

by bsmi021
TaskService.ts23 kB
import { v4 as uuidv4 } from 'uuid'; import { TaskRepository, TaskData } from '../repositories/TaskRepository.js'; import { ProjectRepository } from '../repositories/ProjectRepository.js'; // Needed to check project existence import { logger } from '../utils/logger.js'; import { NotFoundError, ValidationError } from '../utils/errors.js'; // Using custom errors // Define the input structure for adding a task, based on feature spec export interface AddTaskInput { project_id: string; description: string; dependencies?: string[]; priority?: 'high' | 'medium' | 'low'; status?: 'todo' | 'in-progress' | 'review' | 'done'; } // Options for listing tasks export interface ListTasksOptions { project_id: string; status?: TaskData['status']; include_subtasks?: boolean; } // Type for task data potentially including nested subtasks export interface StructuredTaskData extends TaskData { subtasks?: StructuredTaskData[]; } // Type for full task details including dependencies and subtasks export interface FullTaskData extends TaskData { dependencies: string[]; subtasks: TaskData[]; // For V1 showTask, just return direct subtasks without their own nesting/deps } // Input for expanding a task export interface ExpandTaskInput { project_id: string; task_id: string; // Parent task ID subtask_descriptions: string[]; force?: boolean; } import { Database as Db } from 'better-sqlite3'; // Import Db type import { ConflictError } from '../utils/errors.js'; // Import ConflictError export class TaskService { private taskRepository: TaskRepository; private projectRepository: ProjectRepository; private db: Db; // Add db instance constructor( db: Db, // Inject Db instance taskRepository: TaskRepository, projectRepository: ProjectRepository ) { this.db = db; // Store db instance this.taskRepository = taskRepository; this.projectRepository = projectRepository; } /** * Adds a new task to a specified project. */ public async addTask(input: AddTaskInput): Promise<TaskData> { logger.info(`[TaskService] Attempting to add task to project: ${input.project_id}`); const projectExists = this.projectRepository.findById(input.project_id); if (!projectExists) { logger.warn(`[TaskService] Project not found: ${input.project_id}`); throw new NotFoundError(`Project with ID ${input.project_id} not found.`); } const taskId = uuidv4(); const now = new Date().toISOString(); const newTaskData: TaskData = { task_id: taskId, project_id: input.project_id, parent_task_id: null, description: input.description, status: input.status ?? 'todo', priority: input.priority ?? 'medium', created_at: now, updated_at: now, }; // TODO: Validate Dependency Existence try { this.taskRepository.create(newTaskData, input.dependencies); logger.info(`[TaskService] Successfully added task ${taskId} to project ${input.project_id}`); return newTaskData; } catch (error) { logger.error(`[TaskService] Error adding task to project ${input.project_id}:`, error); throw error; } } /** * Lists tasks for a project. */ public async listTasks(options: ListTasksOptions): Promise<TaskData[] | StructuredTaskData[]> { logger.info(`[TaskService] Attempting to list tasks for project: ${options.project_id}`, options); const projectExists = this.projectRepository.findById(options.project_id); if (!projectExists) { logger.warn(`[TaskService] Project not found: ${options.project_id}`); throw new NotFoundError(`Project with ID ${options.project_id} not found.`); } try { const allTasks = this.taskRepository.findByProjectId(options.project_id, options.status); if (!options.include_subtasks) { const topLevelTasks = allTasks.filter(task => !task.parent_task_id); logger.info(`[TaskService] Found ${topLevelTasks.length} top-level tasks for project ${options.project_id}`); return topLevelTasks; } else { const taskMap: Map<string, StructuredTaskData> = new Map(); const rootTasks: StructuredTaskData[] = []; for (const task of allTasks) { taskMap.set(task.task_id, { ...task, subtasks: [] }); } for (const task of allTasks) { if (task.parent_task_id && taskMap.has(task.parent_task_id)) { const parent = taskMap.get(task.parent_task_id)!; parent.subtasks!.push(taskMap.get(task.task_id)!); } else if (!task.parent_task_id) { rootTasks.push(taskMap.get(task.task_id)!); } } logger.info(`[TaskService] Found ${rootTasks.length} structured root tasks for project ${options.project_id}`); return rootTasks; } } catch (error) { logger.error(`[TaskService] Error listing tasks for project ${options.project_id}:`, error); throw error; } } /** * Retrieves the full details of a single task. */ public async getTaskById(projectId: string, taskId: string): Promise<FullTaskData> { logger.info(`[TaskService] Attempting to get task ${taskId} for project ${projectId}`); const task = this.taskRepository.findById(projectId, taskId); if (!task) { logger.warn(`[TaskService] Task ${taskId} not found in project ${projectId}`); throw new NotFoundError(`Task with ID ${taskId} not found in project ${projectId}.`); } try { const dependencies = this.taskRepository.findDependencies(taskId); const subtasks = this.taskRepository.findSubtasks(taskId); const fullTaskData: FullTaskData = { ...task, dependencies: dependencies, subtasks: subtasks, }; logger.info(`[TaskService] Successfully retrieved task ${taskId}`); return fullTaskData; } catch (error) { logger.error(`[TaskService] Error retrieving details for task ${taskId}:`, error); throw error; } } /** * Sets the status for one or more tasks within a project. */ public async setTaskStatus(projectId: string, taskIds: string[], status: TaskData['status']): Promise<number> { logger.info(`[TaskService] Attempting to set status to '${status}' for ${taskIds.length} tasks in project ${projectId}`); const projectExists = this.projectRepository.findById(projectId); if (!projectExists) { logger.warn(`[TaskService] Project not found: ${projectId}`); throw new NotFoundError(`Project with ID ${projectId} not found.`); } const existenceCheck = this.taskRepository.checkTasksExist(projectId, taskIds); if (!existenceCheck.allExist) { logger.warn(`[TaskService] One or more tasks not found in project ${projectId}:`, existenceCheck.missingIds); throw new NotFoundError(`One or more tasks not found in project ${projectId}: ${existenceCheck.missingIds.join(', ')}`); } try { const now = new Date().toISOString(); const updatedCount = this.taskRepository.updateStatus(projectId, taskIds, status, now); if (updatedCount !== taskIds.length) { logger.warn(`[TaskService] Expected to update ${taskIds.length} tasks, but ${updatedCount} were affected.`); } logger.info(`[TaskService] Successfully updated status for ${updatedCount} tasks in project ${projectId}`); return updatedCount; } catch (error) { logger.error(`[TaskService] Error setting status for tasks in project ${projectId}:`, error); throw error; } } /** * Expands a parent task by adding new subtasks. * Optionally deletes existing subtasks first if 'force' is true. * Uses a transaction to ensure atomicity. * @param input - Details including parent task ID, project ID, subtask descriptions, and force flag. * @returns The updated parent task details (including new subtasks). * @throws {NotFoundError} If the project or parent task is not found. * @throws {ConflictError} If subtasks exist and force is false. * @throws {Error} If the database operation fails. */ public async expandTask(input: ExpandTaskInput): Promise<FullTaskData> { const { project_id, task_id: parentTaskId, subtask_descriptions, force = false } = input; logger.info(`[TaskService] Attempting to expand task ${parentTaskId} in project ${project_id} with ${subtask_descriptions.length} subtasks (force=${force})`); // Use a transaction for the entire operation const expandTransaction = this.db.transaction(() => { // 1. Validate Parent Task Existence (within the transaction) const parentTask = this.taskRepository.findById(project_id, parentTaskId); if (!parentTask) { logger.warn(`[TaskService] Parent task ${parentTaskId} not found in project ${project_id}`); throw new NotFoundError(`Parent task with ID ${parentTaskId} not found in project ${project_id}.`); } // 2. Check for existing subtasks const existingSubtasks = this.taskRepository.findSubtasks(parentTaskId); // 3. Handle existing subtasks based on 'force' flag if (existingSubtasks.length > 0) { if (!force) { logger.warn(`[TaskService] Conflict: Task ${parentTaskId} already has subtasks and force=false.`); throw new ConflictError(`Task ${parentTaskId} already has subtasks. Use force=true to replace them.`); } else { logger.info(`[TaskService] Force=true: Deleting ${existingSubtasks.length} existing subtasks for parent ${parentTaskId}.`); this.taskRepository.deleteSubtasks(parentTaskId); // Note: Dependencies of deleted subtasks are implicitly handled by ON DELETE CASCADE in schema } } // 4. Create new subtasks const now = new Date().toISOString(); const createdSubtasks: TaskData[] = []; for (const description of subtask_descriptions) { const subtaskId = uuidv4(); const newSubtaskData: TaskData = { task_id: subtaskId, project_id: project_id, parent_task_id: parentTaskId, description: description, // Assuming length validation done by Zod status: 'todo', // Default status priority: 'medium', // Default priority created_at: now, updated_at: now, }; // Use the repository's create method (which handles its own transaction part for task+deps, but is fine here) // We pass an empty array for dependencies as expandTask doesn't set them for new subtasks this.taskRepository.create(newSubtaskData, []); createdSubtasks.push(newSubtaskData); } // 5. Fetch updated parent task details (including new subtasks and existing dependencies) // We re-fetch to get the consistent state after the transaction commits. // Note: This requires the transaction function to return the necessary data. // Alternatively, construct the FullTaskData manually here. Let's construct manually. const dependencies = this.taskRepository.findDependencies(parentTaskId); // Fetch parent's dependencies const finalParentData: FullTaskData = { ...parentTask, // Use data fetched at the start of transaction updated_at: now, // Update timestamp conceptually (though not saved unless status changes) dependencies: dependencies, subtasks: createdSubtasks, // Return the newly created subtasks }; return finalParentData; }); try { // Execute the transaction const result = expandTransaction(); logger.info(`[TaskService] Successfully expanded task ${parentTaskId} with ${subtask_descriptions.length} new subtasks.`); return result; } catch (error) { logger.error(`[TaskService] Error expanding task ${parentTaskId}:`, error); // Re-throw specific errors or generic internal error if (error instanceof NotFoundError || error instanceof ConflictError) { throw error; } throw new Error(`Failed to expand task: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Finds the next available task based on readiness (status 'todo', dependencies 'done') * and prioritization (priority, creation date). * @param projectId - The project ID. * @returns The full details of the next task, or null if no task is ready. * @throws {NotFoundError} If the project is not found. * @throws {Error} If the database operation fails. */ public async getNextTask(projectId: string): Promise<FullTaskData | null> { logger.info(`[TaskService] Attempting to get next task for project ${projectId}`); // 1. Validate Project Existence const projectExists = this.projectRepository.findById(projectId); if (!projectExists) { logger.warn(`[TaskService] Project not found: ${projectId}`); throw new NotFoundError(`Project with ID ${projectId} not found.`); } // 2. Find ready tasks using the repository method try { const readyTasks = this.taskRepository.findReadyTasks(projectId); if (readyTasks.length === 0) { logger.info(`[TaskService] No ready tasks found for project ${projectId}`); return null; // No task is ready } // 3. The first task in the list is the highest priority one due to repo ordering const nextTask = readyTasks[0]; logger.info(`[TaskService] Next task identified: ${nextTask.task_id}`); // 4. Fetch full details (dependencies, subtasks) for the selected task // We could potentially optimize this if findReadyTasks returned more details, // but for separation of concerns, we call getTaskById logic (or similar). // Re-using getTaskById logic: return await this.getTaskById(projectId, nextTask.task_id); } catch (error) { logger.error(`[TaskService] Error getting next task for project ${projectId}:`, error); throw error; // Re-throw repository or other errors } } /** * Updates specific fields of an existing task. * @param input - Contains project ID, task ID, and optional fields to update. * @returns The full details of the updated task. * @throws {ValidationError} If no update fields are provided or if dependencies are invalid. * @throws {NotFoundError} If the project, task, or any specified dependency task is not found. * @throws {Error} If the database operation fails. */ public async updateTask(input: { project_id: string; task_id: string; description?: string; priority?: TaskData['priority']; dependencies?: string[]; }): Promise<FullTaskData> { const { project_id, task_id } = input; logger.info(`[TaskService] Attempting to update task ${task_id} in project ${project_id}`); // 1. Validate that at least one field is being updated if (input.description === undefined && input.priority === undefined && input.dependencies === undefined) { throw new ValidationError("At least one field (description, priority, or dependencies) must be provided for update."); } // 2. Validate Project Existence (using repo method) const projectExists = this.projectRepository.findById(project_id); if (!projectExists) { logger.warn(`[TaskService] Project not found: ${project_id}`); throw new NotFoundError(`Project with ID ${project_id} not found.`); } // 3. Validate Task Existence (using repo method - findById also implicitly checks project scope) // We need the task data anyway if dependencies are involved, so fetch it now. const existingTask = this.taskRepository.findById(project_id, task_id); if (!existingTask) { logger.warn(`[TaskService] Task ${task_id} not found in project ${project_id}`); throw new NotFoundError(`Task with ID ${task_id} not found in project ${project_id}.`); } // 4. Validate Dependency Existence if provided if (input.dependencies !== undefined) { if (input.dependencies.length > 0) { const depCheck = this.taskRepository.checkTasksExist(project_id, input.dependencies); if (!depCheck.allExist) { logger.warn(`[TaskService] Invalid dependencies provided for task ${task_id}:`, depCheck.missingIds); throw new ValidationError(`One or more dependency tasks not found in project ${project_id}: ${depCheck.missingIds.join(', ')}`); } // Also check for self-dependency if (input.dependencies.includes(task_id)) { throw new ValidationError(`Task ${task_id} cannot depend on itself.`); } } // If input.dependencies is an empty array, it means "remove all dependencies" } // 5. Prepare payload for repository const updatePayload: { description?: string; priority?: TaskData['priority']; dependencies?: string[] } = {}; if (input.description !== undefined) updatePayload.description = input.description; if (input.priority !== undefined) updatePayload.priority = input.priority; if (input.dependencies !== undefined) updatePayload.dependencies = input.dependencies; // 6. Call Repository update method try { const now = new Date().toISOString(); // The repo method handles the transaction for task update + dependency replacement const updatedTaskData = this.taskRepository.updateTask(project_id, task_id, updatePayload, now); // 7. Fetch full details (including potentially updated dependencies and existing subtasks) // Re-use logic similar to getTaskById const finalDependencies = this.taskRepository.findDependencies(task_id); const finalSubtasks = this.taskRepository.findSubtasks(task_id); const fullUpdatedTask: FullTaskData = { ...updatedTaskData, // Use the data returned by the update method dependencies: finalDependencies, subtasks: finalSubtasks, }; logger.info(`[TaskService] Successfully updated task ${task_id} in project ${project_id}`); return fullUpdatedTask; } catch (error) { logger.error(`[TaskService] Error updating task ${task_id} in project ${project_id}:`, error); // Re-throw specific errors if needed, otherwise let the generic error propagate if (error instanceof Error && error.message.includes('not found')) { // Map repo's generic error for not found back to specific NotFoundError throw new NotFoundError(error.message); } throw error; // Re-throw other errors (like DB constraint errors or unexpected ones) } } /** * Deletes one or more tasks within a project. * @param projectId - The project ID. * @param taskIds - An array of task IDs to delete. * @returns The number of tasks successfully deleted. * @throws {NotFoundError} If the project or any of the specified tasks are not found. * @throws {Error} If the database operation fails. */ public async deleteTasks(projectId: string, taskIds: string[]): Promise<number> { logger.info(`[TaskService] Attempting to delete ${taskIds.length} tasks from project ${projectId}`); // 1. Validate Project Existence const projectExists = this.projectRepository.findById(projectId); if (!projectExists) { logger.warn(`[TaskService] Project not found: ${projectId}`); throw new NotFoundError(`Project with ID ${projectId} not found.`); } // 2. Validate Task Existence *before* attempting delete // This ensures we report an accurate count and catch non-existent IDs early. const existenceCheck = this.taskRepository.checkTasksExist(projectId, taskIds); if (!existenceCheck.allExist) { logger.warn(`[TaskService] Cannot delete: One or more tasks not found in project ${projectId}:`, existenceCheck.missingIds); // Throw NotFoundError here, as InvalidParams might be confusing if some IDs were valid throw new NotFoundError(`One or more tasks to delete not found in project ${projectId}: ${existenceCheck.missingIds.join(', ')}`); } // 3. Call Repository delete method try { // The repository method handles the actual DELETE operation const deletedCount = this.taskRepository.deleteTasks(projectId, taskIds); // Double-check count (optional, but good sanity check) if (deletedCount !== taskIds.length) { logger.warn(`[TaskService] Expected to delete ${taskIds.length} tasks, but repository reported ${deletedCount} deletions.`); // This might indicate a race condition or unexpected DB behavior, though unlikely with cascade. // For V1, we'll trust the repo count but log the warning. } logger.info(`[TaskService] Successfully deleted ${deletedCount} tasks from project ${projectId}`); return deletedCount; } catch (error) { logger.error(`[TaskService] Error deleting tasks from project ${projectId}:`, error); throw error; // Re-throw database or other errors } } // --- Add other task service methods later --- }

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/bsmi021/mcp-task-manager-server'

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