# Directory Structure ``` ├── .eslintrc.json ├── .gitignore ├── .prettierrc.json ├── LICENSE ├── package-lock.json ├── package.json ├── public │ └── images │ └── mcp-task-manager-logo.svg ├── README.md ├── src │ ├── config │ │ └── ConfigurationManager.ts │ ├── createServer.ts │ ├── db │ │ ├── DatabaseManager.ts │ │ └── schema.sql │ ├── repositories │ │ ├── ProjectRepository.ts │ │ └── TaskRepository.ts │ ├── server.ts │ ├── services │ │ ├── index.ts │ │ ├── ProjectService.ts │ │ └── TaskService.ts │ ├── tools │ │ ├── addTaskParams.ts │ │ ├── addTaskTool.ts │ │ ├── createProjectParams.ts │ │ ├── createProjectTool.ts │ │ ├── deleteProjectParams.ts │ │ ├── deleteProjectTool.ts │ │ ├── deleteTaskParams.ts │ │ ├── deleteTaskTool.ts │ │ ├── expandTaskParams.ts │ │ ├── expandTaskTool.ts │ │ ├── exportProjectParams.ts │ │ ├── exportProjectTool.ts │ │ ├── getNextTaskParams.ts │ │ ├── getNextTaskTool.ts │ │ ├── importProjectParams.ts │ │ ├── importProjectTool.ts │ │ ├── index.ts │ │ ├── listTasksParams.ts │ │ ├── listTasksTool.ts │ │ ├── setTaskStatusParams.ts │ │ ├── setTaskStatusTool.ts │ │ ├── showTaskParams.ts │ │ ├── showTaskTool.ts │ │ ├── updateTaskParams.ts │ │ └── updateTaskTool.ts │ ├── types │ │ ├── exampleServiceTypes.ts │ │ ├── index.ts │ │ └── taskTypes.ts │ └── utils │ ├── errors.ts │ ├── index.ts │ └── logger.ts ├── tasks.md └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- ```json { "semi": true, "trailingComma": "es5", "singleQuote": false, "printWidth": 80, "tabWidth": 2, "endOfLine": "lf" } ``` -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- ```json { "root": true, "parser": "@typescript-eslint/parser", "plugins": [ "@typescript-eslint", "prettier" ], "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier" // Make sure this is last ], "rules": { "prettier/prettier": "warn", "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], "@typescript-eslint/no-explicit-any": "warn", // Use warn instead of error initially "no-console": "off" // Allow console logging for server apps, or configure properly }, "env": { "node": true, "es2022": true }, "parserOptions": { "ecmaVersion": "latest", "sourceType": "module" } } ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Created by https://www.toptal.com/developers/gitignore/api/node # Edit at https://www.toptal.com/developers/gitignore?templates=node ### Node ### # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional stylelint cache .stylelintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # vuepress v2.x temp and cache directory .temp # Docusaurus cache and generated files .docusaurus # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* ### Node Patch ### # Serverless Webpack directories .webpack/ # Optional stylelint cache # SvelteKit build / generate output .svelte-kit dist/ .generalrules .clinerules .cursorrules # End of https://www.toptal.com/developers/gitignore/api/node .taskmanagerrules docs/ data/taskmanager.db data/taskmanager.* ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # MCP Task Manager Server <div align="center"> <img src="public/images/mcp-task-manager-logo.svg" alt="MCP Task Manager Logo" width="200" height="200" /> </div> A local Model Context Protocol (MCP) server providing backend tools for client-driven project and task management using a SQLite database. ## Overview This server acts as a persistent backend for local MCP clients (like AI agents or scripts) that need to manage structured task data within distinct projects. It handles data storage and provides a standardized set of tools for interaction, while the strategic workflow logic resides within the client. **Key Features:** * **Project-Based:** Tasks are organized within distinct projects. * **SQLite Persistence:** Uses a local SQLite file (`./data/taskmanager.db` by default) for simple, self-contained data storage. * **Client-Driven:** Provides tools for clients; does not dictate workflow. * **MCP Compliant:** Adheres to the Model Context Protocol for tool definition and communication. * **Task Management:** Supports creating projects, adding tasks, listing/showing tasks, updating status, expanding tasks into subtasks, and identifying the next actionable task. * **Import/Export:** Allows exporting project data to JSON and importing from JSON to create new projects. ## Implemented MCP Tools The following tools are available for MCP clients: * **`createProject`**: * **Description:** Creates a new, empty project. * **Params:** `projectName` (string, optional, max 255) * **Returns:** `{ project_id: string }` * **`addTask`**: * **Description:** Adds a new task to a project. * **Params:** `project_id` (string, required, UUID), `description` (string, required, 1-1024), `dependencies` (string[], optional, max 50), `priority` (enum 'high'|'medium'|'low', optional, default 'medium'), `status` (enum 'todo'|'in-progress'|'review'|'done', optional, default 'todo') * **Returns:** Full `TaskData` object of the created task. * **`listTasks`**: * **Description:** Lists tasks for a project, with optional filtering and subtask inclusion. * **Params:** `project_id` (string, required, UUID), `status` (enum 'todo'|'in-progress'|'review'|'done', optional), `include_subtasks` (boolean, optional, default false) * **Returns:** Array of `TaskData` or `StructuredTaskData` objects. * **`showTask`**: * **Description:** Retrieves full details for a specific task, including dependencies and direct subtasks. * **Params:** `project_id` (string, required, UUID), `task_id` (string, required) * **Returns:** `FullTaskData` object. * **`setTaskStatus`**: * **Description:** Updates the status of one or more tasks. * **Params:** `project_id` (string, required, UUID), `task_ids` (string[], required, 1-100), `status` (enum 'todo'|'in-progress'|'review'|'done', required) * **Returns:** `{ success: true, updated_count: number }` * **`expandTask`**: * **Description:** Breaks a parent task into subtasks, optionally replacing existing ones. * **Params:** `project_id` (string, required, UUID), `task_id` (string, required), `subtask_descriptions` (string[], required, 1-20, each 1-512), `force` (boolean, optional, default false) * **Returns:** Updated parent `FullTaskData` object including new subtasks. * **`getNextTask`**: * **Description:** Identifies the next actionable task based on status ('todo'), dependencies ('done'), priority, and creation date. * **Params:** `project_id` (string, required, UUID) * **Returns:** `FullTaskData` object of the next task, or `null` if none are ready. * **`exportProject`**: * **Description:** Exports complete project data as a JSON string. * **Params:** `project_id` (string, required, UUID), `format` (enum 'json', optional, default 'json') * **Returns:** JSON string representing the project. * **`importProject`**: * **Description:** Creates a *new* project from an exported JSON string. * **Params:** `project_data` (string, required, JSON), `new_project_name` (string, optional, max 255) * **Returns:** `{ project_id: string }` of the newly created project. * **`updateTask`**: * **Description:** Updates specific details (description, priority, dependencies) of an existing task. * **Params:** `project_id` (string, required, UUID), `task_id` (string, required, UUID), `description` (string, optional, 1-1024), `priority` (enum 'high'|'medium'|'low', optional), `dependencies` (string[], optional, max 50, replaces existing) * **Returns:** Updated `FullTaskData` object. * **`deleteTask`**: * **Description:** Deletes one or more tasks (and their subtasks/dependency links via cascade). * **Params:** `project_id` (string, required, UUID), `task_ids` (string[], required, 1-100) * **Returns:** `{ success: true, deleted_count: number }` * **`deleteProject`**: * **Description:** Permanently deletes a project and ALL associated data. **Use with caution!** * **Params:** `project_id` (string, required, UUID) * **Returns:** `{ success: true }` *(Note: Refer to the corresponding `src/tools/*Params.ts` files for detailed Zod schemas and parameter descriptions.)* ## Getting Started 1. **Prerequisites:** Node.js (LTS recommended), npm. 2. **Install Dependencies:** ```bash npm install ``` 3. **Run in Development Mode:** (Uses `ts-node` and `nodemon` for auto-reloading) ```bash npm run dev ``` The server will connect via stdio. Logs (JSON format) will be printed to stderr. The SQLite database will be created/updated in `./data/taskmanager.db`. 4. **Build for Production:** ```bash npm run build ``` 5. **Run Production Build:** ```bash npm start ``` ## Configuration * **Database Path:** The location of the SQLite database file can be overridden by setting the `DATABASE_PATH` environment variable. The default is `./data/taskmanager.db`. * **Log Level:** The logging level can be set using the `LOG_LEVEL` environment variable (e.g., `debug`, `info`, `warn`, `error`). The default is `info`. ## Project Structure * `/src`: Source code. * `/config`: Configuration management. * `/db`: Database manager and schema (`schema.sql`). * `/repositories`: Data access layer (SQLite interaction). * `/services`: Core business logic. * `/tools`: MCP tool definitions (*Params.ts) and implementation (*Tool.ts). * `/types`: Shared TypeScript interfaces (currently minimal, mostly in repos/services). * `/utils`: Logging, custom errors, etc. * `createServer.ts`: Server instance creation. * `server.ts`: Main application entry point. * `/dist`: Compiled JavaScript output. * `/docs`: Project documentation (PRD, Feature Specs, RFC). * `/data`: Default location for the SQLite database file (created automatically). * `tasks.md`: Manual task tracking file for development. * Config files (`package.json`, `tsconfig.json`, `.eslintrc.json`, etc.) ## Linting and Formatting * **Lint:** `npm run lint` * **Format:** `npm run format` (Code is automatically linted/formatted on commit via Husky/lint-staged). ``` -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './logger.js'; export * from './errors.js'; // Add other util exports here ``` -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './ProjectService.js'; export * from './TaskService.js'; // Added TaskService export // Remove or comment out ExampleService if it's not being used // export * from './ExampleService.js'; // Add other service exports here ``` -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- ```typescript // Export all types and interfaces from this barrel file export * from './exampleServiceTypes.js'; export * from './taskTypes.js'; // Added export for task types // export * from './yourServiceTypes.js'; // Add new type exports here // Define common types used across services/tools if any export interface CommonContext { sessionId?: string; userId?: string; } ``` -------------------------------------------------------------------------------- /src/types/exampleServiceTypes.ts: -------------------------------------------------------------------------------- ```typescript // Types specific to the ExampleService /** * Configuration options for ExampleService. */ export interface ExampleServiceConfig { greeting: string; enableDetailedLogs: boolean; } /** * Data structure handled by ExampleService. */ export interface ExampleServiceData { name: string; message: string; processedTimestamp: string; metrics?: ExampleServiceMetrics; } /** * Metrics collected during ExampleService processing. */ export interface ExampleServiceMetrics { processingTimeMs: number; } ``` -------------------------------------------------------------------------------- /src/tools/deleteProjectParams.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; export const TOOL_NAME = "deleteProject"; export const TOOL_DESCRIPTION = ` Permanently deletes a project and ALL associated tasks and dependencies. Requires the project ID. This is a highly destructive operation and cannot be undone. Returns a success confirmation upon completion. `; // Zod schema for the parameters, matching FR-013 export const TOOL_PARAMS = z.object({ project_id: z.string() .uuid("The project_id must be a valid UUID.") .describe("The unique identifier (UUID) of the project to permanently delete. This project must exist."), // Required, UUID format }); // Define the expected type for arguments based on the Zod schema export type DeleteProjectArgs = z.infer<typeof TOOL_PARAMS>; ``` -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- ```typescript import { createServer } from "./createServer.js"; import { logger } from "./utils/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; // import { WebSocketServerTransport } from "@modelcontextprotocol/sdk/server/ws.js"; // Example for WebSocket const main = async () => { try { const server = createServer(); logger.info("Starting MCP server"); // Choose your transport const transport = new StdioServerTransport(); // const transport = new WebSocketServerTransport({ port: 8080 }); // Example logger.info("Connecting transport", { transport: transport.constructor.name }); await server.connect(transport); logger.info("MCP Server connected and listening"); } catch (error) { logger.error("Failed to start server", error); process.exit(1); // Exit if server fails to start } }; main(); ``` -------------------------------------------------------------------------------- /src/createServer.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ConfigurationManager } from "./config/ConfigurationManager.js"; import { registerTools } from "./tools/index.js"; import { logger } from "./utils/index.js"; /** * Creates and configures an MCP server instance. * This is the central function for server creation and tool registration. * @returns {McpServer} The configured MCP server instance */ export function createServer(): McpServer { logger.info("Creating MCP server instance"); // Initialize the server const server = new McpServer({ name: "mcp-server", version: "1.0.0", description: "MCP Server based on recommended practices" }); // Get configuration const configManager = ConfigurationManager.getInstance(); // Register all tools registerTools(server); logger.info("MCP server instance created successfully"); return server; } ``` -------------------------------------------------------------------------------- /src/tools/getNextTaskParams.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; export const TOOL_NAME = "getNextTask"; export const TOOL_DESCRIPTION = ` Identifies and returns the next actionable task within a specified project. A task is considered actionable if its status is 'todo' and all its dependencies (if any) have a status of 'done'. If multiple tasks are ready, the one with the highest priority ('high' > 'medium' > 'low') is chosen. If priorities are equal, the task created earliest is chosen. Returns the full details of the next task, or null if no task is currently ready. `; // Zod schema for the parameters, matching FR-007 and getNextTaskTool.md spec export const TOOL_PARAMS = z.object({ project_id: z.string() .uuid("The project_id must be a valid UUID.") .describe("The unique identifier (UUID) of the project to find the next task for."), // Required, UUID format }); // Define the expected type for arguments based on the Zod schema export type GetNextTaskArgs = z.infer<typeof TOOL_PARAMS>; ``` -------------------------------------------------------------------------------- /src/tools/showTaskParams.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; export const TOOL_NAME = "showTask"; export const TOOL_DESCRIPTION = ` Retrieves the full details of a single, specific task, including its dependencies and direct subtasks. Requires the project ID and the task ID. Returns a task object containing all details if found. `; // Zod schema for the parameters, matching FR-004 and showTaskTool.md spec export const TOOL_PARAMS = z.object({ project_id: z.string() .uuid("The project_id must be a valid UUID.") .describe("The unique identifier (UUID) of the project the task belongs to."), // Required, UUID format task_id: z.string() // Add .uuid() if task IDs are also UUIDs, otherwise keep as string .min(1, "Task ID cannot be empty.") .describe("The unique identifier of the task to retrieve details for."), // Required, string (or UUID) }); // Define the expected type for arguments based on the Zod schema export type ShowTaskArgs = z.infer<typeof TOOL_PARAMS>; ``` -------------------------------------------------------------------------------- /src/tools/exportProjectParams.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; export const TOOL_NAME = "exportProject"; export const TOOL_DESCRIPTION = ` Exports the complete data set for a specified project as a JSON string. This includes project metadata, all tasks (hierarchically structured), and their dependencies. Requires the project ID. The format is fixed to JSON for V1. Returns the JSON string representing the project data. `; // Zod schema for the parameters, matching FR-009 and exportProjectTool.md spec export const TOOL_PARAMS = z.object({ project_id: z.string() .uuid("The project_id must be a valid UUID.") .describe("The unique identifier (UUID) of the project to export."), // Required, UUID format format: z.literal('json') // Only allow 'json' for V1 .optional() .default('json') .describe("Optional format for the export. Currently only 'json' is supported (default)."), // Optional, enum (fixed), default }); // Define the expected type for arguments based on the Zod schema export type ExportProjectArgs = z.infer<typeof TOOL_PARAMS>; ``` -------------------------------------------------------------------------------- /src/tools/createProjectParams.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; export const TOOL_NAME = "createProject"; export const TOOL_DESCRIPTION = ` Creates a new, empty project entry in the Task Management Server database. This tool is used by clients (e.g., AI agents) to initiate a new workspace for tasks. It returns the unique identifier (UUID) assigned to the newly created project. An optional name can be provided; otherwise, a default name including a timestamp will be generated. `; // Define the shape of the parameters for the server.tool method export const TOOL_PARAMS = { projectName: z.string() .max(255, "Project name cannot exceed 255 characters.") // Max length constraint .optional() // Optional parameter .describe("Optional human-readable name for the new project (max 255 chars). If omitted, a default name like 'New Project [timestamp]' will be used."), // Detailed description }; // Create a Zod schema object from the shape for validation and type inference const toolParamsSchema = z.object(TOOL_PARAMS); // Define the expected type for arguments based on the Zod schema export type CreateProjectArgs = z.infer<typeof toolParamsSchema>; ``` -------------------------------------------------------------------------------- /src/utils/errors.ts: -------------------------------------------------------------------------------- ```typescript /** * Custom error types for the Task Management Server. * These can be caught in the service layer and mapped to specific * McpError codes in the tool layer. */ // Example: Base service error export class ServiceError extends Error { constructor(message: string, public details?: any) { super(message); this.name = 'ServiceError'; } } // Example: Validation specific error export class ValidationError extends ServiceError { constructor(message: string, details?: any) { super(message, details); this.name = 'ValidationError'; } } // Example: Not found specific error export class NotFoundError extends ServiceError { constructor(message: string = "Resource not found", details?: any) { super(message, details); this.name = 'NotFoundError'; } } // Example: Conflict specific error (e.g., trying to create something that exists) export class ConflictError extends ServiceError { constructor(message: string = "Resource conflict", details?: any) { super(message, details); this.name = 'ConflictError'; } } // Add other custom error types as needed ``` -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- ```typescript import { pino, Logger } from 'pino'; // Try named import for the function /** * Pino logger instance configured for structured JSON logging to stderr. * MCP servers typically use stdout for protocol messages, so logs go to stderr. */ export const logger: Logger = pino( { level: process.env.LOG_LEVEL || 'info', // Default to 'info', configurable via env var formatters: { level: (label: string) => { // Add type for label // Standardize level labels if desired, e.g., uppercase return { level: label.toUpperCase() }; }, // bindings: (bindings) => { // // Add custom bindings if needed, e.g., hostname, pid // return { pid: bindings.pid, hostname: bindings.hostname }; // }, }, timestamp: pino.stdTimeFunctions.isoTime, // Use ISO 8601 timestamps }, pino.destination(2) // Direct output to stderr (file descriptor 2) ); // Example usage (replace console.log/error calls throughout the app): // logger.info('Server starting...'); // logger.debug({ userId: '123' }, 'User logged in'); // logger.error(new Error('Something failed'), 'Failed to process request'); ``` -------------------------------------------------------------------------------- /src/tools/deleteTaskParams.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; export const TOOL_NAME = "deleteTask"; export const TOOL_DESCRIPTION = ` Deletes one or more tasks within a specified project. Requires the project ID and an array of task IDs to delete. Note: Deleting a task also deletes its subtasks and dependency links due to database cascade rules. Returns the count of successfully deleted tasks. `; // Zod schema for the parameters, matching FR-012 export const TOOL_PARAMS = z.object({ project_id: z.string() .uuid("The project_id must be a valid UUID.") .describe("The unique identifier (UUID) of the project containing the tasks to delete. This project must exist."), // Required, UUID format task_ids: z.array( z.string() .uuid("Each task ID must be a valid UUID.") .describe("A unique identifier (UUID) of a task to delete.") ) .min(1, "At least one task ID must be provided.") .max(100, "Cannot delete more than 100 tasks per call.") .describe("An array of task IDs (UUIDs, 1-100) to be deleted from the specified project."), // Required, array of UUID strings, limits }); // Define the expected type for arguments based on the Zod schema export type DeleteTaskArgs = z.infer<typeof TOOL_PARAMS>; ``` -------------------------------------------------------------------------------- /src/tools/importProjectParams.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; export const TOOL_NAME = "importProject"; export const TOOL_DESCRIPTION = ` Creates a *new* project by importing data from a JSON string. The JSON data must conform to the structure previously generated by the 'exportProject' tool. Performs validation on the input data (parsing, basic structure, size limit). Returns the unique project_id of the newly created project upon success. `; // Zod schema for the parameters, matching FR-010 and importProjectTool.md spec export const TOOL_PARAMS = z.object({ project_data: z.string() .min(1, "Project data cannot be empty.") // Size validation happens in the service layer before parsing .describe("Required. A JSON string containing the full project data, conforming to the export structure. Max size e.g., 10MB."), // Required, string new_project_name: z.string() .max(255, "New project name cannot exceed 255 characters.") .optional() .describe("Optional name for the newly created project (max 255 chars). If omitted, a name based on the original project name and import timestamp will be used."), // Optional, string, max length }); // Define the expected type for arguments based on the Zod schema export type ImportProjectArgs = z.infer<typeof TOOL_PARAMS>; ``` -------------------------------------------------------------------------------- /src/tools/listTasksParams.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; export const TOOL_NAME = "listTasks"; export const TOOL_DESCRIPTION = ` Retrieves a list of tasks for a specified project. Allows optional filtering by task status ('todo', 'in-progress', 'review', 'done'). Provides an option to include nested subtasks directly within their parent task objects in the response. Returns an array of task objects. `; // Re-use enum from addTaskParams or define locally if preferred const TaskStatusEnum = z.enum(['todo', 'in-progress', 'review', 'done']); // Zod schema for the parameters, matching FR-003 and listTasksTool.md spec export const TOOL_PARAMS = z.object({ project_id: z.string() .uuid("The project_id must be a valid UUID.") .describe("The unique identifier (UUID) of the project whose tasks are to be listed. This project must exist."), // Required, UUID format status: TaskStatusEnum .optional() .describe("Optional filter to return only tasks matching the specified status."), // Optional, enum include_subtasks: z.boolean() .optional() .default(false) // Default value .describe("Optional flag (default false). If true, the response will include subtasks nested within their parent tasks."), // Optional, boolean, default }); // Define the expected type for arguments based on the Zod schema export type ListTasksArgs = z.infer<typeof TOOL_PARAMS>; ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "mcp-task-manager-server", "version": "0.1.0", "description": "My new MCP Server", "main": "dist/server.js", "type": "module", "scripts": { "start": "node dist/server.js", "build": "tsc && copyfiles -f src/db/*.sql dist/db", "dev": "nodemon --watch src --ext ts --exec \"node --loader ts-node/esm src/server.ts\"", "lint": "eslint . --ext .ts", "format": "prettier --write \"src/**/*.ts\"", "test": "echo \"Error: no test specified\" && exit 1", "prepare": "husky install || true" }, "keywords": [ "mcp", "model-context-protocol" ], "license": "ISC", "dependencies": { "@modelcontextprotocol/sdk": "^1.9.0", "@types/better-sqlite3": "^7.6.13", "@types/inquirer": "^9.0.7", "@types/uuid": "^10.0.0", "better-sqlite3": "^11.9.1", "chalk": "^5.3.0", "inquirer": "^12.5.0", "pino": "^9.6.0", "uuid": "^11.1.0", "zod": "^3.23.8" }, "devDependencies": { "@types/node": "^20.14.2", "@typescript-eslint/eslint-plugin": "^7.13.0", "@typescript-eslint/parser": "^7.13.0", "copyfiles": "^2.4.1", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "husky": "^9.0.11", "lint-staged": "^15.2.5", "nodemon": "^3.1.3", "prettier": "^3.3.2", "ts-node": "^10.9.2", "typescript": "^5.4.5" }, "lint-staged": { "*.ts": [ "eslint --fix", "prettier --write" ] } } ``` -------------------------------------------------------------------------------- /src/tools/setTaskStatusParams.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; export const TOOL_NAME = "setTaskStatus"; export const TOOL_DESCRIPTION = ` Updates the status ('todo', 'in-progress', 'review', 'done') for one or more tasks within a specified project. Requires the project ID, an array of task IDs (1-100), and the target status. Verifies all tasks exist in the project before updating. Returns the count of updated tasks. `; // Re-use enum from other param files const TaskStatusEnum = z.enum(['todo', 'in-progress', 'review', 'done']); // Zod schema for the parameters, matching FR-005 and setTaskStatusTool.md spec export const TOOL_PARAMS = z.object({ project_id: z.string() .uuid("The project_id must be a valid UUID.") .describe("The unique identifier (UUID) of the project containing the tasks."), // Required, UUID format task_ids: z.array( z.string().min(1, "Task ID cannot be empty.") // Add .uuid() if task IDs are UUIDs .describe("A unique identifier of a task to update.") ) .min(1, "At least one task ID must be provided.") .max(100, "Cannot update more than 100 tasks per call.") .describe("An array of task IDs (1-100) whose status should be updated."), // Required, array of strings, limits status: TaskStatusEnum .describe("The target status to set for the specified tasks."), // Required, enum }); // Define the expected type for arguments based on the Zod schema export type SetTaskStatusArgs = z.infer<typeof TOOL_PARAMS>; ``` -------------------------------------------------------------------------------- /tasks.md: -------------------------------------------------------------------------------- ```markdown # Task Manager Server - Development Tasks This file tracks the implementation progress based on the defined milestones. ## Milestone 1: Core Setup & `createProject` Tool - [x] **Create `tasks.md`:** Initial file creation. - [x] **Define DB Schema:** Create `src/db/schema.sql` with tables and indexes. - [x] **Implement DB Manager:** Create `src/db/DatabaseManager.ts` for connection, init, WAL. - [x] **Update Config:** Ensure `src/config/ConfigurationManager.ts` handles DB path. - [x] **Implement Project Repo:** Create `src/repositories/ProjectRepository.ts` with `create` method. - [x] **Implement Project Service:** Create `src/services/ProjectService.ts` with `createProject` method. - [x] **Implement `createProject` Params:** Create `src/tools/createProjectParams.ts`. - [x] **Implement `createProject` Tool:** Create `src/tools/createProjectTool.ts`. - [x] **Implement Utilities:** Create/update `src/utils/logger.ts`, `src/utils/errors.ts`, `src/utils/index.ts`. - [x] **Update Server Setup:** Modify `src/server.ts`, `src/createServer.ts`, `src/tools/index.ts`, `src/services/index.ts`. - [ ] **Write Tests:** Unit test `ProjectService`, Integration test `createProject` tool. *(Skipped/Deferred)* ## Milestone 2: Core Task Management Tools - [x] Implement `addTask` tool (FR-002) - [x] Implement `listTasks` tool (FR-003) - [x] Implement `showTask` tool (FR-004) - [x] Implement `setTaskStatus` tool (FR-005) ## Milestone 3: Advanced & I/O Tools - [x] Implement `expandTask` tool (FR-006) - [x] Implement `getNextTask` tool (FR-007) - [x] Implement `exportProject` tool (FR-009) - [x] Implement `importProject` tool (FR-010) - [x] Implement structured logging (NFR-006). - [x] Finalize documentation (README, tool descriptions). ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ "module": "NodeNext", /* Specify what module code is generated. */ "moduleResolution": "NodeNext", /* Specify how TypeScript looks up a file from a given module specifier. */ "outDir": "./dist", /* Specify an output folder for all emitted files. */ "rootDir": "./src", /* Specify the root folder within your source files. */ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ "strict": true, /* Enable all strict type-checking options. */ "skipLibCheck": true, /* Skip type checking all .d.ts files. */ "resolveJsonModule": true, /* Enable importing .json files */ "sourceMap": true, /* Create source map files for emitted JavaScript files. */ "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ "declarationMap": true, /* Create sourcemaps for d.ts files. */ "allowJs": true, /* Allow JavaScript files to be a part of your program. */ }, "ts-node": { /* ts-node specific options */ "transpileOnly": true, /* Skip type checking for faster execution */ "files": true /* Include files in tsconfig.json */ }, "include": [ "src/**/*" ], /* Specifies an array of filenames or patterns to include in the program */ "exclude": [ "node_modules", "dist" ] /* Specifies an array of filenames or patterns that should be skipped when resolving include */ } ``` -------------------------------------------------------------------------------- /src/types/taskTypes.ts: -------------------------------------------------------------------------------- ```typescript /** * Represents the possible status values for a task. * Using string literal union as per .clinerules (no enums). */ export type TaskStatus = 'todo' | 'in-progress' | 'review' | 'done'; /** * Represents the possible priority levels for a task. * Using string literal union as per .clinerules (no enums). */ export type TaskPriority = 'high' | 'medium' | 'low'; /** * Interface representing a Task object as returned by the API. */ export interface Task { task_id: string; // UUID format project_id: string; // UUID format parent_task_id: string | null; // UUID format or null description: string; status: TaskStatus; priority: TaskPriority; created_at: string; // ISO8601 format updated_at: string; // ISO8601 format dependencies?: string[]; // Array of task_ids this task depends on subtasks?: Task[]; // Array of subtasks (populated if requested, e.g., listTasks with include_subtasks=true) } /** * Interface representing the payload for updating a task (FR-011). * All fields are optional, but at least one must be provided for an update. */ export interface TaskUpdatePayload { description?: string; priority?: TaskPriority; dependencies?: string[]; // Represents the complete new list of dependencies } /** * Interface representing the structure of a Task as stored in the database. * May differ slightly from the API representation (e.g., no nested subtasks/dependencies). */ export interface TaskDbObject { task_id: string; project_id: string; parent_task_id: string | null; description: string; status: TaskStatus; priority: TaskPriority; created_at: string; updated_at: string; } /** * Interface representing a record in the task_dependencies table. */ export interface TaskDependencyDbObject { task_id: string; depends_on_task_id: string; } ``` -------------------------------------------------------------------------------- /public/images/mcp-task-manager-logo.svg: -------------------------------------------------------------------------------- ``` <?xml version="1.0" encoding="UTF-8"?> <svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"> <!-- Background circle --> <circle cx="100" cy="100" r="90" fill="#2a2a2a" /> <!-- Outer ring --> <circle cx="100" cy="100" r="85" fill="none" stroke="#4a86e8" stroke-width="4" /> <!-- Task board grid --> <rect x="50" y="60" width="100" height="80" rx="5" ry="5" fill="#333" stroke="#4a86e8" stroke-width="2" /> <!-- Task columns --> <rect x="55" y="65" width="28" height="70" rx="3" ry="3" fill="#444" /> <rect x="87" y="65" width="28" height="70" rx="3" ry="3" fill="#444" /> <rect x="119" y="65" width="28" height="70" rx="3" ry="3" fill="#444" /> <!-- Task items --> <rect x="57" y="70" width="24" height="8" rx="2" ry="2" fill="#6aa84f" /> <!-- Done --> <rect x="57" y="82" width="24" height="8" rx="2" ry="2" fill="#6aa84f" /> <!-- Done --> <rect x="89" y="70" width="24" height="8" rx="2" ry="2" fill="#f1c232" /> <!-- In Progress --> <rect x="121" y="70" width="24" height="8" rx="2" ry="2" fill="#cc4125" /> <!-- Todo --> <rect x="121" y="82" width="24" height="8" rx="2" ry="2" fill="#cc4125" /> <!-- Todo --> <rect x="121" y="94" width="24" height="8" rx="2" ry="2" fill="#cc4125" /> <!-- Todo --> <!-- "MCP" text --> <text x="100" y="155" font-family="Arial, sans-serif" font-weight="bold" font-size="24" fill="white" text-anchor="middle">MCP TASKS</text> <!-- Database icon to represent SQLite --> <g transform="translate(160, 40) scale(0.6)"> <path d="M10,30 C10,42 30,42 30,30 L30,10 C30,0 10,0 10,10 L10,30 Z" fill="#e69138" /> <rect x="10" y="10" width="20" height="5" fill="#2a2a2a" /> <rect x="10" y="20" width="20" height="5" fill="#2a2a2a" /> </g> <!-- Connection lines representing dependencies --> <path d="M81 74 L89 74" stroke="#4a86e8" stroke-width="1.5" /> <path d="M113 74 L121 74" stroke="#4a86e8" stroke-width="1.5" /> </svg> ``` -------------------------------------------------------------------------------- /src/tools/expandTaskParams.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; export const TOOL_NAME = "expandTask"; export const TOOL_DESCRIPTION = ` Breaks down a specified parent task into multiple subtasks based on provided descriptions. Requires the project ID, the parent task ID, and an array of descriptions for the new subtasks. Optionally allows forcing the replacement of existing subtasks using the 'force' flag. Returns the updated parent task details, including the newly created subtasks. `; // Zod schema for the parameters, matching FR-006 and expandTaskTool.md spec export const TOOL_PARAMS = z.object({ project_id: z.string() .uuid("The project_id must be a valid UUID.") .describe("The unique identifier (UUID) of the project containing the parent task."), // Required, UUID format task_id: z.string() // Add .uuid() if task IDs are also UUIDs .min(1, "Parent task ID cannot be empty.") .describe("The unique identifier of the parent task to be expanded."), // Required, string (or UUID) subtask_descriptions: z.array( z.string() .min(1, "Subtask description cannot be empty.") .max(512, "Subtask description cannot exceed 512 characters.") .describe("A textual description for one of the new subtasks (1-512 characters).") ) .min(1, "At least one subtask description must be provided.") .max(20, "Cannot create more than 20 subtasks per call.") .describe("An array of descriptions (1-20) for the new subtasks to be created under the parent task."), // Required, array of strings, limits force: z.boolean() .optional() .default(false) .describe("Optional flag (default false). If true, any existing subtasks of the parent task will be deleted before creating the new ones. If false and subtasks exist, the operation will fail."), // Optional, boolean, default }); // Define the expected type for arguments based on the Zod schema export type ExpandTaskArgs = z.infer<typeof TOOL_PARAMS>; ``` -------------------------------------------------------------------------------- /src/tools/addTaskParams.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; export const TOOL_NAME = "addTask"; export const TOOL_DESCRIPTION = ` Adds a new task to a specified project within the Task Management Server. Requires the project ID and a description for the task. Optionally accepts a list of dependency task IDs, a priority level, and an initial status. Returns the full details of the newly created task upon success. `; // Allowed enum values for status and priority const TaskStatusEnum = z.enum(['todo', 'in-progress', 'review', 'done']); const TaskPriorityEnum = z.enum(['high', 'medium', 'low']); // Zod schema for the parameters, matching FR-002 and addTaskTool.md spec export const TOOL_PARAMS = z.object({ project_id: z.string() .uuid("The project_id must be a valid UUID.") .describe("The unique identifier (UUID) of the project to add the task to. This project must already exist."), // Required, UUID format description: z.string() .min(1, "Task description cannot be empty.") .max(1024, "Task description cannot exceed 1024 characters.") .describe("The textual description of the task to be performed (1-1024 characters)."), // Required, length limits dependencies: z.array(z.string().describe("A task ID that this new task depends on.")) // Allow any string for now, existence checked in service (or deferred) .max(50, "A task cannot have more than 50 dependencies.") .optional() .describe("An optional list of task IDs (strings) that must be completed before this task can start (max 50)."), // Optional, array of strings, count limit priority: TaskPriorityEnum .optional() .default('medium') // Default value .describe("Optional task priority. Defaults to 'medium' if not specified."), // Optional, enum, default status: TaskStatusEnum .optional() .default('todo') // Default value .describe("Optional initial status of the task. Defaults to 'todo' if not specified."), // Optional, enum, default }); // Define the expected type for arguments based on the Zod schema export type AddTaskArgs = z.infer<typeof TOOL_PARAMS>; ``` -------------------------------------------------------------------------------- /src/tools/exportProjectTool.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, ExportProjectArgs } from "./exportProjectParams.js"; import { ProjectService } from "../services/ProjectService.js"; // Assuming ProjectService is exported via services/index.js import { logger } from '../utils/logger.js'; import { NotFoundError } from "../utils/errors.js"; /** * Registers the exportProject tool with the MCP server. * * @param server - The McpServer instance. * @param projectService - An instance of the ProjectService. */ export const exportProjectTool = (server: McpServer, projectService: ProjectService): void => { const processRequest = async (args: ExportProjectArgs) => { logger.info(`[${TOOL_NAME}] Received request with args:`, args); try { // Zod schema ensures format is 'json' if provided, or defaults to 'json' const jsonString = await projectService.exportProject(args.project_id); // Format the successful response logger.info(`[${TOOL_NAME}] Successfully exported project ${args.project_id}`); return { content: [{ type: "text" as const, text: jsonString // Return the JSON string directly }] }; } catch (error: unknown) { // Handle potential errors logger.error(`[${TOOL_NAME}] Error processing request:`, error); if (error instanceof NotFoundError) { // Project not found throw new McpError(ErrorCode.InvalidParams, error.message); } else { // Generic internal error const message = error instanceof Error ? error.message : 'An unknown error occurred while exporting the project.'; throw new McpError(ErrorCode.InternalError, message); } } }; // Register the tool with the server server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); logger.info(`[${TOOL_NAME}] Tool registered successfully.`); }; ``` -------------------------------------------------------------------------------- /src/tools/showTaskTool.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, ShowTaskArgs } from "./showTaskParams.js"; import { TaskService } from "../services/TaskService.js"; import { logger } from '../utils/logger.js'; import { NotFoundError } from "../utils/errors.js"; /** * Registers the showTask tool with the MCP server. * * @param server - The McpServer instance. * @param taskService - An instance of the TaskService. */ export const showTaskTool = (server: McpServer, taskService: TaskService): void => { const processRequest = async (args: ShowTaskArgs) => { logger.info(`[${TOOL_NAME}] Received request with args:`, args); try { // Call the service method to get the task details const task = await taskService.getTaskById(args.project_id, args.task_id); // Format the successful response logger.info(`[${TOOL_NAME}] Found task ${args.task_id} in project ${args.project_id}`); return { content: [{ type: "text" as const, text: JSON.stringify(task) // Return the full task object }] }; } catch (error: unknown) { // Handle potential errors logger.error(`[${TOOL_NAME}] Error processing request:`, error); if (error instanceof NotFoundError) { // Specific error if the project or task wasn't found // Map to InvalidParams as the provided ID(s) are invalid in this context throw new McpError(ErrorCode.InvalidParams, error.message); } else { // Generic internal error const message = error instanceof Error ? error.message : 'An unknown error occurred while retrieving the task.'; throw new McpError(ErrorCode.InternalError, message); } } }; // Register the tool with the server server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); logger.info(`[${TOOL_NAME}] Tool registered successfully.`); }; ``` -------------------------------------------------------------------------------- /src/db/schema.sql: -------------------------------------------------------------------------------- ```sql -- Database schema for the MCP Task Manager Server -- Based on RFC-2025-001 -- Enable foreign key support PRAGMA foreign_keys = ON; -- Use Write-Ahead Logging for better concurrency PRAGMA journal_mode = WAL; -- Table: projects -- Stores project metadata CREATE TABLE IF NOT EXISTS projects ( project_id TEXT PRIMARY KEY NOT NULL, -- UUID format name TEXT NOT NULL, created_at TEXT NOT NULL -- ISO8601 format (e.g., YYYY-MM-DDTHH:MM:SS.SSSZ) ); -- Table: tasks -- Stores individual task details CREATE TABLE IF NOT EXISTS tasks ( task_id TEXT PRIMARY KEY NOT NULL, -- UUID format project_id TEXT NOT NULL, parent_task_id TEXT NULL, -- For subtasks description TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('todo', 'in-progress', 'review', 'done')), priority TEXT NOT NULL CHECK(priority IN ('high', 'medium', 'low')), created_at TEXT NOT NULL, -- ISO8601 format updated_at TEXT NOT NULL, -- ISO8601 format FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE, FOREIGN KEY (parent_task_id) REFERENCES tasks(task_id) ON DELETE CASCADE ); -- Table: task_dependencies -- Stores prerequisite relationships between tasks CREATE TABLE IF NOT EXISTS task_dependencies ( task_id TEXT NOT NULL, -- The task that depends on another depends_on_task_id TEXT NOT NULL, -- The task that must be completed first PRIMARY KEY (task_id, depends_on_task_id), FOREIGN KEY (task_id) REFERENCES tasks(task_id) ON DELETE CASCADE, FOREIGN KEY (depends_on_task_id) REFERENCES tasks(task_id) ON DELETE CASCADE ); -- Indexes for performance optimization -- Index on tasks table CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority); CREATE INDEX IF NOT EXISTS idx_tasks_parent_task_id ON tasks(parent_task_id); CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at); -- Indexes on task_dependencies table CREATE INDEX IF NOT EXISTS idx_task_dependencies_task_id ON task_dependencies(task_id); CREATE INDEX IF NOT EXISTS idx_task_dependencies_depends_on_task_id ON task_dependencies(depends_on_task_id); ``` -------------------------------------------------------------------------------- /src/tools/listTasksTool.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, ListTasksArgs } from "./listTasksParams.js"; import { TaskService } from "../services/TaskService.js"; import { logger } from '../utils/logger.js'; import { NotFoundError } from "../utils/errors.js"; /** * Registers the listTasks tool with the MCP server. * * @param server - The McpServer instance. * @param taskService - An instance of the TaskService. */ export const listTasksTool = (server: McpServer, taskService: TaskService): void => { const processRequest = async (args: ListTasksArgs) => { logger.info(`[${TOOL_NAME}] Received request with args:`, args); try { // Call the service method to list tasks const tasks = await taskService.listTasks({ project_id: args.project_id, status: args.status, include_subtasks: args.include_subtasks, }); // Format the successful response logger.info(`[${TOOL_NAME}] Found ${tasks.length} tasks for project ${args.project_id}`); return { content: [{ type: "text" as const, text: JSON.stringify(tasks) // Return the array of task objects }] }; } catch (error: unknown) { // Handle potential errors logger.error(`[${TOOL_NAME}] Error processing request:`, error); if (error instanceof NotFoundError) { // Specific error if the project wasn't found throw new McpError(ErrorCode.InvalidParams, error.message); // Map NotFound to InvalidParams for project_id } else { // Generic internal error const message = error instanceof Error ? error.message : 'An unknown error occurred while listing tasks.'; throw new McpError(ErrorCode.InternalError, message); } } }; // Register the tool with the server server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); logger.info(`[${TOOL_NAME}] Tool registered successfully.`); }; ``` -------------------------------------------------------------------------------- /src/tools/getNextTaskTool.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, GetNextTaskArgs } from "./getNextTaskParams.js"; import { TaskService } from "../services/TaskService.js"; import { logger } from '../utils/logger.js'; import { NotFoundError } from "../utils/errors.js"; /** * Registers the getNextTask tool with the MCP server. * * @param server - The McpServer instance. * @param taskService - An instance of the TaskService. */ export const getNextTaskTool = (server: McpServer, taskService: TaskService): void => { const processRequest = async (args: GetNextTaskArgs) => { logger.info(`[${TOOL_NAME}] Received request with args:`, args); try { // Call the service method to get the next task const nextTask = await taskService.getNextTask(args.project_id); // Format the successful response if (nextTask) { logger.info(`[${TOOL_NAME}] Next task found: ${nextTask.task_id} in project ${args.project_id}`); } else { logger.info(`[${TOOL_NAME}] No ready task found for project ${args.project_id}`); } return { content: [{ type: "text" as const, // Return the full task object or null text: JSON.stringify(nextTask) }] }; } catch (error: unknown) { // Handle potential errors logger.error(`[${TOOL_NAME}] Error processing request:`, error); if (error instanceof NotFoundError) { // Project not found throw new McpError(ErrorCode.InvalidParams, error.message); } else { // Generic internal error const message = error instanceof Error ? error.message : 'An unknown error occurred while getting the next task.'; throw new McpError(ErrorCode.InternalError, message); } } }; // Register the tool with the server server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); logger.info(`[${TOOL_NAME}] Tool registered successfully.`); }; ``` -------------------------------------------------------------------------------- /src/tools/setTaskStatusTool.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, SetTaskStatusArgs } from "./setTaskStatusParams.js"; import { TaskService } from "../services/TaskService.js"; import { logger } from '../utils/logger.js'; import { NotFoundError } from "../utils/errors.js"; /** * Registers the setTaskStatus tool with the MCP server. * * @param server - The McpServer instance. * @param taskService - An instance of the TaskService. */ export const setTaskStatusTool = (server: McpServer, taskService: TaskService): void => { const processRequest = async (args: SetTaskStatusArgs) => { logger.info(`[${TOOL_NAME}] Received request with args:`, args); try { // Call the service method to update the status const updatedCount = await taskService.setTaskStatus( args.project_id, args.task_ids, args.status ); // Format the successful response const responsePayload = { success: true, updated_count: updatedCount }; logger.info(`[${TOOL_NAME}] Updated status for ${updatedCount} tasks in project ${args.project_id}`); return { content: [{ type: "text" as const, text: JSON.stringify(responsePayload) }] }; } catch (error: unknown) { // Handle potential errors logger.error(`[${TOOL_NAME}] Error processing request:`, error); if (error instanceof NotFoundError) { // Specific error if the project or any task wasn't found // Map to InvalidParams as the provided ID(s) are invalid throw new McpError(ErrorCode.InvalidParams, error.message); } else { // Generic internal error const message = error instanceof Error ? error.message : 'An unknown error occurred while setting task status.'; throw new McpError(ErrorCode.InternalError, message); } } }; // Register the tool with the server server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); logger.info(`[${TOOL_NAME}] Tool registered successfully.`); }; ``` -------------------------------------------------------------------------------- /src/tools/importProjectTool.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, ImportProjectArgs } from "./importProjectParams.js"; import { ProjectService } from "../services/ProjectService.js"; import { logger } from '../utils/logger.js'; import { ValidationError } from "../utils/errors.js"; // Import specific errors /** * Registers the importProject tool with the MCP server. * * @param server - The McpServer instance. * @param projectService - An instance of the ProjectService. */ export const importProjectTool = (server: McpServer, projectService: ProjectService): void => { const processRequest = async (args: ImportProjectArgs) => { logger.info(`[${TOOL_NAME}] Received request (project name: ${args.new_project_name || 'Default'})`); try { // Call the service method to import the project const result = await projectService.importProject( args.project_data, args.new_project_name ); // Format the successful response const responsePayload = { project_id: result.project_id }; logger.info(`[${TOOL_NAME}] Successfully imported project. New ID: ${result.project_id}`); return { content: [{ type: "text" as const, text: JSON.stringify(responsePayload) }] }; } catch (error: unknown) { // Handle potential errors logger.error(`[${TOOL_NAME}] Error processing request:`, error); if (error instanceof ValidationError) { // JSON parsing, schema validation, size limit, or other data issues throw new McpError(ErrorCode.InvalidParams, error.message); } else { // Generic internal error (likely database related from the transaction) const message = error instanceof Error ? error.message : 'An unknown error occurred during project import.'; throw new McpError(ErrorCode.InternalError, message); } } }; // Register the tool with the server server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); logger.info(`[${TOOL_NAME}] Tool registered successfully.`); }; ``` -------------------------------------------------------------------------------- /src/tools/deleteTaskTool.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, DeleteTaskArgs } from "./deleteTaskParams.js"; import { TaskService } from "../services/TaskService.js"; // Assuming TaskService is exported from index import { logger } from '../utils/logger.js'; import { NotFoundError } from "../utils/errors.js"; // Import custom errors /** * Registers the deleteTask tool with the MCP server. * * @param server - The McpServer instance. * @param taskService - An instance of the TaskService. */ export const deleteTaskTool = (server: McpServer, taskService: TaskService): void => { const processRequest = async (args: DeleteTaskArgs): Promise<{ content: { type: 'text', text: string }[] }> => { logger.info(`[${TOOL_NAME}] Received request to delete ${args.task_ids.length} tasks from project ${args.project_id}`); try { // Call the service method to delete the tasks const deletedCount = await taskService.deleteTasks(args.project_id, args.task_ids); // Format the successful response logger.info(`[${TOOL_NAME}] Successfully deleted ${deletedCount} tasks from project ${args.project_id}`); return { content: [{ type: "text" as const, text: JSON.stringify({ success: true, deleted_count: deletedCount }) }] }; } catch (error: unknown) { // Handle potential errors according to systemPatterns.md mapping logger.error(`[${TOOL_NAME}] Error processing request:`, error); if (error instanceof NotFoundError) { // Project or one/more tasks not found - Map to InvalidParams as per convention throw new McpError(ErrorCode.InvalidParams, error.message); } else { // Generic internal error const message = error instanceof Error ? error.message : 'An unknown error occurred while deleting tasks.'; throw new McpError(ErrorCode.InternalError, message); } } }; // Register the tool with the server server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); // Using .shape as this schema doesn't use .refine() logger.info(`[${TOOL_NAME}] Tool registered successfully.`); }; ``` -------------------------------------------------------------------------------- /src/tools/deleteProjectTool.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, DeleteProjectArgs } from "./deleteProjectParams.js"; import { ProjectService } from "../services/ProjectService.js"; // Assuming ProjectService is exported from index import { logger } from '../utils/logger.js'; import { NotFoundError } from "../utils/errors.js"; // Import custom errors /** * Registers the deleteProject tool with the MCP server. * * @param server - The McpServer instance. * @param projectService - An instance of the ProjectService. */ export const deleteProjectTool = (server: McpServer, projectService: ProjectService): void => { const processRequest = async (args: DeleteProjectArgs): Promise<{ content: { type: 'text', text: string }[] }> => { logger.warn(`[${TOOL_NAME}] Received request to DELETE project ${args.project_id}. This is a destructive operation.`); // Log deletion intent clearly try { // Call the service method to delete the project const success = await projectService.deleteProject(args.project_id); // Format the successful response logger.info(`[${TOOL_NAME}] Successfully deleted project ${args.project_id}`); return { content: [{ type: "text" as const, text: JSON.stringify({ success: success }) // Return true if deleted }] }; } catch (error: unknown) { // Handle potential errors according to systemPatterns.md mapping logger.error(`[${TOOL_NAME}] Error processing request:`, error); if (error instanceof NotFoundError) { // Project not found - Map to InvalidParams as per convention throw new McpError(ErrorCode.InvalidParams, error.message); } else { // Generic internal error const message = error instanceof Error ? error.message : 'An unknown error occurred while deleting the project.'; throw new McpError(ErrorCode.InternalError, message); } } }; // Register the tool with the server server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); // Using .shape as this schema doesn't use .refine() logger.info(`[${TOOL_NAME}] Tool registered successfully.`); }; ``` -------------------------------------------------------------------------------- /src/tools/createProjectTool.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, CreateProjectArgs } from "./createProjectParams.js"; import { ProjectService } from "../services/ProjectService.js"; // Assuming ProjectService is exported from services/index.js or directly import { logger } from '../utils/logger.js'; // Assuming logger exists // Import custom errors if needed for specific mapping // import { ServiceError } from "../utils/errors.js"; /** * Registers the createProject tool with the MCP server. * * @param server - The McpServer instance. * @param projectService - An instance of the ProjectService. */ export const createProjectTool = (server: McpServer, projectService: ProjectService): void => { // Define the asynchronous function that handles the actual tool logic const processRequest = async (args: CreateProjectArgs) => { logger.info(`[${TOOL_NAME}] Received request with args:`, args); try { // Call the service method to create the project const newProject = await projectService.createProject(args.projectName); // Format the successful response according to MCP standards const responsePayload = { project_id: newProject.project_id }; logger.info(`[${TOOL_NAME}] Project created successfully: ${newProject.project_id}`); return { content: [{ type: "text" as const, // Required type assertion text: JSON.stringify(responsePayload) }] }; } catch (error: unknown) { // Handle potential errors from the service layer logger.error(`[${TOOL_NAME}] Error processing request:`, error); // Basic error mapping: Assume internal error unless a specific known error type is caught // TODO: Add more specific error mapping if ProjectService throws custom errors // (e.g., catch (error instanceof ValidationError) { throw new McpError(ErrorCode.InvalidParams, ...)}) const message = error instanceof Error ? error.message : 'An unknown error occurred during project creation.'; throw new McpError(ErrorCode.InternalError, message); } }; // Register the tool with the server server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, processRequest); logger.info(`[${TOOL_NAME}] Tool registered successfully.`); }; ``` -------------------------------------------------------------------------------- /src/tools/expandTaskTool.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, ExpandTaskArgs } from "./expandTaskParams.js"; import { TaskService } from "../services/TaskService.js"; import { logger } from '../utils/logger.js'; import { NotFoundError, ConflictError } from "../utils/errors.js"; // Import specific errors /** * Registers the expandTask tool with the MCP server. * * @param server - The McpServer instance. * @param taskService - An instance of the TaskService. */ export const expandTaskTool = (server: McpServer, taskService: TaskService): void => { const processRequest = async (args: ExpandTaskArgs) => { logger.info(`[${TOOL_NAME}] Received request with args:`, args); try { // Call the service method to expand the task const updatedParentTask = await taskService.expandTask({ project_id: args.project_id, task_id: args.task_id, subtask_descriptions: args.subtask_descriptions, force: args.force, }); // Format the successful response logger.info(`[${TOOL_NAME}] Successfully expanded task ${args.task_id} in project ${args.project_id}`); return { content: [{ type: "text" as const, // Return the updated parent task details, including new subtasks text: JSON.stringify(updatedParentTask) }] }; } catch (error: unknown) { // Handle potential errors logger.error(`[${TOOL_NAME}] Error processing request:`, error); if (error instanceof NotFoundError) { // Project or parent task not found throw new McpError(ErrorCode.InvalidParams, error.message); } else if (error instanceof ConflictError) { // Subtasks exist and force=false - map to InvalidParams as the request is invalid without force=true throw new McpError(ErrorCode.InvalidParams, error.message); } else { // Generic internal error const message = error instanceof Error ? error.message : 'An unknown error occurred while expanding the task.'; throw new McpError(ErrorCode.InternalError, message); } } }; // Register the tool with the server server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); logger.info(`[${TOOL_NAME}] Tool registered successfully.`); }; ``` -------------------------------------------------------------------------------- /src/tools/updateTaskParams.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { TaskPriority, TaskStatus } from '../types/taskTypes.js'; // Import shared types export const TOOL_NAME = "updateTask"; export const TOOL_DESCRIPTION = ` Updates specific details of an existing task within a project. Requires the project ID and task ID. Allows updating description, priority, and/or dependencies. At least one optional field (description, priority, dependencies) must be provided. Returns the full details of the updated task upon success. `; // Define the possible priority values based on the shared type const priorities: [TaskPriority, ...TaskPriority[]] = ['high', 'medium', 'low']; // Base Zod schema without refinement - needed for server.tool registration export const UPDATE_TASK_BASE_SCHEMA = z.object({ project_id: z.string() .uuid("The project_id must be a valid UUID.") .describe("The unique identifier (UUID) of the project containing the task to update. This project must exist."), // Required, UUID format task_id: z.string() .uuid("The task_id must be a valid UUID.") // Assuming task IDs are UUIDs for consistency .describe("The unique identifier (UUID) of the task to update. This task must exist within the specified project."), // Required, UUID format description: z.string() .min(1, "Description cannot be empty if provided.") .max(1024, "Description cannot exceed 1024 characters.") .optional() .describe("Optional. The new textual description for the task (1-1024 characters)."), // Optional, string, limits priority: z.enum(priorities) .optional() .describe("Optional. The new priority level for the task ('high', 'medium', or 'low')."), // Optional, enum dependencies: z.array( z.string() .uuid("Each dependency task ID must be a valid UUID.") .describe("A task ID (UUID) that this task should depend on.") ) .max(50, "A task cannot have more than 50 dependencies.") .optional() .describe("Optional. The complete list of task IDs (UUIDs) that this task depends on. Replaces the existing list entirely. Max 50 dependencies."), // Optional, array of UUID strings, limit }); // Refined schema for validation and type inference export const TOOL_PARAMS = UPDATE_TASK_BASE_SCHEMA.refine( data => data.description !== undefined || data.priority !== undefined || data.dependencies !== undefined, { message: "At least one field to update (description, priority, or dependencies) must be provided.", // path: [], // No specific path, applies to the object } ); // Define the expected type for arguments based on the *refined* Zod schema export type UpdateTaskArgs = z.infer<typeof TOOL_PARAMS>; ``` -------------------------------------------------------------------------------- /src/repositories/ProjectRepository.ts: -------------------------------------------------------------------------------- ```typescript import { Database as Db } from 'better-sqlite3'; import { logger } from '../utils/logger.js'; // Assuming logger exists export interface ProjectData { project_id: string; name: string; created_at: string; // ISO8601 format } export class ProjectRepository { private db: Db; // Pass the database connection instance constructor(db: Db) { this.db = db; } /** * Creates a new project record in the database. * @param project - The project data to insert. * @throws {Error} If the database operation fails. */ public create(project: ProjectData): void { const sql = ` INSERT INTO projects (project_id, name, created_at) VALUES (@project_id, @name, @created_at) `; try { const stmt = this.db.prepare(sql); const info = stmt.run(project); logger.info(`[ProjectRepository] Created project ${project.project_id}, changes: ${info.changes}`); } catch (error) { logger.error(`[ProjectRepository] Failed to create project ${project.project_id}:`, error); // Re-throw the error to be handled by the service layer throw error; } } /** * Finds a project by its ID. * @param projectId - The ID of the project to find. * @returns The project data if found, otherwise undefined. */ public findById(projectId: string): ProjectData | undefined { const sql = `SELECT project_id, name, created_at FROM projects WHERE project_id = ?`; try { const stmt = this.db.prepare(sql); const project = stmt.get(projectId) as ProjectData | undefined; return project; } catch (error) { logger.error(`[ProjectRepository] Failed to find project ${projectId}:`, error); throw error; // Re-throw } } /** * Deletes a project by its ID. * Relies on ON DELETE CASCADE in the schema to remove associated tasks/dependencies. * @param projectId - The ID of the project to delete. * @returns The number of projects deleted (0 or 1). * @throws {Error} If the database operation fails. */ public deleteProject(projectId: string): number { const sql = `DELETE FROM projects WHERE project_id = ?`; try { const stmt = this.db.prepare(sql); const info = stmt.run(projectId); logger.info(`[ProjectRepository] Attempted to delete project ${projectId}. Rows affected: ${info.changes}`); // Cascade delete handles tasks/dependencies in the background via schema definition. return info.changes; } catch (error) { logger.error(`[ProjectRepository] Failed to delete project ${projectId}:`, error); throw error; // Re-throw } } // Add other methods as needed (e.g., update, list) } ``` -------------------------------------------------------------------------------- /src/tools/addTaskTool.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Correct path for McpServer import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; // Correct path for Error types import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, AddTaskArgs } from "./addTaskParams.js"; import { TaskService } from "../services/TaskService.js"; // Assuming TaskService is exported via services/index.js import { logger } from '../utils/logger.js'; import { NotFoundError, ValidationError } from "../utils/errors.js"; // Import custom errors /** * Registers the addTask tool with the MCP server. * * @param server - The McpServer instance. * @param taskService - An instance of the TaskService. */ export const addTaskTool = (server: McpServer, taskService: TaskService): void => { // Define the asynchronous function that handles the actual tool logic const processRequest = async (args: AddTaskArgs) => { logger.info(`[${TOOL_NAME}] Received request with args:`, args); try { // Call the service method to add the task // The Zod schema handles basic type/format/length validation const newTask = await taskService.addTask({ project_id: args.project_id, description: args.description, dependencies: args.dependencies, // Pass optional fields priority: args.priority, status: args.status, }); // Format the successful response according to MCP standards // Return the full details of the created task as per spec FR-FS-011 logger.info(`[${TOOL_NAME}] Task added successfully: ${newTask.task_id}`); return { content: [{ type: "text" as const, text: JSON.stringify(newTask) // Return the full task object }] }; } catch (error: unknown) { // Handle potential errors from the service layer logger.error(`[${TOOL_NAME}] Error processing request:`, error); if (error instanceof NotFoundError) { // Specific error if the project wasn't found - map to InvalidParams as project_id is invalid throw new McpError(ErrorCode.InvalidParams, error.message); } else if (error instanceof ValidationError) { // Specific error for validation issues within the service (e.g., dependency check if implemented) throw new McpError(ErrorCode.InvalidParams, error.message); } else { // Generic internal error for database issues or unexpected problems const message = error instanceof Error ? error.message : 'An unknown error occurred while adding the task.'; throw new McpError(ErrorCode.InternalError, message); } } }; // Register the tool with the server, passing the shape of the Zod object server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); logger.info(`[${TOOL_NAME}] Tool registered successfully.`); }; ``` -------------------------------------------------------------------------------- /src/tools/updateTaskTool.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; // Import the base schema shape for registration and the refined schema for validation/types import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, UPDATE_TASK_BASE_SCHEMA, UpdateTaskArgs } from "./updateTaskParams.js"; import { TaskService, FullTaskData } from "../services/TaskService.js"; // Assuming TaskService is exported from index import { logger } from '../utils/logger.js'; import { NotFoundError, ValidationError } from "../utils/errors.js"; // Import custom errors /** * Registers the updateTask tool with the MCP server. * * @param server - The McpServer instance. * @param taskService - An instance of the TaskService. */ export const updateTaskTool = (server: McpServer, taskService: TaskService): void => { const processRequest = async (args: UpdateTaskArgs): Promise<{ content: { type: 'text', text: string }[] }> => { logger.info(`[${TOOL_NAME}] Received request with args:`, { ...args, dependencies: args.dependencies ? `[${args.dependencies.length} items]` : undefined }); // Avoid logging potentially large arrays try { // Call the service method to update the task // The service method now returns FullTaskData const updatedTask: FullTaskData = await taskService.updateTask({ project_id: args.project_id, task_id: args.task_id, description: args.description, priority: args.priority, dependencies: args.dependencies, }); // Format the successful response logger.info(`[${TOOL_NAME}] Successfully updated task ${args.task_id} in project ${args.project_id}`); return { content: [{ type: "text" as const, text: JSON.stringify(updatedTask) // Return the full updated task details }] }; } catch (error: unknown) { // Handle potential errors according to systemPatterns.md mapping logger.error(`[${TOOL_NAME}] Error processing request:`, error); if (error instanceof ValidationError) { // Validation error from service (e.g., no fields provided, invalid deps) throw new McpError(ErrorCode.InvalidParams, error.message); } else if (error instanceof NotFoundError) { // Project or task not found - Map to InvalidParams as per SDK limitations/convention throw new McpError(ErrorCode.InvalidParams, error.message); } else { // Generic internal error const message = error instanceof Error ? error.message : 'An unknown error occurred while updating the task.'; throw new McpError(ErrorCode.InternalError, message); } } }; // Register the tool with the server using the base schema's shape server.tool(TOOL_NAME, TOOL_DESCRIPTION, UPDATE_TASK_BASE_SCHEMA.shape, processRequest); logger.info(`[${TOOL_NAME}] Tool registered successfully.`); }; ``` -------------------------------------------------------------------------------- /src/tools/index.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ConfigurationManager } from "../config/ConfigurationManager.js"; import { logger } from "../utils/index.js"; // Now using barrel file import { DatabaseManager } from "../db/DatabaseManager.js"; import { ProjectRepository } from "../repositories/ProjectRepository.js"; import { TaskRepository } from "../repositories/TaskRepository.js"; // Added TaskRepository import import { ProjectService, TaskService } from "../services/index.js"; // Using barrel file, added TaskService // Import tool registration functions // import { exampleTool } from "./exampleTool.js"; // Commenting out example import { createProjectTool } from "./createProjectTool.js"; import { addTaskTool } from "./addTaskTool.js"; import { listTasksTool } from "./listTasksTool.js"; import { showTaskTool } from "./showTaskTool.js"; import { setTaskStatusTool } from "./setTaskStatusTool.js"; import { expandTaskTool } from "./expandTaskTool.js"; import { getNextTaskTool } from "./getNextTaskTool.js"; import { exportProjectTool } from "./exportProjectTool.js"; import { importProjectTool } from "./importProjectTool.js"; import { updateTaskTool } from "./updateTaskTool.js"; // Import the new tool import { deleteTaskTool } from "./deleteTaskTool.js"; // Import deleteTask tool import { deleteProjectTool } from "./deleteProjectTool.js"; // Import deleteProject tool // import { yourTool } from "./yourTool.js"; // Add other new tool imports here /** * Register all defined tools with the MCP server instance. * This function centralizes tool registration logic. * It also instantiates necessary services and repositories. */ export function registerTools(server: McpServer): void { logger.info("Registering tools..."); const configManager = ConfigurationManager.getInstance(); // --- Instantiate Dependencies --- // Note: Consider dependency injection frameworks for larger applications try { const dbManager = DatabaseManager.getInstance(); const db = dbManager.getDb(); // Get the initialized DB connection // Instantiate Repositories const projectRepository = new ProjectRepository(db); const taskRepository = new TaskRepository(db); // Instantiate TaskRepository // Instantiate Services const projectService = new ProjectService(db, projectRepository, taskRepository); // Pass db and both repos const taskService = new TaskService(db, taskRepository, projectRepository); // Instantiate TaskService, passing db and repos // --- Register Tools --- // Register each tool, passing necessary services // exampleTool(server, configManager.getExampleServiceConfig()); // Example commented out createProjectTool(server, projectService); addTaskTool(server, taskService); listTasksTool(server, taskService); showTaskTool(server, taskService); setTaskStatusTool(server, taskService); expandTaskTool(server, taskService); getNextTaskTool(server, taskService); exportProjectTool(server, projectService); importProjectTool(server, projectService); // Register importProjectTool (uses ProjectService) updateTaskTool(server, taskService); // Register the new updateTask tool deleteTaskTool(server, taskService); // Register deleteTask tool deleteProjectTool(server, projectService); // Register deleteProject tool (uses ProjectService) // ... etc. logger.info("All tools registered successfully."); } catch (error) { logger.error("Failed to instantiate dependencies or register tools:", error); // Depending on the desired behavior, you might want to exit the process // process.exit(1); throw new Error("Failed to initialize server components during tool registration."); } } ``` -------------------------------------------------------------------------------- /src/db/DatabaseManager.ts: -------------------------------------------------------------------------------- ```typescript import Database, { Database as Db } from 'better-sqlite3'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; // Added for ES Module dirname import { ConfigurationManager } from '../config/ConfigurationManager.js'; import { logger } from '../utils/logger.js'; // Assuming logger exists export class DatabaseManager { private static instance: DatabaseManager; private db!: Db; // Added definite assignment assertion private dbPath: string; private constructor() { const configManager = ConfigurationManager.getInstance(); // TODO: Get path from configManager once implemented // For now, use a default relative path this.dbPath = configManager.getDatabasePath(); // Assuming this method exists logger.info(`[DatabaseManager] Using database path: ${this.dbPath}`); this.initializeDatabase(); } public static getInstance(): DatabaseManager { if (!DatabaseManager.instance) { DatabaseManager.instance = new DatabaseManager(); } return DatabaseManager.instance; } private initializeDatabase(): void { try { const dbDir = path.dirname(this.dbPath); if (!fs.existsSync(dbDir)) { logger.info(`[DatabaseManager] Creating database directory: ${dbDir}`); fs.mkdirSync(dbDir, { recursive: true }); } const dbExists = fs.existsSync(this.dbPath); logger.info(`[DatabaseManager] Database file ${this.dbPath} exists: ${dbExists}`); // Pass a wrapper function for verbose logging to match expected signature this.db = new Database(this.dbPath, { verbose: (message?: any, ...additionalArgs: any[]) => logger.debug({ sql: message, params: additionalArgs }, 'SQLite Query') }); // Always enable foreign keys and WAL mode upon connection this.db.pragma('foreign_keys = ON'); // Assert type for pragma result const journalMode = this.db.pragma('journal_mode = WAL') as [{ journal_mode: string }]; logger.info(`[DatabaseManager] Journal mode set to: ${journalMode[0]?.journal_mode ?? 'unknown'}`); // Check if initialization is needed (simple check: does 'projects' table exist?) const tableCheck = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='projects';").get(); if (!tableCheck) { logger.info('[DatabaseManager] Projects table not found. Initializing schema...'); // Revert to looking for schema.sql relative to the compiled JS file's directory (__dirname) const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // This will be dist/db when running compiled code const schemaPath = path.join(__dirname, 'schema.sql'); logger.info(`[DatabaseManager] Looking for schema file at: ${schemaPath}`); if (!fs.existsSync(schemaPath)) { logger.error(`[DatabaseManager] Schema file not found at ${schemaPath}. Ensure build process copied it correctly.`); throw new Error(`Schema file not found at ${schemaPath}. Build process might be incomplete.`); } const schemaSql = fs.readFileSync(schemaPath, 'utf8'); this.db.exec(schemaSql); logger.info('[DatabaseManager] Database schema initialized successfully.'); } else { logger.info('[DatabaseManager] Database schema already initialized.'); } } catch (error) { logger.error('[DatabaseManager] Failed to initialize database:', error); // Propagate the error to prevent the server from starting with a broken DB connection throw error; } } public getDb(): Db { if (!this.db) { // This should ideally not happen if constructor succeeded logger.error('[DatabaseManager] Database connection not available.'); throw new Error('Database connection not available.'); } return this.db; } // Optional: Add a close method for graceful shutdown public closeDb(): void { if (this.db) { this.db.close(); logger.info('[DatabaseManager] Database connection closed.'); } } } ``` -------------------------------------------------------------------------------- /src/config/ConfigurationManager.ts: -------------------------------------------------------------------------------- ```typescript // Import config types for services as they are added import { ExampleServiceConfig } from '../types/index.js'; // Define the structure for all configurations managed interface ManagedConfigs { exampleService: Required<ExampleServiceConfig>; // Add other service config types here: // yourService: Required<YourServiceConfig>; databasePath: string; // Added for database file location } /** * Centralized configuration management for all services. * Implements singleton pattern to ensure consistent configuration. */ export class ConfigurationManager { private static instance: ConfigurationManager | null = null; private static instanceLock = false; private config: ManagedConfigs; private constructor() { // Initialize with default configurations this.config = { exampleService: { // Define defaults for ExampleService greeting: "Hello", enableDetailedLogs: false, }, // Initialize other service configs with defaults: // yourService: { // someSetting: 'default value', // retryCount: 3, // }, // Default database path databasePath: './data/taskmanager.db', }; // Optional: Load overrides from environment variables or config files here this.loadEnvironmentOverrides(); } /** * Get the singleton instance of ConfigurationManager. * Basic lock to prevent race conditions during initial creation. */ public static getInstance(): ConfigurationManager { if (!ConfigurationManager.instance) { if (!ConfigurationManager.instanceLock) { ConfigurationManager.instanceLock = true; // Lock try { ConfigurationManager.instance = new ConfigurationManager(); } finally { ConfigurationManager.instanceLock = false; // Unlock } } else { // Basic busy wait if locked (consider a more robust async lock if high contention is expected) while (ConfigurationManager.instanceLock) { } // Re-check instance after wait if (!ConfigurationManager.instance) { // This path is less likely but handles edge cases if lock logic needs refinement return ConfigurationManager.getInstance(); } } } return ConfigurationManager.instance; } // --- Getters for specific configurations --- public getExampleServiceConfig(): Required<ExampleServiceConfig> { // Return a copy to prevent accidental modification of the internal state return { ...this.config.exampleService }; } // Add getters for other service configs: // public getYourServiceConfig(): Required<YourServiceConfig> { // return { ...this.config.yourService }; // } public getDatabasePath(): string { // Return a copy to prevent accidental modification (though less critical for a string) return this.config.databasePath; } // --- Updaters for specific configurations (if runtime updates are needed) --- public updateExampleServiceConfig(update: Partial<ExampleServiceConfig>): void { this.config.exampleService = { ...this.config.exampleService, ...update, }; // Optional: Notify relevant services about the config change } // Add updaters for other service configs: // public updateYourServiceConfig(update: Partial<YourServiceConfig>): void { // this.config.yourService = { // ...this.config.yourService, // ...update, // }; // } /** * Example method to load configuration overrides from environment variables. * Call this in the constructor. */ private loadEnvironmentOverrides(): void { // Example for ExampleService if (process.env.EXAMPLE_GREETING) { this.config.exampleService.greeting = process.env.EXAMPLE_GREETING; } if (process.env.EXAMPLE_ENABLE_LOGS) { this.config.exampleService.enableDetailedLogs = process.env.EXAMPLE_ENABLE_LOGS.toLowerCase() === 'true'; } // Override for Database Path if (process.env.DATABASE_PATH) { this.config.databasePath = process.env.DATABASE_PATH; } // Add logic for other services based on their environment variables // if (process.env.YOUR_SERVICE_RETRY_COUNT) { // const retryCount = parseInt(process.env.YOUR_SERVICE_RETRY_COUNT, 10); // if (!isNaN(retryCount)) { // this.config.yourService.retryCount = retryCount; // } // } } } ``` -------------------------------------------------------------------------------- /src/services/ProjectService.ts: -------------------------------------------------------------------------------- ```typescript import { v4 as uuidv4 } from 'uuid'; import { Database as Db } from 'better-sqlite3'; // Import Db type import { ProjectRepository, ProjectData } from '../repositories/ProjectRepository.js'; import { TaskRepository, TaskData, DependencyData } from '../repositories/TaskRepository.js'; import { logger } from '../utils/logger.js'; import { NotFoundError, ValidationError, ConflictError } from '../utils/errors.js'; // Import errors // Define structure for the export/import JSON interface ExportTask extends TaskData { dependencies: string[]; // List of task IDs this task depends on subtasks: ExportTask[]; // Nested subtasks } interface ExportData { project_metadata: ProjectData; tasks: ExportTask[]; // Root tasks } export class ProjectService { private projectRepository: ProjectRepository; private taskRepository: TaskRepository; private db: Db; // Add db instance constructor( db: Db, // Inject Db instance projectRepository: ProjectRepository, taskRepository: TaskRepository ) { this.db = db; // Store db instance this.projectRepository = projectRepository; this.taskRepository = taskRepository; } /** * Creates a new project. */ public async createProject(projectName?: string): Promise<ProjectData> { const projectId = uuidv4(); const now = new Date().toISOString(); const finalProjectName = projectName?.trim() || `New Project ${now}`; const newProject: ProjectData = { project_id: projectId, name: finalProjectName, created_at: now, }; logger.info(`[ProjectService] Attempting to create project: ${projectId} with name "${finalProjectName}"`); try { this.projectRepository.create(newProject); logger.info(`[ProjectService] Successfully created project: ${projectId}`); return newProject; } catch (error) { logger.error(`[ProjectService] Error creating project ${projectId}:`, error); throw error; } } /** * Retrieves a project by its ID. */ public async getProjectById(projectId: string): Promise<ProjectData | undefined> { logger.info(`[ProjectService] Attempting to find project: ${projectId}`); try { const project = this.projectRepository.findById(projectId); if (project) { logger.info(`[ProjectService] Found project: ${projectId}`); } else { logger.warn(`[ProjectService] Project not found: ${projectId}`); } return project; } catch (error) { logger.error(`[ProjectService] Error finding project ${projectId}:`, error); throw error; } } /** * Exports all data for a given project as a JSON string. */ public async exportProject(projectId: string): Promise<string> { logger.info(`[ProjectService] Attempting to export project: ${projectId}`); const projectMetadata = this.projectRepository.findById(projectId); if (!projectMetadata) { logger.warn(`[ProjectService] Project not found for export: ${projectId}`); throw new NotFoundError(`Project with ID ${projectId} not found.`); } try { const allTasks = this.taskRepository.findAllTasksForProject(projectId); const allDependencies = this.taskRepository.findAllDependenciesForProject(projectId); const taskMap: Map<string, ExportTask> = new Map(); const rootTasks: ExportTask[] = []; const dependencyMap: Map<string, string[]> = new Map(); for (const dep of allDependencies) { if (!dependencyMap.has(dep.task_id)) { dependencyMap.set(dep.task_id, []); } dependencyMap.get(dep.task_id)!.push(dep.depends_on_task_id); } for (const task of allTasks) { taskMap.set(task.task_id, { ...task, dependencies: dependencyMap.get(task.task_id) || [], subtasks: [], }); } for (const task of allTasks) { const exportTask = taskMap.get(task.task_id)!; if (task.parent_task_id && taskMap.has(task.parent_task_id)) { const parent = taskMap.get(task.parent_task_id)!; if (!parent.subtasks) parent.subtasks = []; parent.subtasks.push(exportTask); } else if (!task.parent_task_id) { rootTasks.push(exportTask); } } const exportData: ExportData = { project_metadata: projectMetadata, tasks: rootTasks, }; const jsonString = JSON.stringify(exportData, null, 2); logger.info(`[ProjectService] Successfully prepared export data for project ${projectId}`); return jsonString; } catch (error) { logger.error(`[ProjectService] Error exporting project ${projectId}:`, error); throw error; } } /** * Imports project data from a JSON string, creating a new project. */ public async importProject(projectDataString: string, newProjectName?: string): Promise<{ project_id: string }> { logger.info(`[ProjectService] Attempting to import project...`); let importData: ExportData; try { if (projectDataString.length > 10 * 1024 * 1024) { // Example 10MB limit throw new ValidationError('Input data exceeds size limit (e.g., 10MB).'); } importData = JSON.parse(projectDataString); // TODO: Implement rigorous schema validation (Zod?) if (!importData || !importData.project_metadata || !Array.isArray(importData.tasks)) { throw new ValidationError('Invalid import data structure: Missing required fields.'); } logger.debug(`[ProjectService] Successfully parsed import data.`); } catch (error) { logger.error('[ProjectService] Failed to parse or validate import JSON:', error); if (error instanceof SyntaxError) { throw new ValidationError(`Invalid JSON format: ${error.message}`); } throw new ValidationError(`Invalid import data: ${error instanceof Error ? error.message : 'Unknown validation error'}`); } const importTransaction = this.db.transaction(() => { const newProjectId = uuidv4(); const now = new Date().toISOString(); const finalProjectName = newProjectName?.trim() || `${importData.project_metadata.name} (Imported ${now})`; const newProject: ProjectData = { project_id: newProjectId, name: finalProjectName.substring(0, 255), created_at: now, }; this.projectRepository.create(newProject); logger.info(`[ProjectService] Created new project ${newProjectId} for import.`); const idMap = new Map<string, string>(); const processTask = (task: ExportTask, parentDbId: string | null) => { const newTaskId = uuidv4(); idMap.set(task.task_id, newTaskId); const newTaskData: TaskData = { task_id: newTaskId, project_id: newProjectId, parent_task_id: parentDbId, description: task.description, status: task.status, priority: task.priority, created_at: task.created_at, updated_at: task.updated_at, }; this.taskRepository.create(newTaskData, []); // Create task first if (task.subtasks && task.subtasks.length > 0) { task.subtasks.forEach(subtask => processTask(subtask, newTaskId)); } }; importData.tasks.forEach(rootTask => processTask(rootTask, null)); logger.info(`[ProjectService] Processed ${idMap.size} tasks for import.`); const insertDependencyStmt = this.db.prepare(` INSERT INTO task_dependencies (task_id, depends_on_task_id) VALUES (?, ?) ON CONFLICT DO NOTHING `); let depCount = 0; const processDeps = (task: ExportTask) => { const newTaskId = idMap.get(task.task_id); if (newTaskId && task.dependencies && task.dependencies.length > 0) { for (const oldDepId of task.dependencies) { const newDepId = idMap.get(oldDepId); if (newDepId) { insertDependencyStmt.run(newTaskId, newDepId); depCount++; } else { logger.warn(`[ProjectService] Dependency task ID ${oldDepId} not found in import map for task ${task.task_id}. Skipping dependency.`); } } } if (task.subtasks && task.subtasks.length > 0) { task.subtasks.forEach(processDeps); } }; importData.tasks.forEach(processDeps); logger.info(`[ProjectService] Processed ${depCount} dependencies for import.`); return { project_id: newProjectId }; }); try { const result = importTransaction(); logger.info(`[ProjectService] Successfully imported project. New project ID: ${result.project_id}`); return result; } catch (error) { logger.error(`[ProjectService] Error during import transaction:`, error); if (error instanceof NotFoundError || error instanceof ValidationError || error instanceof ConflictError) { throw error; } throw new Error(`Failed to import project: ${error instanceof Error ? error.message : 'Unknown database error'}`); } } /** * Deletes a project and all its associated data (tasks, dependencies). * @param projectId - The ID of the project to delete. * @returns A boolean indicating success (true if deleted, false if not found initially). * @throws {NotFoundError} If the project is not found. * @throws {Error} If the database operation fails. */ public async deleteProject(projectId: string): Promise<boolean> { logger.info(`[ProjectService] Attempting to delete project: ${projectId}`); // 1. Validate Project Existence *before* attempting delete const projectExists = this.projectRepository.findById(projectId); if (!projectExists) { logger.warn(`[ProjectService] Project not found for deletion: ${projectId}`); throw new NotFoundError(`Project with ID ${projectId} not found.`); } // 2. Call Repository delete method try { // The repository method handles the actual DELETE operation on the projects table. // Cascade delete defined in the schema handles tasks and dependencies. const deletedCount = this.projectRepository.deleteProject(projectId); if (deletedCount !== 1) { // This shouldn't happen if findById succeeded, but log a warning if it does. logger.warn(`[ProjectService] Expected to delete 1 project, but repository reported ${deletedCount} deletions for project ${projectId}.`); // Still return true as the project is gone, but log indicates potential issue. } logger.info(`[ProjectService] Successfully deleted project ${projectId} and associated data.`); return true; // Indicate success } catch (error) { logger.error(`[ProjectService] Error deleting project ${projectId}:`, error); throw error; // Re-throw database or other errors } } } ``` -------------------------------------------------------------------------------- /src/repositories/TaskRepository.ts: -------------------------------------------------------------------------------- ```typescript import { Database as Db, Statement } from 'better-sqlite3'; import { logger } from '../utils/logger.js'; // Define the structure for task data in the database // Aligning with schema.sql and feature specs export interface TaskData { task_id: string; // UUID project_id: string; // UUID parent_task_id?: string | null; // UUID or null description: string; status: 'todo' | 'in-progress' | 'review' | 'done'; priority: 'high' | 'medium' | 'low'; created_at: string; // ISO8601 updated_at: string; // ISO8601 } // Define the structure for dependency data export interface DependencyData { task_id: string; depends_on_task_id: string; } export class TaskRepository { private db: Db; private insertTaskStmt: Statement | null = null; private insertDependencyStmt: Statement | null = null; constructor(db: Db) { this.db = db; // Prepare statements for efficiency this.prepareStatements(); } private prepareStatements(): void { try { this.insertTaskStmt = this.db.prepare(` INSERT INTO tasks ( task_id, project_id, parent_task_id, description, status, priority, created_at, updated_at ) VALUES ( @task_id, @project_id, @parent_task_id, @description, @status, @priority, @created_at, @updated_at ) `); this.insertDependencyStmt = this.db.prepare(` INSERT INTO task_dependencies (task_id, depends_on_task_id) VALUES (@task_id, @depends_on_task_id) ON CONFLICT(task_id, depends_on_task_id) DO NOTHING -- Ignore if dependency already exists `); } catch (error) { logger.error('[TaskRepository] Failed to prepare statements:', error); // Handle error appropriately, maybe re-throw or set a flag throw error; } } /** * Creates a new task and optionally its dependencies in the database. * Uses a transaction to ensure atomicity. * @param task - The core task data to insert. * @param dependencies - An array of dependency task IDs for this task. * @throws {Error} If the database operation fails. */ public create(task: TaskData, dependencies: string[] = []): void { if (!this.insertTaskStmt || !this.insertDependencyStmt) { logger.error('[TaskRepository] Statements not prepared. Cannot create task.'); throw new Error('TaskRepository statements not initialized.'); } // Use a transaction for atomicity const transaction = this.db.transaction((taskData: TaskData, deps: string[]) => { // Insert the main task const taskInfo = this.insertTaskStmt!.run(taskData); if (taskInfo.changes !== 1) { throw new Error(`Failed to insert task ${taskData.task_id}. Changes: ${taskInfo.changes}`); } // Insert dependencies for (const depId of deps) { const depData: DependencyData = { task_id: taskData.task_id, depends_on_task_id: depId, }; const depInfo = this.insertDependencyStmt!.run(depData); // We don't strictly need to check changes here due to ON CONFLICT DO NOTHING } return taskInfo.changes; // Indicate success }); try { transaction(task, dependencies); logger.info(`[TaskRepository] Created task ${task.task_id} with ${dependencies.length} dependencies.`); } catch (error) { logger.error(`[TaskRepository] Failed to create task ${task.task_id} transaction:`, error); throw error; // Re-throw to be handled by the service layer } } /** * Finds tasks by project ID, optionally filtering by status. * Does not handle subtask nesting directly in this query for V1 simplicity. * @param projectId - The ID of the project. * @param statusFilter - Optional status to filter by. * @returns An array of matching task data. */ public findByProjectId(projectId: string, statusFilter?: TaskData['status']): TaskData[] { let sql = ` SELECT task_id, project_id, parent_task_id, description, status, priority, created_at, updated_at FROM tasks WHERE project_id = ? `; const params: (string | null)[] = [projectId]; if (statusFilter) { sql += ` AND status = ?`; params.push(statusFilter); } // For simplicity in V1, we only fetch top-level tasks or all tasks depending on include_subtasks strategy in service // If we only wanted top-level: sql += ` AND parent_task_id IS NULL`; // If fetching all and structuring in service, this query is fine. sql += ` ORDER BY created_at ASC`; // Default sort order try { const stmt = this.db.prepare(sql); const tasks = stmt.all(...params) as TaskData[]; logger.debug(`[TaskRepository] Found ${tasks.length} tasks for project ${projectId} with status filter '${statusFilter || 'none'}'`); return tasks; } catch (error) { logger.error(`[TaskRepository] Failed to find tasks for project ${projectId}:`, error); throw error; // Re-throw } } /** * Finds a single task by its ID and project ID. * @param projectId - The project ID. * @param taskId - The task ID. * @returns The task data if found, otherwise undefined. */ public findById(projectId: string, taskId: string): TaskData | undefined { const sql = ` SELECT task_id, project_id, parent_task_id, description, status, priority, created_at, updated_at FROM tasks WHERE project_id = ? AND task_id = ? `; try { const stmt = this.db.prepare(sql); const task = stmt.get(projectId, taskId) as TaskData | undefined; logger.debug(`[TaskRepository] Found task ${taskId} in project ${projectId}: ${!!task}`); return task; } catch (error) { logger.error(`[TaskRepository] Failed to find task ${taskId} in project ${projectId}:`, error); throw error; } } /** * Finds the direct subtasks for a given parent task ID. * @param parentTaskId - The ID of the parent task. * @returns An array of direct subtask data. */ public findSubtasks(parentTaskId: string): TaskData[] { const sql = ` SELECT task_id, project_id, parent_task_id, description, status, priority, created_at, updated_at FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC `; try { const stmt = this.db.prepare(sql); const subtasks = stmt.all(parentTaskId) as TaskData[]; logger.debug(`[TaskRepository] Found ${subtasks.length} subtasks for parent ${parentTaskId}`); return subtasks; } catch (error) { logger.error(`[TaskRepository] Failed to find subtasks for parent ${parentTaskId}:`, error); throw error; } } /** * Finds the IDs of tasks that the given task depends on. * @param taskId - The ID of the task whose dependencies are needed. * @returns An array of task IDs that this task depends on. */ public findDependencies(taskId: string): string[] { const sql = `SELECT depends_on_task_id FROM task_dependencies WHERE task_id = ?`; try { const stmt = this.db.prepare(sql); // Ensure result is always an array of strings const results = stmt.all(taskId) as { depends_on_task_id: string }[]; const dependencyIds = results.map(row => row.depends_on_task_id); logger.debug(`[TaskRepository] Found ${dependencyIds.length} dependencies for task ${taskId}`); return dependencyIds; } catch (error) { logger.error(`[TaskRepository] Failed to find dependencies for task ${taskId}:`, error); throw error; } } /** * Updates the status and updated_at timestamp for a list of tasks within a project. * Assumes task existence has already been verified. * @param projectId - The project ID. * @param taskIds - An array of task IDs to update. * @param status - The new status to set. * @param timestamp - The ISO8601 timestamp for updated_at. * @returns The number of rows affected by the update. * @throws {Error} If the database operation fails. */ public updateStatus(projectId: string, taskIds: string[], status: TaskData['status'], timestamp: string): number { if (taskIds.length === 0) { return 0; } // Create placeholders for the IN clause const placeholders = taskIds.map(() => '?').join(','); const sql = ` UPDATE tasks SET status = ?, updated_at = ? WHERE project_id = ? AND task_id IN (${placeholders}) `; const params = [status, timestamp, projectId, ...taskIds]; try { const stmt = this.db.prepare(sql); const info = stmt.run(...params); logger.info(`[TaskRepository] Updated status for ${info.changes} tasks in project ${projectId} to ${status}.`); return info.changes; } catch (error) { logger.error(`[TaskRepository] Failed to update status for tasks in project ${projectId}:`, error); throw error; } } /** * Checks if all provided task IDs exist within the specified project. * @param projectId - The project ID. * @param taskIds - An array of task IDs to check. * @returns An object indicating if all exist and a list of missing IDs if not. * @throws {Error} If the database operation fails. */ public checkTasksExist(projectId: string, taskIds: string[]): { allExist: boolean; missingIds: string[] } { if (taskIds.length === 0) { return { allExist: true, missingIds: [] }; } const placeholders = taskIds.map(() => '?').join(','); const sql = ` SELECT task_id FROM tasks WHERE project_id = ? AND task_id IN (${placeholders}) `; const params = [projectId, ...taskIds]; try { const stmt = this.db.prepare(sql); const foundTasks = stmt.all(...params) as { task_id: string }[]; const foundIds = new Set(foundTasks.map(t => t.task_id)); const missingIds = taskIds.filter(id => !foundIds.has(id)); const allExist = missingIds.length === 0; if (!allExist) { logger.warn(`[TaskRepository] Missing tasks in project ${projectId}:`, missingIds); } return { allExist, missingIds }; } catch (error) { logger.error(`[TaskRepository] Failed to check task existence in project ${projectId}:`, error); throw error; } } /** * Deletes all direct subtasks of a given parent task. * @param parentTaskId - The ID of the parent task whose subtasks should be deleted. * @returns The number of subtasks deleted. * @throws {Error} If the database operation fails. */ public deleteSubtasks(parentTaskId: string): number { const sql = `DELETE FROM tasks WHERE parent_task_id = ?`; try { const stmt = this.db.prepare(sql); const info = stmt.run(parentTaskId); logger.info(`[TaskRepository] Deleted ${info.changes} subtasks for parent ${parentTaskId}.`); return info.changes; } catch (error) { logger.error(`[TaskRepository] Failed to delete subtasks for parent ${parentTaskId}:`, error); throw error; } } /** * Finds tasks that are ready to be worked on (status 'todo' and all dependencies 'done'). * Orders them by priority ('high', 'medium', 'low') then creation date. * @param projectId - The project ID. * @returns An array of ready task data, ordered by priority and creation date. */ public findReadyTasks(projectId: string): TaskData[] { // This query finds tasks in the project with status 'todo' // AND for which no dependency exists OR all existing dependencies have status 'done'. const sql = ` SELECT t.task_id, t.project_id, t.parent_task_id, t.description, t.status, t.priority, t.created_at, t.updated_at FROM tasks t WHERE t.project_id = ? AND t.status = 'todo' AND NOT EXISTS ( SELECT 1 FROM task_dependencies td JOIN tasks dep_task ON td.depends_on_task_id = dep_task.task_id WHERE td.task_id = t.task_id AND dep_task.status != 'done' ) ORDER BY CASE t.priority WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 -- Should not happen based on CHECK constraint END ASC, t.created_at ASC `; try { const stmt = this.db.prepare(sql); const tasks = stmt.all(projectId) as TaskData[]; logger.debug(`[TaskRepository] Found ${tasks.length} ready tasks for project ${projectId}`); return tasks; } catch (error) { logger.error(`[TaskRepository] Failed to find ready tasks for project ${projectId}:`, error); throw error; } } /** * Finds ALL tasks for a given project ID, ordered by creation date. * @param projectId - The project ID. * @returns An array of all task data for the project. */ public findAllTasksForProject(projectId: string): TaskData[] { const sql = ` SELECT task_id, project_id, parent_task_id, description, status, priority, created_at, updated_at FROM tasks WHERE project_id = ? ORDER BY created_at ASC `; try { const stmt = this.db.prepare(sql); const tasks = stmt.all(projectId) as TaskData[]; logger.debug(`[TaskRepository] Found all ${tasks.length} tasks for project ${projectId}`); return tasks; } catch (error) { logger.error(`[TaskRepository] Failed to find all tasks for project ${projectId}:`, error); throw error; } } /** * Finds ALL dependencies for tasks within a given project ID. * @param projectId - The project ID. * @returns An array of all dependency relationships for the project. */ public findAllDependenciesForProject(projectId: string): DependencyData[] { // Select dependencies where the *dependent* task belongs to the project const sql = ` SELECT td.task_id, td.depends_on_task_id FROM task_dependencies td JOIN tasks t ON td.task_id = t.task_id WHERE t.project_id = ? `; try { const stmt = this.db.prepare(sql); const dependencies = stmt.all(projectId) as DependencyData[]; logger.debug(`[TaskRepository] Found ${dependencies.length} dependencies for project ${projectId}`); return dependencies; } catch (error) { logger.error(`[TaskRepository] Failed to find all dependencies for project ${projectId}:`, error); throw error; } } // --- Add other methods later --- /** * Updates a task's description, priority, and/or dependencies. * Handles dependency replacement atomically within a transaction. * @param projectId - The project ID. * @param taskId - The task ID to update. * @param updatePayload - Object containing optional fields to update. * @param timestamp - The ISO8601 timestamp for updated_at. * @returns The updated task data. * @throws {Error} If the task doesn't exist or the database operation fails. */ public updateTask( projectId: string, taskId: string, updatePayload: { description?: string; priority?: TaskData['priority']; dependencies?: string[] }, timestamp: string ): TaskData { const transaction = this.db.transaction(() => { const setClauses: string[] = []; const params: (string | null)[] = []; if (updatePayload.description !== undefined) { setClauses.push('description = ?'); params.push(updatePayload.description); } if (updatePayload.priority !== undefined) { setClauses.push('priority = ?'); params.push(updatePayload.priority); } // Always update the timestamp setClauses.push('updated_at = ?'); params.push(timestamp); // If nothing else to update, we still update the timestamp if (setClauses.length === 1 && updatePayload.dependencies === undefined) { logger.warn(`[TaskRepository] updateTask called for ${taskId} with no fields to update other than timestamp.`); // Or potentially throw an error if this shouldn't happen based on service validation } // Update the main task table if there are fields to update let changes = 0; if (setClauses.length > 0) { const updateSql = ` UPDATE tasks SET ${setClauses.join(', ')} WHERE project_id = ? AND task_id = ? `; params.push(projectId, taskId); const updateStmt = this.db.prepare(updateSql); const info = updateStmt.run(...params); changes = info.changes; if (changes !== 1) { // Check if the task actually exists before throwing generic error const exists = this.findById(projectId, taskId); if (!exists) { throw new Error(`Task ${taskId} not found in project ${projectId}.`); // Will be caught and mapped later } else { throw new Error(`Failed to update task ${taskId}. Expected 1 change, got ${changes}.`); } } logger.debug(`[TaskRepository] Updated task ${taskId} fields.`); } // Handle dependencies if provided (replaces existing) if (updatePayload.dependencies !== undefined) { if (!this.insertDependencyStmt) { throw new Error('TaskRepository insertDependencyStmt not initialized.'); } // 1. Delete existing dependencies for this task const deleteDepsStmt = this.db.prepare(`DELETE FROM task_dependencies WHERE task_id = ?`); const deleteInfo = deleteDepsStmt.run(taskId); logger.debug(`[TaskRepository] Deleted ${deleteInfo.changes} existing dependencies for task ${taskId}.`); // 2. Insert new dependencies const newDeps = updatePayload.dependencies; for (const depId of newDeps) { const depData: DependencyData = { task_id: taskId, depends_on_task_id: depId, }; // ON CONFLICT DO NOTHING handles duplicates or self-references if schema allows this.insertDependencyStmt.run(depData); } logger.debug(`[TaskRepository] Inserted ${newDeps.length} new dependencies for task ${taskId}.`); } // Fetch and return the updated task data const updatedTask = this.findById(projectId, taskId); if (!updatedTask) { // Should not happen if update succeeded, but safety check throw new Error(`Failed to retrieve updated task ${taskId} after update.`); } return updatedTask; }); try { const result = transaction(); logger.info(`[TaskRepository] Successfully updated task ${taskId}.`); return result; } catch (error) { logger.error(`[TaskRepository] Failed transaction for updating task ${taskId}:`, error); throw error; // Re-throw to be handled by the service layer } } /** * Deletes multiple tasks by their IDs within a specific project. * Relies on ON DELETE CASCADE for subtasks and dependencies. * @param projectId - The project ID. * @param taskIds - An array of task IDs to delete. * @returns The number of tasks deleted. * @throws {Error} If the database operation fails. */ public deleteTasks(projectId: string, taskIds: string[]): number { if (taskIds.length === 0) { return 0; } // Create placeholders for the IN clause const placeholders = taskIds.map(() => '?').join(','); const sql = ` DELETE FROM tasks WHERE project_id = ? AND task_id IN (${placeholders}) `; const params = [projectId, ...taskIds]; try { const stmt = this.db.prepare(sql); const info = stmt.run(...params); logger.info(`[TaskRepository] Deleted ${info.changes} tasks from project ${projectId}.`); // Note: Cascade deletes for subtasks/dependencies happen automatically via schema. return info.changes; } catch (error) { logger.error(`[TaskRepository] Failed to delete tasks from project ${projectId}:`, error); throw error; } } // --- Add other methods later --- // deleteById(taskId: string): void; } ``` -------------------------------------------------------------------------------- /src/services/TaskService.ts: -------------------------------------------------------------------------------- ```typescript 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 --- } ```