# 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 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": false, 5 | "printWidth": 80, 6 | "tabWidth": 2, 7 | "endOfLine": "lf" 8 | } 9 | ``` -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint", 6 | "prettier" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "prettier" // Make sure this is last 12 | ], 13 | "rules": { 14 | "prettier/prettier": "warn", 15 | "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], 16 | "@typescript-eslint/no-explicit-any": "warn", // Use warn instead of error initially 17 | "no-console": "off" // Allow console logging for server apps, or configure properly 18 | }, 19 | "env": { 20 | "node": true, 21 | "es2022": true 22 | }, 23 | "parserOptions": { 24 | "ecmaVersion": "latest", 25 | "sourceType": "module" 26 | } 27 | } 28 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Created by https://www.toptal.com/developers/gitignore/api/node 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | .pnpm-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Snowpack dependency directory (https://snowpack.dev/) 50 | web_modules/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional stylelint cache 62 | .stylelintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variable files 80 | .env 81 | .env.development.local 82 | .env.test.local 83 | .env.production.local 84 | .env.local 85 | 86 | # parcel-bundler cache (https://parceljs.org/) 87 | .cache 88 | .parcel-cache 89 | 90 | # Next.js build output 91 | .next 92 | out 93 | 94 | # Nuxt.js build / generate output 95 | .nuxt 96 | dist 97 | 98 | # Gatsby files 99 | .cache/ 100 | # Comment in the public line in if your project uses Gatsby and not Next.js 101 | # https://nextjs.org/blog/next-9-1#public-directory-support 102 | # public 103 | 104 | # vuepress build output 105 | .vuepress/dist 106 | 107 | # vuepress v2.x temp and cache directory 108 | .temp 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | 135 | ### Node Patch ### 136 | # Serverless Webpack directories 137 | .webpack/ 138 | 139 | # Optional stylelint cache 140 | 141 | # SvelteKit build / generate output 142 | .svelte-kit 143 | 144 | dist/ 145 | .generalrules 146 | .clinerules 147 | .cursorrules 148 | 149 | 150 | 151 | # End of https://www.toptal.com/developers/gitignore/api/node 152 | .taskmanagerrules 153 | docs/ 154 | data/taskmanager.db 155 | data/taskmanager.* 156 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP Task Manager Server 2 | 3 | <div align="center"> 4 | <img src="public/images/mcp-task-manager-logo.svg" alt="MCP Task Manager Logo" width="200" height="200" /> 5 | </div> 6 | 7 | A local Model Context Protocol (MCP) server providing backend tools for client-driven project and task management using a SQLite database. 8 | 9 | ## Overview 10 | 11 | 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. 12 | 13 | **Key Features:** 14 | 15 | * **Project-Based:** Tasks are organized within distinct projects. 16 | * **SQLite Persistence:** Uses a local SQLite file (`./data/taskmanager.db` by default) for simple, self-contained data storage. 17 | * **Client-Driven:** Provides tools for clients; does not dictate workflow. 18 | * **MCP Compliant:** Adheres to the Model Context Protocol for tool definition and communication. 19 | * **Task Management:** Supports creating projects, adding tasks, listing/showing tasks, updating status, expanding tasks into subtasks, and identifying the next actionable task. 20 | * **Import/Export:** Allows exporting project data to JSON and importing from JSON to create new projects. 21 | 22 | ## Implemented MCP Tools 23 | 24 | The following tools are available for MCP clients: 25 | 26 | * **`createProject`**: 27 | * **Description:** Creates a new, empty project. 28 | * **Params:** `projectName` (string, optional, max 255) 29 | * **Returns:** `{ project_id: string }` 30 | * **`addTask`**: 31 | * **Description:** Adds a new task to a project. 32 | * **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') 33 | * **Returns:** Full `TaskData` object of the created task. 34 | * **`listTasks`**: 35 | * **Description:** Lists tasks for a project, with optional filtering and subtask inclusion. 36 | * **Params:** `project_id` (string, required, UUID), `status` (enum 'todo'|'in-progress'|'review'|'done', optional), `include_subtasks` (boolean, optional, default false) 37 | * **Returns:** Array of `TaskData` or `StructuredTaskData` objects. 38 | * **`showTask`**: 39 | * **Description:** Retrieves full details for a specific task, including dependencies and direct subtasks. 40 | * **Params:** `project_id` (string, required, UUID), `task_id` (string, required) 41 | * **Returns:** `FullTaskData` object. 42 | * **`setTaskStatus`**: 43 | * **Description:** Updates the status of one or more tasks. 44 | * **Params:** `project_id` (string, required, UUID), `task_ids` (string[], required, 1-100), `status` (enum 'todo'|'in-progress'|'review'|'done', required) 45 | * **Returns:** `{ success: true, updated_count: number }` 46 | * **`expandTask`**: 47 | * **Description:** Breaks a parent task into subtasks, optionally replacing existing ones. 48 | * **Params:** `project_id` (string, required, UUID), `task_id` (string, required), `subtask_descriptions` (string[], required, 1-20, each 1-512), `force` (boolean, optional, default false) 49 | * **Returns:** Updated parent `FullTaskData` object including new subtasks. 50 | * **`getNextTask`**: 51 | * **Description:** Identifies the next actionable task based on status ('todo'), dependencies ('done'), priority, and creation date. 52 | * **Params:** `project_id` (string, required, UUID) 53 | * **Returns:** `FullTaskData` object of the next task, or `null` if none are ready. 54 | * **`exportProject`**: 55 | * **Description:** Exports complete project data as a JSON string. 56 | * **Params:** `project_id` (string, required, UUID), `format` (enum 'json', optional, default 'json') 57 | * **Returns:** JSON string representing the project. 58 | * **`importProject`**: 59 | * **Description:** Creates a *new* project from an exported JSON string. 60 | * **Params:** `project_data` (string, required, JSON), `new_project_name` (string, optional, max 255) 61 | * **Returns:** `{ project_id: string }` of the newly created project. 62 | * **`updateTask`**: 63 | * **Description:** Updates specific details (description, priority, dependencies) of an existing task. 64 | * **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) 65 | * **Returns:** Updated `FullTaskData` object. 66 | * **`deleteTask`**: 67 | * **Description:** Deletes one or more tasks (and their subtasks/dependency links via cascade). 68 | * **Params:** `project_id` (string, required, UUID), `task_ids` (string[], required, 1-100) 69 | * **Returns:** `{ success: true, deleted_count: number }` 70 | * **`deleteProject`**: 71 | * **Description:** Permanently deletes a project and ALL associated data. **Use with caution!** 72 | * **Params:** `project_id` (string, required, UUID) 73 | * **Returns:** `{ success: true }` 74 | 75 | *(Note: Refer to the corresponding `src/tools/*Params.ts` files for detailed Zod schemas and parameter descriptions.)* 76 | 77 | ## Getting Started 78 | 79 | 1. **Prerequisites:** Node.js (LTS recommended), npm. 80 | 2. **Install Dependencies:** 81 | 82 | ```bash 83 | npm install 84 | ``` 85 | 86 | 3. **Run in Development Mode:** (Uses `ts-node` and `nodemon` for auto-reloading) 87 | 88 | ```bash 89 | npm run dev 90 | ``` 91 | 92 | 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`. 93 | 4. **Build for Production:** 94 | 95 | ```bash 96 | npm run build 97 | ``` 98 | 99 | 5. **Run Production Build:** 100 | 101 | ```bash 102 | npm start 103 | ``` 104 | 105 | ## Configuration 106 | 107 | * **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`. 108 | * **Log Level:** The logging level can be set using the `LOG_LEVEL` environment variable (e.g., `debug`, `info`, `warn`, `error`). The default is `info`. 109 | 110 | ## Project Structure 111 | 112 | * `/src`: Source code. 113 | * `/config`: Configuration management. 114 | * `/db`: Database manager and schema (`schema.sql`). 115 | * `/repositories`: Data access layer (SQLite interaction). 116 | * `/services`: Core business logic. 117 | * `/tools`: MCP tool definitions (*Params.ts) and implementation (*Tool.ts). 118 | * `/types`: Shared TypeScript interfaces (currently minimal, mostly in repos/services). 119 | * `/utils`: Logging, custom errors, etc. 120 | * `createServer.ts`: Server instance creation. 121 | * `server.ts`: Main application entry point. 122 | * `/dist`: Compiled JavaScript output. 123 | * `/docs`: Project documentation (PRD, Feature Specs, RFC). 124 | * `/data`: Default location for the SQLite database file (created automatically). 125 | * `tasks.md`: Manual task tracking file for development. 126 | * Config files (`package.json`, `tsconfig.json`, `.eslintrc.json`, etc.) 127 | 128 | ## Linting and Formatting 129 | 130 | * **Lint:** `npm run lint` 131 | * **Format:** `npm run format` 132 | 133 | (Code is automatically linted/formatted on commit via Husky/lint-staged). 134 | ``` -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | export * from './logger.js'; 2 | export * from './errors.js'; 3 | // Add other util exports here 4 | ``` -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | export * from './ProjectService.js'; 2 | export * from './TaskService.js'; // Added TaskService export 3 | // Remove or comment out ExampleService if it's not being used 4 | // export * from './ExampleService.js'; 5 | // Add other service exports here 6 | ``` -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | // Export all types and interfaces from this barrel file 2 | export * from './exampleServiceTypes.js'; 3 | export * from './taskTypes.js'; // Added export for task types 4 | // export * from './yourServiceTypes.js'; // Add new type exports here 5 | 6 | // Define common types used across services/tools if any 7 | export interface CommonContext { 8 | sessionId?: string; 9 | userId?: string; 10 | } 11 | ``` -------------------------------------------------------------------------------- /src/types/exampleServiceTypes.ts: -------------------------------------------------------------------------------- ```typescript 1 | // Types specific to the ExampleService 2 | 3 | /** 4 | * Configuration options for ExampleService. 5 | */ 6 | export interface ExampleServiceConfig { 7 | greeting: string; 8 | enableDetailedLogs: boolean; 9 | } 10 | 11 | /** 12 | * Data structure handled by ExampleService. 13 | */ 14 | export interface ExampleServiceData { 15 | name: string; 16 | message: string; 17 | processedTimestamp: string; 18 | metrics?: ExampleServiceMetrics; 19 | } 20 | 21 | /** 22 | * Metrics collected during ExampleService processing. 23 | */ 24 | export interface ExampleServiceMetrics { 25 | processingTimeMs: number; 26 | } 27 | ``` -------------------------------------------------------------------------------- /src/tools/deleteProjectParams.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | 3 | export const TOOL_NAME = "deleteProject"; 4 | 5 | export const TOOL_DESCRIPTION = ` 6 | Permanently deletes a project and ALL associated tasks and dependencies. 7 | Requires the project ID. This is a highly destructive operation and cannot be undone. 8 | Returns a success confirmation upon completion. 9 | `; 10 | 11 | // Zod schema for the parameters, matching FR-013 12 | export const TOOL_PARAMS = z.object({ 13 | project_id: z.string() 14 | .uuid("The project_id must be a valid UUID.") 15 | .describe("The unique identifier (UUID) of the project to permanently delete. This project must exist."), // Required, UUID format 16 | 17 | }); 18 | 19 | // Define the expected type for arguments based on the Zod schema 20 | export type DeleteProjectArgs = z.infer<typeof TOOL_PARAMS>; 21 | ``` -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { createServer } from "./createServer.js"; 2 | import { logger } from "./utils/index.js"; 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 4 | // import { WebSocketServerTransport } from "@modelcontextprotocol/sdk/server/ws.js"; // Example for WebSocket 5 | 6 | const main = async () => { 7 | try { 8 | const server = createServer(); 9 | logger.info("Starting MCP server"); 10 | 11 | // Choose your transport 12 | const transport = new StdioServerTransport(); 13 | // const transport = new WebSocketServerTransport({ port: 8080 }); // Example 14 | 15 | logger.info("Connecting transport", { transport: transport.constructor.name }); 16 | await server.connect(transport); 17 | 18 | logger.info("MCP Server connected and listening"); 19 | 20 | } catch (error) { 21 | logger.error("Failed to start server", error); 22 | process.exit(1); // Exit if server fails to start 23 | } 24 | }; 25 | 26 | main(); 27 | ``` -------------------------------------------------------------------------------- /src/createServer.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { ConfigurationManager } from "./config/ConfigurationManager.js"; 3 | import { registerTools } from "./tools/index.js"; 4 | import { logger } from "./utils/index.js"; 5 | 6 | /** 7 | * Creates and configures an MCP server instance. 8 | * This is the central function for server creation and tool registration. 9 | * @returns {McpServer} The configured MCP server instance 10 | */ 11 | export function createServer(): McpServer { 12 | logger.info("Creating MCP server instance"); 13 | 14 | // Initialize the server 15 | const server = new McpServer({ 16 | name: "mcp-server", 17 | version: "1.0.0", 18 | description: "MCP Server based on recommended practices" 19 | }); 20 | 21 | // Get configuration 22 | const configManager = ConfigurationManager.getInstance(); 23 | 24 | // Register all tools 25 | registerTools(server); 26 | 27 | logger.info("MCP server instance created successfully"); 28 | return server; 29 | } 30 | ``` -------------------------------------------------------------------------------- /src/tools/getNextTaskParams.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | 3 | export const TOOL_NAME = "getNextTask"; 4 | 5 | export const TOOL_DESCRIPTION = ` 6 | Identifies and returns the next actionable task within a specified project. 7 | A task is considered actionable if its status is 'todo' and all its dependencies (if any) have a status of 'done'. 8 | If multiple tasks are ready, the one with the highest priority ('high' > 'medium' > 'low') is chosen. 9 | If priorities are equal, the task created earliest is chosen. 10 | Returns the full details of the next task, or null if no task is currently ready. 11 | `; 12 | 13 | // Zod schema for the parameters, matching FR-007 and getNextTaskTool.md spec 14 | export const TOOL_PARAMS = z.object({ 15 | project_id: z.string() 16 | .uuid("The project_id must be a valid UUID.") 17 | .describe("The unique identifier (UUID) of the project to find the next task for."), // Required, UUID format 18 | }); 19 | 20 | // Define the expected type for arguments based on the Zod schema 21 | export type GetNextTaskArgs = z.infer<typeof TOOL_PARAMS>; 22 | ``` -------------------------------------------------------------------------------- /src/tools/showTaskParams.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | 3 | export const TOOL_NAME = "showTask"; 4 | 5 | export const TOOL_DESCRIPTION = ` 6 | Retrieves the full details of a single, specific task, including its dependencies and direct subtasks. 7 | Requires the project ID and the task ID. 8 | Returns a task object containing all details if found. 9 | `; 10 | 11 | // Zod schema for the parameters, matching FR-004 and showTaskTool.md spec 12 | export const TOOL_PARAMS = z.object({ 13 | project_id: z.string() 14 | .uuid("The project_id must be a valid UUID.") 15 | .describe("The unique identifier (UUID) of the project the task belongs to."), // Required, UUID format 16 | 17 | task_id: z.string() 18 | // Add .uuid() if task IDs are also UUIDs, otherwise keep as string 19 | .min(1, "Task ID cannot be empty.") 20 | .describe("The unique identifier of the task to retrieve details for."), // Required, string (or UUID) 21 | }); 22 | 23 | // Define the expected type for arguments based on the Zod schema 24 | export type ShowTaskArgs = z.infer<typeof TOOL_PARAMS>; 25 | ``` -------------------------------------------------------------------------------- /src/tools/exportProjectParams.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | 3 | export const TOOL_NAME = "exportProject"; 4 | 5 | export const TOOL_DESCRIPTION = ` 6 | Exports the complete data set for a specified project as a JSON string. 7 | This includes project metadata, all tasks (hierarchically structured), and their dependencies. 8 | Requires the project ID. The format is fixed to JSON for V1. 9 | Returns the JSON string representing the project data. 10 | `; 11 | 12 | // Zod schema for the parameters, matching FR-009 and exportProjectTool.md spec 13 | export const TOOL_PARAMS = z.object({ 14 | project_id: z.string() 15 | .uuid("The project_id must be a valid UUID.") 16 | .describe("The unique identifier (UUID) of the project to export."), // Required, UUID format 17 | 18 | format: z.literal('json') // Only allow 'json' for V1 19 | .optional() 20 | .default('json') 21 | .describe("Optional format for the export. Currently only 'json' is supported (default)."), // Optional, enum (fixed), default 22 | }); 23 | 24 | // Define the expected type for arguments based on the Zod schema 25 | export type ExportProjectArgs = z.infer<typeof TOOL_PARAMS>; 26 | ``` -------------------------------------------------------------------------------- /src/tools/createProjectParams.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | 3 | export const TOOL_NAME = "createProject"; 4 | 5 | export const TOOL_DESCRIPTION = ` 6 | Creates a new, empty project entry in the Task Management Server database. 7 | This tool is used by clients (e.g., AI agents) to initiate a new workspace for tasks. 8 | It returns the unique identifier (UUID) assigned to the newly created project. 9 | An optional name can be provided; otherwise, a default name including a timestamp will be generated. 10 | `; 11 | 12 | // Define the shape of the parameters for the server.tool method 13 | export const TOOL_PARAMS = { 14 | projectName: z.string() 15 | .max(255, "Project name cannot exceed 255 characters.") // Max length constraint 16 | .optional() // Optional parameter 17 | .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 18 | }; 19 | 20 | // Create a Zod schema object from the shape for validation and type inference 21 | const toolParamsSchema = z.object(TOOL_PARAMS); 22 | 23 | // Define the expected type for arguments based on the Zod schema 24 | export type CreateProjectArgs = z.infer<typeof toolParamsSchema>; 25 | ``` -------------------------------------------------------------------------------- /src/utils/errors.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Custom error types for the Task Management Server. 3 | * These can be caught in the service layer and mapped to specific 4 | * McpError codes in the tool layer. 5 | */ 6 | 7 | // Example: Base service error 8 | export class ServiceError extends Error { 9 | constructor(message: string, public details?: any) { 10 | super(message); 11 | this.name = 'ServiceError'; 12 | } 13 | } 14 | 15 | // Example: Validation specific error 16 | export class ValidationError extends ServiceError { 17 | constructor(message: string, details?: any) { 18 | super(message, details); 19 | this.name = 'ValidationError'; 20 | } 21 | } 22 | 23 | // Example: Not found specific error 24 | export class NotFoundError extends ServiceError { 25 | constructor(message: string = "Resource not found", details?: any) { 26 | super(message, details); 27 | this.name = 'NotFoundError'; 28 | } 29 | } 30 | 31 | // Example: Conflict specific error (e.g., trying to create something that exists) 32 | export class ConflictError extends ServiceError { 33 | constructor(message: string = "Resource conflict", details?: any) { 34 | super(message, details); 35 | this.name = 'ConflictError'; 36 | } 37 | } 38 | 39 | // Add other custom error types as needed 40 | ``` -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { pino, Logger } from 'pino'; // Try named import for the function 2 | 3 | /** 4 | * Pino logger instance configured for structured JSON logging to stderr. 5 | * MCP servers typically use stdout for protocol messages, so logs go to stderr. 6 | */ 7 | export const logger: Logger = pino( 8 | { 9 | level: process.env.LOG_LEVEL || 'info', // Default to 'info', configurable via env var 10 | formatters: { 11 | level: (label: string) => { // Add type for label 12 | // Standardize level labels if desired, e.g., uppercase 13 | return { level: label.toUpperCase() }; 14 | }, 15 | // bindings: (bindings) => { 16 | // // Add custom bindings if needed, e.g., hostname, pid 17 | // return { pid: bindings.pid, hostname: bindings.hostname }; 18 | // }, 19 | }, 20 | timestamp: pino.stdTimeFunctions.isoTime, // Use ISO 8601 timestamps 21 | }, 22 | pino.destination(2) // Direct output to stderr (file descriptor 2) 23 | ); 24 | 25 | // Example usage (replace console.log/error calls throughout the app): 26 | // logger.info('Server starting...'); 27 | // logger.debug({ userId: '123' }, 'User logged in'); 28 | // logger.error(new Error('Something failed'), 'Failed to process request'); 29 | ``` -------------------------------------------------------------------------------- /src/tools/deleteTaskParams.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | 3 | export const TOOL_NAME = "deleteTask"; 4 | 5 | export const TOOL_DESCRIPTION = ` 6 | Deletes one or more tasks within a specified project. 7 | Requires the project ID and an array of task IDs to delete. 8 | Note: Deleting a task also deletes its subtasks and dependency links due to database cascade rules. 9 | Returns the count of successfully deleted tasks. 10 | `; 11 | 12 | // Zod schema for the parameters, matching FR-012 13 | export const TOOL_PARAMS = z.object({ 14 | project_id: z.string() 15 | .uuid("The project_id must be a valid UUID.") 16 | .describe("The unique identifier (UUID) of the project containing the tasks to delete. This project must exist."), // Required, UUID format 17 | 18 | task_ids: z.array( 19 | z.string() 20 | .uuid("Each task ID must be a valid UUID.") 21 | .describe("A unique identifier (UUID) of a task to delete.") 22 | ) 23 | .min(1, "At least one task ID must be provided.") 24 | .max(100, "Cannot delete more than 100 tasks per call.") 25 | .describe("An array of task IDs (UUIDs, 1-100) to be deleted from the specified project."), // Required, array of UUID strings, limits 26 | 27 | }); 28 | 29 | // Define the expected type for arguments based on the Zod schema 30 | export type DeleteTaskArgs = z.infer<typeof TOOL_PARAMS>; 31 | ``` -------------------------------------------------------------------------------- /src/tools/importProjectParams.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | 3 | export const TOOL_NAME = "importProject"; 4 | 5 | export const TOOL_DESCRIPTION = ` 6 | Creates a *new* project by importing data from a JSON string. 7 | The JSON data must conform to the structure previously generated by the 'exportProject' tool. 8 | Performs validation on the input data (parsing, basic structure, size limit). 9 | Returns the unique project_id of the newly created project upon success. 10 | `; 11 | 12 | // Zod schema for the parameters, matching FR-010 and importProjectTool.md spec 13 | export const TOOL_PARAMS = z.object({ 14 | project_data: z.string() 15 | .min(1, "Project data cannot be empty.") 16 | // Size validation happens in the service layer before parsing 17 | .describe("Required. A JSON string containing the full project data, conforming to the export structure. Max size e.g., 10MB."), // Required, string 18 | 19 | new_project_name: z.string() 20 | .max(255, "New project name cannot exceed 255 characters.") 21 | .optional() 22 | .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 23 | }); 24 | 25 | // Define the expected type for arguments based on the Zod schema 26 | export type ImportProjectArgs = z.infer<typeof TOOL_PARAMS>; 27 | ``` -------------------------------------------------------------------------------- /src/tools/listTasksParams.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | 3 | export const TOOL_NAME = "listTasks"; 4 | 5 | export const TOOL_DESCRIPTION = ` 6 | Retrieves a list of tasks for a specified project. 7 | Allows optional filtering by task status ('todo', 'in-progress', 'review', 'done'). 8 | Provides an option to include nested subtasks directly within their parent task objects in the response. 9 | Returns an array of task objects. 10 | `; 11 | 12 | // Re-use enum from addTaskParams or define locally if preferred 13 | const TaskStatusEnum = z.enum(['todo', 'in-progress', 'review', 'done']); 14 | 15 | // Zod schema for the parameters, matching FR-003 and listTasksTool.md spec 16 | export const TOOL_PARAMS = z.object({ 17 | project_id: z.string() 18 | .uuid("The project_id must be a valid UUID.") 19 | .describe("The unique identifier (UUID) of the project whose tasks are to be listed. This project must exist."), // Required, UUID format 20 | 21 | status: TaskStatusEnum 22 | .optional() 23 | .describe("Optional filter to return only tasks matching the specified status."), // Optional, enum 24 | 25 | include_subtasks: z.boolean() 26 | .optional() 27 | .default(false) // Default value 28 | .describe("Optional flag (default false). If true, the response will include subtasks nested within their parent tasks."), // Optional, boolean, default 29 | }); 30 | 31 | // Define the expected type for arguments based on the Zod schema 32 | export type ListTasksArgs = z.infer<typeof TOOL_PARAMS>; 33 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "mcp-task-manager-server", 3 | "version": "0.1.0", 4 | "description": "My new MCP Server", 5 | "main": "dist/server.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "node dist/server.js", 9 | "build": "tsc && copyfiles -f src/db/*.sql dist/db", 10 | "dev": "nodemon --watch src --ext ts --exec \"node --loader ts-node/esm src/server.ts\"", 11 | "lint": "eslint . --ext .ts", 12 | "format": "prettier --write \"src/**/*.ts\"", 13 | "test": "echo \"Error: no test specified\" && exit 1", 14 | "prepare": "husky install || true" 15 | }, 16 | "keywords": [ 17 | "mcp", 18 | "model-context-protocol" 19 | ], 20 | "license": "ISC", 21 | "dependencies": { 22 | "@modelcontextprotocol/sdk": "^1.9.0", 23 | "@types/better-sqlite3": "^7.6.13", 24 | "@types/inquirer": "^9.0.7", 25 | "@types/uuid": "^10.0.0", 26 | "better-sqlite3": "^11.9.1", 27 | "chalk": "^5.3.0", 28 | "inquirer": "^12.5.0", 29 | "pino": "^9.6.0", 30 | "uuid": "^11.1.0", 31 | "zod": "^3.23.8" 32 | }, 33 | "devDependencies": { 34 | "@types/node": "^20.14.2", 35 | "@typescript-eslint/eslint-plugin": "^7.13.0", 36 | "@typescript-eslint/parser": "^7.13.0", 37 | "copyfiles": "^2.4.1", 38 | "eslint": "^8.57.0", 39 | "eslint-config-prettier": "^9.1.0", 40 | "eslint-plugin-prettier": "^5.1.3", 41 | "husky": "^9.0.11", 42 | "lint-staged": "^15.2.5", 43 | "nodemon": "^3.1.3", 44 | "prettier": "^3.3.2", 45 | "ts-node": "^10.9.2", 46 | "typescript": "^5.4.5" 47 | }, 48 | "lint-staged": { 49 | "*.ts": [ 50 | "eslint --fix", 51 | "prettier --write" 52 | ] 53 | } 54 | } 55 | ``` -------------------------------------------------------------------------------- /src/tools/setTaskStatusParams.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | 3 | export const TOOL_NAME = "setTaskStatus"; 4 | 5 | export const TOOL_DESCRIPTION = ` 6 | Updates the status ('todo', 'in-progress', 'review', 'done') for one or more tasks within a specified project. 7 | Requires the project ID, an array of task IDs (1-100), and the target status. 8 | Verifies all tasks exist in the project before updating. Returns the count of updated tasks. 9 | `; 10 | 11 | // Re-use enum from other param files 12 | const TaskStatusEnum = z.enum(['todo', 'in-progress', 'review', 'done']); 13 | 14 | // Zod schema for the parameters, matching FR-005 and setTaskStatusTool.md spec 15 | export const TOOL_PARAMS = z.object({ 16 | project_id: z.string() 17 | .uuid("The project_id must be a valid UUID.") 18 | .describe("The unique identifier (UUID) of the project containing the tasks."), // Required, UUID format 19 | 20 | task_ids: z.array( 21 | z.string().min(1, "Task ID cannot be empty.") 22 | // Add .uuid() if task IDs are UUIDs 23 | .describe("A unique identifier of a task to update.") 24 | ) 25 | .min(1, "At least one task ID must be provided.") 26 | .max(100, "Cannot update more than 100 tasks per call.") 27 | .describe("An array of task IDs (1-100) whose status should be updated."), // Required, array of strings, limits 28 | 29 | status: TaskStatusEnum 30 | .describe("The target status to set for the specified tasks."), // Required, enum 31 | }); 32 | 33 | // Define the expected type for arguments based on the Zod schema 34 | export type SetTaskStatusArgs = z.infer<typeof TOOL_PARAMS>; 35 | ``` -------------------------------------------------------------------------------- /tasks.md: -------------------------------------------------------------------------------- ```markdown 1 | # Task Manager Server - Development Tasks 2 | 3 | This file tracks the implementation progress based on the defined milestones. 4 | 5 | ## Milestone 1: Core Setup & `createProject` Tool 6 | 7 | - [x] **Create `tasks.md`:** Initial file creation. 8 | - [x] **Define DB Schema:** Create `src/db/schema.sql` with tables and indexes. 9 | - [x] **Implement DB Manager:** Create `src/db/DatabaseManager.ts` for connection, init, WAL. 10 | - [x] **Update Config:** Ensure `src/config/ConfigurationManager.ts` handles DB path. 11 | - [x] **Implement Project Repo:** Create `src/repositories/ProjectRepository.ts` with `create` method. 12 | - [x] **Implement Project Service:** Create `src/services/ProjectService.ts` with `createProject` method. 13 | - [x] **Implement `createProject` Params:** Create `src/tools/createProjectParams.ts`. 14 | - [x] **Implement `createProject` Tool:** Create `src/tools/createProjectTool.ts`. 15 | - [x] **Implement Utilities:** Create/update `src/utils/logger.ts`, `src/utils/errors.ts`, `src/utils/index.ts`. 16 | - [x] **Update Server Setup:** Modify `src/server.ts`, `src/createServer.ts`, `src/tools/index.ts`, `src/services/index.ts`. 17 | - [ ] **Write Tests:** Unit test `ProjectService`, Integration test `createProject` tool. *(Skipped/Deferred)* 18 | 19 | ## Milestone 2: Core Task Management Tools 20 | 21 | - [x] Implement `addTask` tool (FR-002) 22 | - [x] Implement `listTasks` tool (FR-003) 23 | - [x] Implement `showTask` tool (FR-004) 24 | - [x] Implement `setTaskStatus` tool (FR-005) 25 | 26 | ## Milestone 3: Advanced & I/O Tools 27 | 28 | - [x] Implement `expandTask` tool (FR-006) 29 | - [x] Implement `getNextTask` tool (FR-007) 30 | - [x] Implement `exportProject` tool (FR-009) 31 | - [x] Implement `importProject` tool (FR-010) 32 | - [x] Implement structured logging (NFR-006). 33 | - [x] Finalize documentation (README, tool descriptions). 34 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 4 | "module": "NodeNext", /* Specify what module code is generated. */ 5 | "moduleResolution": "NodeNext", /* Specify how TypeScript looks up a file from a given module specifier. */ 6 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 7 | "rootDir": "./src", /* Specify the root folder within your source files. */ 8 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 9 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 10 | "strict": true, /* Enable all strict type-checking options. */ 11 | "skipLibCheck": true, /* Skip type checking all .d.ts files. */ 12 | "resolveJsonModule": true, /* Enable importing .json files */ 13 | "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 14 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 15 | "declarationMap": true, /* Create sourcemaps for d.ts files. */ 16 | "allowJs": true, /* Allow JavaScript files to be a part of your program. */ 17 | }, 18 | "ts-node": { /* ts-node specific options */ 19 | "transpileOnly": true, /* Skip type checking for faster execution */ 20 | "files": true /* Include files in tsconfig.json */ 21 | }, 22 | "include": [ 23 | "src/**/*" 24 | ], /* Specifies an array of filenames or patterns to include in the program */ 25 | "exclude": [ 26 | "node_modules", 27 | "dist" 28 | ] /* Specifies an array of filenames or patterns that should be skipped when resolving include */ 29 | } ``` -------------------------------------------------------------------------------- /src/types/taskTypes.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Represents the possible status values for a task. 3 | * Using string literal union as per .clinerules (no enums). 4 | */ 5 | export type TaskStatus = 'todo' | 'in-progress' | 'review' | 'done'; 6 | 7 | /** 8 | * Represents the possible priority levels for a task. 9 | * Using string literal union as per .clinerules (no enums). 10 | */ 11 | export type TaskPriority = 'high' | 'medium' | 'low'; 12 | 13 | /** 14 | * Interface representing a Task object as returned by the API. 15 | */ 16 | export interface Task { 17 | task_id: string; // UUID format 18 | project_id: string; // UUID format 19 | parent_task_id: string | null; // UUID format or null 20 | description: string; 21 | status: TaskStatus; 22 | priority: TaskPriority; 23 | created_at: string; // ISO8601 format 24 | updated_at: string; // ISO8601 format 25 | dependencies?: string[]; // Array of task_ids this task depends on 26 | subtasks?: Task[]; // Array of subtasks (populated if requested, e.g., listTasks with include_subtasks=true) 27 | } 28 | 29 | /** 30 | * Interface representing the payload for updating a task (FR-011). 31 | * All fields are optional, but at least one must be provided for an update. 32 | */ 33 | export interface TaskUpdatePayload { 34 | description?: string; 35 | priority?: TaskPriority; 36 | dependencies?: string[]; // Represents the complete new list of dependencies 37 | } 38 | 39 | /** 40 | * Interface representing the structure of a Task as stored in the database. 41 | * May differ slightly from the API representation (e.g., no nested subtasks/dependencies). 42 | */ 43 | export interface TaskDbObject { 44 | task_id: string; 45 | project_id: string; 46 | parent_task_id: string | null; 47 | description: string; 48 | status: TaskStatus; 49 | priority: TaskPriority; 50 | created_at: string; 51 | updated_at: string; 52 | } 53 | 54 | /** 55 | * Interface representing a record in the task_dependencies table. 56 | */ 57 | export interface TaskDependencyDbObject { 58 | task_id: string; 59 | depends_on_task_id: string; 60 | } 61 | ``` -------------------------------------------------------------------------------- /public/images/mcp-task-manager-logo.svg: -------------------------------------------------------------------------------- ``` 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"> 3 | <!-- Background circle --> 4 | <circle cx="100" cy="100" r="90" fill="#2a2a2a" /> 5 | 6 | <!-- Outer ring --> 7 | <circle cx="100" cy="100" r="85" fill="none" stroke="#4a86e8" stroke-width="4" /> 8 | 9 | <!-- Task board grid --> 10 | <rect x="50" y="60" width="100" height="80" rx="5" ry="5" fill="#333" stroke="#4a86e8" stroke-width="2" /> 11 | 12 | <!-- Task columns --> 13 | <rect x="55" y="65" width="28" height="70" rx="3" ry="3" fill="#444" /> 14 | <rect x="87" y="65" width="28" height="70" rx="3" ry="3" fill="#444" /> 15 | <rect x="119" y="65" width="28" height="70" rx="3" ry="3" fill="#444" /> 16 | 17 | <!-- Task items --> 18 | <rect x="57" y="70" width="24" height="8" rx="2" ry="2" fill="#6aa84f" /> <!-- Done --> 19 | <rect x="57" y="82" width="24" height="8" rx="2" ry="2" fill="#6aa84f" /> <!-- Done --> 20 | <rect x="89" y="70" width="24" height="8" rx="2" ry="2" fill="#f1c232" /> <!-- In Progress --> 21 | <rect x="121" y="70" width="24" height="8" rx="2" ry="2" fill="#cc4125" /> <!-- Todo --> 22 | <rect x="121" y="82" width="24" height="8" rx="2" ry="2" fill="#cc4125" /> <!-- Todo --> 23 | <rect x="121" y="94" width="24" height="8" rx="2" ry="2" fill="#cc4125" /> <!-- Todo --> 24 | 25 | <!-- "MCP" text --> 26 | <text x="100" y="155" font-family="Arial, sans-serif" font-weight="bold" font-size="24" fill="white" text-anchor="middle">MCP TASKS</text> 27 | 28 | <!-- Database icon to represent SQLite --> 29 | <g transform="translate(160, 40) scale(0.6)"> 30 | <path d="M10,30 C10,42 30,42 30,30 L30,10 C30,0 10,0 10,10 L10,30 Z" fill="#e69138" /> 31 | <rect x="10" y="10" width="20" height="5" fill="#2a2a2a" /> 32 | <rect x="10" y="20" width="20" height="5" fill="#2a2a2a" /> 33 | </g> 34 | 35 | <!-- Connection lines representing dependencies --> 36 | <path d="M81 74 L89 74" stroke="#4a86e8" stroke-width="1.5" /> 37 | <path d="M113 74 L121 74" stroke="#4a86e8" stroke-width="1.5" /> 38 | </svg> ``` -------------------------------------------------------------------------------- /src/tools/expandTaskParams.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | 3 | export const TOOL_NAME = "expandTask"; 4 | 5 | export const TOOL_DESCRIPTION = ` 6 | Breaks down a specified parent task into multiple subtasks based on provided descriptions. 7 | Requires the project ID, the parent task ID, and an array of descriptions for the new subtasks. 8 | Optionally allows forcing the replacement of existing subtasks using the 'force' flag. 9 | Returns the updated parent task details, including the newly created subtasks. 10 | `; 11 | 12 | // Zod schema for the parameters, matching FR-006 and expandTaskTool.md spec 13 | export const TOOL_PARAMS = z.object({ 14 | project_id: z.string() 15 | .uuid("The project_id must be a valid UUID.") 16 | .describe("The unique identifier (UUID) of the project containing the parent task."), // Required, UUID format 17 | 18 | task_id: z.string() 19 | // Add .uuid() if task IDs are also UUIDs 20 | .min(1, "Parent task ID cannot be empty.") 21 | .describe("The unique identifier of the parent task to be expanded."), // Required, string (or UUID) 22 | 23 | subtask_descriptions: z.array( 24 | z.string() 25 | .min(1, "Subtask description cannot be empty.") 26 | .max(512, "Subtask description cannot exceed 512 characters.") 27 | .describe("A textual description for one of the new subtasks (1-512 characters).") 28 | ) 29 | .min(1, "At least one subtask description must be provided.") 30 | .max(20, "Cannot create more than 20 subtasks per call.") 31 | .describe("An array of descriptions (1-20) for the new subtasks to be created under the parent task."), // Required, array of strings, limits 32 | 33 | force: z.boolean() 34 | .optional() 35 | .default(false) 36 | .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 37 | }); 38 | 39 | // Define the expected type for arguments based on the Zod schema 40 | export type ExpandTaskArgs = z.infer<typeof TOOL_PARAMS>; 41 | ``` -------------------------------------------------------------------------------- /src/tools/addTaskParams.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | 3 | export const TOOL_NAME = "addTask"; 4 | 5 | export const TOOL_DESCRIPTION = ` 6 | Adds a new task to a specified project within the Task Management Server. 7 | Requires the project ID and a description for the task. 8 | Optionally accepts a list of dependency task IDs, a priority level, and an initial status. 9 | Returns the full details of the newly created task upon success. 10 | `; 11 | 12 | // Allowed enum values for status and priority 13 | const TaskStatusEnum = z.enum(['todo', 'in-progress', 'review', 'done']); 14 | const TaskPriorityEnum = z.enum(['high', 'medium', 'low']); 15 | 16 | // Zod schema for the parameters, matching FR-002 and addTaskTool.md spec 17 | export const TOOL_PARAMS = z.object({ 18 | project_id: z.string() 19 | .uuid("The project_id must be a valid UUID.") 20 | .describe("The unique identifier (UUID) of the project to add the task to. This project must already exist."), // Required, UUID format 21 | 22 | description: z.string() 23 | .min(1, "Task description cannot be empty.") 24 | .max(1024, "Task description cannot exceed 1024 characters.") 25 | .describe("The textual description of the task to be performed (1-1024 characters)."), // Required, length limits 26 | 27 | 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) 28 | .max(50, "A task cannot have more than 50 dependencies.") 29 | .optional() 30 | .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 31 | 32 | priority: TaskPriorityEnum 33 | .optional() 34 | .default('medium') // Default value 35 | .describe("Optional task priority. Defaults to 'medium' if not specified."), // Optional, enum, default 36 | 37 | status: TaskStatusEnum 38 | .optional() 39 | .default('todo') // Default value 40 | .describe("Optional initial status of the task. Defaults to 'todo' if not specified."), // Optional, enum, default 41 | }); 42 | 43 | // Define the expected type for arguments based on the Zod schema 44 | export type AddTaskArgs = z.infer<typeof TOOL_PARAMS>; 45 | ``` -------------------------------------------------------------------------------- /src/tools/exportProjectTool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 3 | import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, ExportProjectArgs } from "./exportProjectParams.js"; 4 | import { ProjectService } from "../services/ProjectService.js"; // Assuming ProjectService is exported via services/index.js 5 | import { logger } from '../utils/logger.js'; 6 | import { NotFoundError } from "../utils/errors.js"; 7 | 8 | /** 9 | * Registers the exportProject tool with the MCP server. 10 | * 11 | * @param server - The McpServer instance. 12 | * @param projectService - An instance of the ProjectService. 13 | */ 14 | export const exportProjectTool = (server: McpServer, projectService: ProjectService): void => { 15 | 16 | const processRequest = async (args: ExportProjectArgs) => { 17 | logger.info(`[${TOOL_NAME}] Received request with args:`, args); 18 | try { 19 | // Zod schema ensures format is 'json' if provided, or defaults to 'json' 20 | const jsonString = await projectService.exportProject(args.project_id); 21 | 22 | // Format the successful response 23 | logger.info(`[${TOOL_NAME}] Successfully exported project ${args.project_id}`); 24 | return { 25 | content: [{ 26 | type: "text" as const, 27 | text: jsonString // Return the JSON string directly 28 | }] 29 | }; 30 | } catch (error: unknown) { 31 | // Handle potential errors 32 | logger.error(`[${TOOL_NAME}] Error processing request:`, error); 33 | 34 | if (error instanceof NotFoundError) { 35 | // Project not found 36 | throw new McpError(ErrorCode.InvalidParams, error.message); 37 | } else { 38 | // Generic internal error 39 | const message = error instanceof Error ? error.message : 'An unknown error occurred while exporting the project.'; 40 | throw new McpError(ErrorCode.InternalError, message); 41 | } 42 | } 43 | }; 44 | 45 | // Register the tool with the server 46 | server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); 47 | 48 | logger.info(`[${TOOL_NAME}] Tool registered successfully.`); 49 | }; 50 | ``` -------------------------------------------------------------------------------- /src/tools/showTaskTool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 3 | import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, ShowTaskArgs } from "./showTaskParams.js"; 4 | import { TaskService } from "../services/TaskService.js"; 5 | import { logger } from '../utils/logger.js'; 6 | import { NotFoundError } from "../utils/errors.js"; 7 | 8 | /** 9 | * Registers the showTask tool with the MCP server. 10 | * 11 | * @param server - The McpServer instance. 12 | * @param taskService - An instance of the TaskService. 13 | */ 14 | export const showTaskTool = (server: McpServer, taskService: TaskService): void => { 15 | 16 | const processRequest = async (args: ShowTaskArgs) => { 17 | logger.info(`[${TOOL_NAME}] Received request with args:`, args); 18 | try { 19 | // Call the service method to get the task details 20 | const task = await taskService.getTaskById(args.project_id, args.task_id); 21 | 22 | // Format the successful response 23 | logger.info(`[${TOOL_NAME}] Found task ${args.task_id} in project ${args.project_id}`); 24 | return { 25 | content: [{ 26 | type: "text" as const, 27 | text: JSON.stringify(task) // Return the full task object 28 | }] 29 | }; 30 | } catch (error: unknown) { 31 | // Handle potential errors 32 | logger.error(`[${TOOL_NAME}] Error processing request:`, error); 33 | 34 | if (error instanceof NotFoundError) { 35 | // Specific error if the project or task wasn't found 36 | // Map to InvalidParams as the provided ID(s) are invalid in this context 37 | throw new McpError(ErrorCode.InvalidParams, error.message); 38 | } else { 39 | // Generic internal error 40 | const message = error instanceof Error ? error.message : 'An unknown error occurred while retrieving the task.'; 41 | throw new McpError(ErrorCode.InternalError, message); 42 | } 43 | } 44 | }; 45 | 46 | // Register the tool with the server 47 | server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); 48 | 49 | logger.info(`[${TOOL_NAME}] Tool registered successfully.`); 50 | }; 51 | ``` -------------------------------------------------------------------------------- /src/db/schema.sql: -------------------------------------------------------------------------------- ```sql 1 | -- Database schema for the MCP Task Manager Server 2 | -- Based on RFC-2025-001 3 | 4 | -- Enable foreign key support 5 | PRAGMA foreign_keys = ON; 6 | 7 | -- Use Write-Ahead Logging for better concurrency 8 | PRAGMA journal_mode = WAL; 9 | 10 | -- Table: projects 11 | -- Stores project metadata 12 | CREATE TABLE IF NOT EXISTS projects ( 13 | project_id TEXT PRIMARY KEY NOT NULL, -- UUID format 14 | name TEXT NOT NULL, 15 | created_at TEXT NOT NULL -- ISO8601 format (e.g., YYYY-MM-DDTHH:MM:SS.SSSZ) 16 | ); 17 | 18 | -- Table: tasks 19 | -- Stores individual task details 20 | CREATE TABLE IF NOT EXISTS tasks ( 21 | task_id TEXT PRIMARY KEY NOT NULL, -- UUID format 22 | project_id TEXT NOT NULL, 23 | parent_task_id TEXT NULL, -- For subtasks 24 | description TEXT NOT NULL, 25 | status TEXT NOT NULL CHECK(status IN ('todo', 'in-progress', 'review', 'done')), 26 | priority TEXT NOT NULL CHECK(priority IN ('high', 'medium', 'low')), 27 | created_at TEXT NOT NULL, -- ISO8601 format 28 | updated_at TEXT NOT NULL, -- ISO8601 format 29 | FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE, 30 | FOREIGN KEY (parent_task_id) REFERENCES tasks(task_id) ON DELETE CASCADE 31 | ); 32 | 33 | -- Table: task_dependencies 34 | -- Stores prerequisite relationships between tasks 35 | CREATE TABLE IF NOT EXISTS task_dependencies ( 36 | task_id TEXT NOT NULL, -- The task that depends on another 37 | depends_on_task_id TEXT NOT NULL, -- The task that must be completed first 38 | PRIMARY KEY (task_id, depends_on_task_id), 39 | FOREIGN KEY (task_id) REFERENCES tasks(task_id) ON DELETE CASCADE, 40 | FOREIGN KEY (depends_on_task_id) REFERENCES tasks(task_id) ON DELETE CASCADE 41 | ); 42 | 43 | -- Indexes for performance optimization 44 | 45 | -- Index on tasks table 46 | CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id); 47 | CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); 48 | CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority); 49 | CREATE INDEX IF NOT EXISTS idx_tasks_parent_task_id ON tasks(parent_task_id); 50 | CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at); 51 | 52 | -- Indexes on task_dependencies table 53 | CREATE INDEX IF NOT EXISTS idx_task_dependencies_task_id ON task_dependencies(task_id); 54 | CREATE INDEX IF NOT EXISTS idx_task_dependencies_depends_on_task_id ON task_dependencies(depends_on_task_id); 55 | ``` -------------------------------------------------------------------------------- /src/tools/listTasksTool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 3 | import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, ListTasksArgs } from "./listTasksParams.js"; 4 | import { TaskService } from "../services/TaskService.js"; 5 | import { logger } from '../utils/logger.js'; 6 | import { NotFoundError } from "../utils/errors.js"; 7 | 8 | /** 9 | * Registers the listTasks tool with the MCP server. 10 | * 11 | * @param server - The McpServer instance. 12 | * @param taskService - An instance of the TaskService. 13 | */ 14 | export const listTasksTool = (server: McpServer, taskService: TaskService): void => { 15 | 16 | const processRequest = async (args: ListTasksArgs) => { 17 | logger.info(`[${TOOL_NAME}] Received request with args:`, args); 18 | try { 19 | // Call the service method to list tasks 20 | const tasks = await taskService.listTasks({ 21 | project_id: args.project_id, 22 | status: args.status, 23 | include_subtasks: args.include_subtasks, 24 | }); 25 | 26 | // Format the successful response 27 | logger.info(`[${TOOL_NAME}] Found ${tasks.length} tasks for project ${args.project_id}`); 28 | return { 29 | content: [{ 30 | type: "text" as const, 31 | text: JSON.stringify(tasks) // Return the array of task objects 32 | }] 33 | }; 34 | } catch (error: unknown) { 35 | // Handle potential errors 36 | logger.error(`[${TOOL_NAME}] Error processing request:`, error); 37 | 38 | if (error instanceof NotFoundError) { 39 | // Specific error if the project wasn't found 40 | throw new McpError(ErrorCode.InvalidParams, error.message); // Map NotFound to InvalidParams for project_id 41 | } else { 42 | // Generic internal error 43 | const message = error instanceof Error ? error.message : 'An unknown error occurred while listing tasks.'; 44 | throw new McpError(ErrorCode.InternalError, message); 45 | } 46 | } 47 | }; 48 | 49 | // Register the tool with the server 50 | server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); 51 | 52 | logger.info(`[${TOOL_NAME}] Tool registered successfully.`); 53 | }; 54 | ``` -------------------------------------------------------------------------------- /src/tools/getNextTaskTool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 3 | import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, GetNextTaskArgs } from "./getNextTaskParams.js"; 4 | import { TaskService } from "../services/TaskService.js"; 5 | import { logger } from '../utils/logger.js'; 6 | import { NotFoundError } from "../utils/errors.js"; 7 | 8 | /** 9 | * Registers the getNextTask tool with the MCP server. 10 | * 11 | * @param server - The McpServer instance. 12 | * @param taskService - An instance of the TaskService. 13 | */ 14 | export const getNextTaskTool = (server: McpServer, taskService: TaskService): void => { 15 | 16 | const processRequest = async (args: GetNextTaskArgs) => { 17 | logger.info(`[${TOOL_NAME}] Received request with args:`, args); 18 | try { 19 | // Call the service method to get the next task 20 | const nextTask = await taskService.getNextTask(args.project_id); 21 | 22 | // Format the successful response 23 | if (nextTask) { 24 | logger.info(`[${TOOL_NAME}] Next task found: ${nextTask.task_id} in project ${args.project_id}`); 25 | } else { 26 | logger.info(`[${TOOL_NAME}] No ready task found for project ${args.project_id}`); 27 | } 28 | 29 | return { 30 | content: [{ 31 | type: "text" as const, 32 | // Return the full task object or null 33 | text: JSON.stringify(nextTask) 34 | }] 35 | }; 36 | } catch (error: unknown) { 37 | // Handle potential errors 38 | logger.error(`[${TOOL_NAME}] Error processing request:`, error); 39 | 40 | if (error instanceof NotFoundError) { 41 | // Project not found 42 | throw new McpError(ErrorCode.InvalidParams, error.message); 43 | } else { 44 | // Generic internal error 45 | const message = error instanceof Error ? error.message : 'An unknown error occurred while getting the next task.'; 46 | throw new McpError(ErrorCode.InternalError, message); 47 | } 48 | } 49 | }; 50 | 51 | // Register the tool with the server 52 | server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); 53 | 54 | logger.info(`[${TOOL_NAME}] Tool registered successfully.`); 55 | }; 56 | ``` -------------------------------------------------------------------------------- /src/tools/setTaskStatusTool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 3 | import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, SetTaskStatusArgs } from "./setTaskStatusParams.js"; 4 | import { TaskService } from "../services/TaskService.js"; 5 | import { logger } from '../utils/logger.js'; 6 | import { NotFoundError } from "../utils/errors.js"; 7 | 8 | /** 9 | * Registers the setTaskStatus tool with the MCP server. 10 | * 11 | * @param server - The McpServer instance. 12 | * @param taskService - An instance of the TaskService. 13 | */ 14 | export const setTaskStatusTool = (server: McpServer, taskService: TaskService): void => { 15 | 16 | const processRequest = async (args: SetTaskStatusArgs) => { 17 | logger.info(`[${TOOL_NAME}] Received request with args:`, args); 18 | try { 19 | // Call the service method to update the status 20 | const updatedCount = await taskService.setTaskStatus( 21 | args.project_id, 22 | args.task_ids, 23 | args.status 24 | ); 25 | 26 | // Format the successful response 27 | const responsePayload = { success: true, updated_count: updatedCount }; 28 | logger.info(`[${TOOL_NAME}] Updated status for ${updatedCount} tasks in project ${args.project_id}`); 29 | return { 30 | content: [{ 31 | type: "text" as const, 32 | text: JSON.stringify(responsePayload) 33 | }] 34 | }; 35 | } catch (error: unknown) { 36 | // Handle potential errors 37 | logger.error(`[${TOOL_NAME}] Error processing request:`, error); 38 | 39 | if (error instanceof NotFoundError) { 40 | // Specific error if the project or any task wasn't found 41 | // Map to InvalidParams as the provided ID(s) are invalid 42 | throw new McpError(ErrorCode.InvalidParams, error.message); 43 | } else { 44 | // Generic internal error 45 | const message = error instanceof Error ? error.message : 'An unknown error occurred while setting task status.'; 46 | throw new McpError(ErrorCode.InternalError, message); 47 | } 48 | } 49 | }; 50 | 51 | // Register the tool with the server 52 | server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); 53 | 54 | logger.info(`[${TOOL_NAME}] Tool registered successfully.`); 55 | }; 56 | ``` -------------------------------------------------------------------------------- /src/tools/importProjectTool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 3 | import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, ImportProjectArgs } from "./importProjectParams.js"; 4 | import { ProjectService } from "../services/ProjectService.js"; 5 | import { logger } from '../utils/logger.js'; 6 | import { ValidationError } from "../utils/errors.js"; // Import specific errors 7 | 8 | /** 9 | * Registers the importProject tool with the MCP server. 10 | * 11 | * @param server - The McpServer instance. 12 | * @param projectService - An instance of the ProjectService. 13 | */ 14 | export const importProjectTool = (server: McpServer, projectService: ProjectService): void => { 15 | 16 | const processRequest = async (args: ImportProjectArgs) => { 17 | logger.info(`[${TOOL_NAME}] Received request (project name: ${args.new_project_name || 'Default'})`); 18 | try { 19 | // Call the service method to import the project 20 | const result = await projectService.importProject( 21 | args.project_data, 22 | args.new_project_name 23 | ); 24 | 25 | // Format the successful response 26 | const responsePayload = { project_id: result.project_id }; 27 | logger.info(`[${TOOL_NAME}] Successfully imported project. New ID: ${result.project_id}`); 28 | return { 29 | content: [{ 30 | type: "text" as const, 31 | text: JSON.stringify(responsePayload) 32 | }] 33 | }; 34 | } catch (error: unknown) { 35 | // Handle potential errors 36 | logger.error(`[${TOOL_NAME}] Error processing request:`, error); 37 | 38 | if (error instanceof ValidationError) { 39 | // JSON parsing, schema validation, size limit, or other data issues 40 | throw new McpError(ErrorCode.InvalidParams, error.message); 41 | } else { 42 | // Generic internal error (likely database related from the transaction) 43 | const message = error instanceof Error ? error.message : 'An unknown error occurred during project import.'; 44 | throw new McpError(ErrorCode.InternalError, message); 45 | } 46 | } 47 | }; 48 | 49 | // Register the tool with the server 50 | server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); 51 | 52 | logger.info(`[${TOOL_NAME}] Tool registered successfully.`); 53 | }; 54 | ``` -------------------------------------------------------------------------------- /src/tools/deleteTaskTool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 3 | import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, DeleteTaskArgs } from "./deleteTaskParams.js"; 4 | import { TaskService } from "../services/TaskService.js"; // Assuming TaskService is exported from index 5 | import { logger } from '../utils/logger.js'; 6 | import { NotFoundError } from "../utils/errors.js"; // Import custom errors 7 | 8 | /** 9 | * Registers the deleteTask tool with the MCP server. 10 | * 11 | * @param server - The McpServer instance. 12 | * @param taskService - An instance of the TaskService. 13 | */ 14 | export const deleteTaskTool = (server: McpServer, taskService: TaskService): void => { 15 | 16 | const processRequest = async (args: DeleteTaskArgs): Promise<{ content: { type: 'text', text: string }[] }> => { 17 | logger.info(`[${TOOL_NAME}] Received request to delete ${args.task_ids.length} tasks from project ${args.project_id}`); 18 | try { 19 | // Call the service method to delete the tasks 20 | const deletedCount = await taskService.deleteTasks(args.project_id, args.task_ids); 21 | 22 | // Format the successful response 23 | logger.info(`[${TOOL_NAME}] Successfully deleted ${deletedCount} tasks from project ${args.project_id}`); 24 | return { 25 | content: [{ 26 | type: "text" as const, 27 | text: JSON.stringify({ success: true, deleted_count: deletedCount }) 28 | }] 29 | }; 30 | } catch (error: unknown) { 31 | // Handle potential errors according to systemPatterns.md mapping 32 | logger.error(`[${TOOL_NAME}] Error processing request:`, error); 33 | 34 | if (error instanceof NotFoundError) { 35 | // Project or one/more tasks not found - Map to InvalidParams as per convention 36 | throw new McpError(ErrorCode.InvalidParams, error.message); 37 | } else { 38 | // Generic internal error 39 | const message = error instanceof Error ? error.message : 'An unknown error occurred while deleting tasks.'; 40 | throw new McpError(ErrorCode.InternalError, message); 41 | } 42 | } 43 | }; 44 | 45 | // Register the tool with the server 46 | server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); // Using .shape as this schema doesn't use .refine() 47 | 48 | logger.info(`[${TOOL_NAME}] Tool registered successfully.`); 49 | }; 50 | ``` -------------------------------------------------------------------------------- /src/tools/deleteProjectTool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 3 | import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, DeleteProjectArgs } from "./deleteProjectParams.js"; 4 | import { ProjectService } from "../services/ProjectService.js"; // Assuming ProjectService is exported from index 5 | import { logger } from '../utils/logger.js'; 6 | import { NotFoundError } from "../utils/errors.js"; // Import custom errors 7 | 8 | /** 9 | * Registers the deleteProject tool with the MCP server. 10 | * 11 | * @param server - The McpServer instance. 12 | * @param projectService - An instance of the ProjectService. 13 | */ 14 | export const deleteProjectTool = (server: McpServer, projectService: ProjectService): void => { 15 | 16 | const processRequest = async (args: DeleteProjectArgs): Promise<{ content: { type: 'text', text: string }[] }> => { 17 | logger.warn(`[${TOOL_NAME}] Received request to DELETE project ${args.project_id}. This is a destructive operation.`); // Log deletion intent clearly 18 | try { 19 | // Call the service method to delete the project 20 | const success = await projectService.deleteProject(args.project_id); 21 | 22 | // Format the successful response 23 | logger.info(`[${TOOL_NAME}] Successfully deleted project ${args.project_id}`); 24 | return { 25 | content: [{ 26 | type: "text" as const, 27 | text: JSON.stringify({ success: success }) // Return true if deleted 28 | }] 29 | }; 30 | } catch (error: unknown) { 31 | // Handle potential errors according to systemPatterns.md mapping 32 | logger.error(`[${TOOL_NAME}] Error processing request:`, error); 33 | 34 | if (error instanceof NotFoundError) { 35 | // Project not found - Map to InvalidParams as per convention 36 | throw new McpError(ErrorCode.InvalidParams, error.message); 37 | } else { 38 | // Generic internal error 39 | const message = error instanceof Error ? error.message : 'An unknown error occurred while deleting the project.'; 40 | throw new McpError(ErrorCode.InternalError, message); 41 | } 42 | } 43 | }; 44 | 45 | // Register the tool with the server 46 | server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); // Using .shape as this schema doesn't use .refine() 47 | 48 | logger.info(`[${TOOL_NAME}] Tool registered successfully.`); 49 | }; 50 | ``` -------------------------------------------------------------------------------- /src/tools/createProjectTool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 3 | import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, CreateProjectArgs } from "./createProjectParams.js"; 4 | import { ProjectService } from "../services/ProjectService.js"; // Assuming ProjectService is exported from services/index.js or directly 5 | import { logger } from '../utils/logger.js'; // Assuming logger exists 6 | // Import custom errors if needed for specific mapping 7 | // import { ServiceError } from "../utils/errors.js"; 8 | 9 | /** 10 | * Registers the createProject tool with the MCP server. 11 | * 12 | * @param server - The McpServer instance. 13 | * @param projectService - An instance of the ProjectService. 14 | */ 15 | export const createProjectTool = (server: McpServer, projectService: ProjectService): void => { 16 | 17 | // Define the asynchronous function that handles the actual tool logic 18 | const processRequest = async (args: CreateProjectArgs) => { 19 | logger.info(`[${TOOL_NAME}] Received request with args:`, args); 20 | try { 21 | // Call the service method to create the project 22 | const newProject = await projectService.createProject(args.projectName); 23 | 24 | // Format the successful response according to MCP standards 25 | const responsePayload = { project_id: newProject.project_id }; 26 | logger.info(`[${TOOL_NAME}] Project created successfully: ${newProject.project_id}`); 27 | 28 | return { 29 | content: [{ 30 | type: "text" as const, // Required type assertion 31 | text: JSON.stringify(responsePayload) 32 | }] 33 | }; 34 | } catch (error: unknown) { 35 | // Handle potential errors from the service layer 36 | logger.error(`[${TOOL_NAME}] Error processing request:`, error); 37 | 38 | // Basic error mapping: Assume internal error unless a specific known error type is caught 39 | // TODO: Add more specific error mapping if ProjectService throws custom errors 40 | // (e.g., catch (error instanceof ValidationError) { throw new McpError(ErrorCode.InvalidParams, ...)}) 41 | const message = error instanceof Error ? error.message : 'An unknown error occurred during project creation.'; 42 | throw new McpError(ErrorCode.InternalError, message); 43 | } 44 | }; 45 | 46 | // Register the tool with the server 47 | server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, processRequest); 48 | 49 | logger.info(`[${TOOL_NAME}] Tool registered successfully.`); 50 | }; 51 | ``` -------------------------------------------------------------------------------- /src/tools/expandTaskTool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 3 | import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, ExpandTaskArgs } from "./expandTaskParams.js"; 4 | import { TaskService } from "../services/TaskService.js"; 5 | import { logger } from '../utils/logger.js'; 6 | import { NotFoundError, ConflictError } from "../utils/errors.js"; // Import specific errors 7 | 8 | /** 9 | * Registers the expandTask tool with the MCP server. 10 | * 11 | * @param server - The McpServer instance. 12 | * @param taskService - An instance of the TaskService. 13 | */ 14 | export const expandTaskTool = (server: McpServer, taskService: TaskService): void => { 15 | 16 | const processRequest = async (args: ExpandTaskArgs) => { 17 | logger.info(`[${TOOL_NAME}] Received request with args:`, args); 18 | try { 19 | // Call the service method to expand the task 20 | const updatedParentTask = await taskService.expandTask({ 21 | project_id: args.project_id, 22 | task_id: args.task_id, 23 | subtask_descriptions: args.subtask_descriptions, 24 | force: args.force, 25 | }); 26 | 27 | // Format the successful response 28 | logger.info(`[${TOOL_NAME}] Successfully expanded task ${args.task_id} in project ${args.project_id}`); 29 | return { 30 | content: [{ 31 | type: "text" as const, 32 | // Return the updated parent task details, including new subtasks 33 | text: JSON.stringify(updatedParentTask) 34 | }] 35 | }; 36 | } catch (error: unknown) { 37 | // Handle potential errors 38 | logger.error(`[${TOOL_NAME}] Error processing request:`, error); 39 | 40 | if (error instanceof NotFoundError) { 41 | // Project or parent task not found 42 | throw new McpError(ErrorCode.InvalidParams, error.message); 43 | } else if (error instanceof ConflictError) { 44 | // Subtasks exist and force=false - map to InvalidParams as the request is invalid without force=true 45 | throw new McpError(ErrorCode.InvalidParams, error.message); 46 | } else { 47 | // Generic internal error 48 | const message = error instanceof Error ? error.message : 'An unknown error occurred while expanding the task.'; 49 | throw new McpError(ErrorCode.InternalError, message); 50 | } 51 | } 52 | }; 53 | 54 | // Register the tool with the server 55 | server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); 56 | 57 | logger.info(`[${TOOL_NAME}] Tool registered successfully.`); 58 | }; 59 | ``` -------------------------------------------------------------------------------- /src/tools/updateTaskParams.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { TaskPriority, TaskStatus } from '../types/taskTypes.js'; // Import shared types 3 | 4 | export const TOOL_NAME = "updateTask"; 5 | 6 | export const TOOL_DESCRIPTION = ` 7 | Updates specific details of an existing task within a project. 8 | Requires the project ID and task ID. Allows updating description, priority, and/or dependencies. 9 | At least one optional field (description, priority, dependencies) must be provided. 10 | Returns the full details of the updated task upon success. 11 | `; 12 | 13 | // Define the possible priority values based on the shared type 14 | const priorities: [TaskPriority, ...TaskPriority[]] = ['high', 'medium', 'low']; 15 | 16 | // Base Zod schema without refinement - needed for server.tool registration 17 | export const UPDATE_TASK_BASE_SCHEMA = z.object({ 18 | project_id: z.string() 19 | .uuid("The project_id must be a valid UUID.") 20 | .describe("The unique identifier (UUID) of the project containing the task to update. This project must exist."), // Required, UUID format 21 | 22 | task_id: z.string() 23 | .uuid("The task_id must be a valid UUID.") // Assuming task IDs are UUIDs for consistency 24 | .describe("The unique identifier (UUID) of the task to update. This task must exist within the specified project."), // Required, UUID format 25 | 26 | description: z.string() 27 | .min(1, "Description cannot be empty if provided.") 28 | .max(1024, "Description cannot exceed 1024 characters.") 29 | .optional() 30 | .describe("Optional. The new textual description for the task (1-1024 characters)."), // Optional, string, limits 31 | 32 | priority: z.enum(priorities) 33 | .optional() 34 | .describe("Optional. The new priority level for the task ('high', 'medium', or 'low')."), // Optional, enum 35 | 36 | dependencies: z.array( 37 | z.string() 38 | .uuid("Each dependency task ID must be a valid UUID.") 39 | .describe("A task ID (UUID) that this task should depend on.") 40 | ) 41 | .max(50, "A task cannot have more than 50 dependencies.") 42 | .optional() 43 | .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 44 | }); 45 | 46 | // Refined schema for validation and type inference 47 | export const TOOL_PARAMS = UPDATE_TASK_BASE_SCHEMA.refine( 48 | data => data.description !== undefined || data.priority !== undefined || data.dependencies !== undefined, { 49 | message: "At least one field to update (description, priority, or dependencies) must be provided.", 50 | // path: [], // No specific path, applies to the object 51 | } 52 | ); 53 | 54 | // Define the expected type for arguments based on the *refined* Zod schema 55 | export type UpdateTaskArgs = z.infer<typeof TOOL_PARAMS>; 56 | ``` -------------------------------------------------------------------------------- /src/repositories/ProjectRepository.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Database as Db } from 'better-sqlite3'; 2 | import { logger } from '../utils/logger.js'; // Assuming logger exists 3 | 4 | export interface ProjectData { 5 | project_id: string; 6 | name: string; 7 | created_at: string; // ISO8601 format 8 | } 9 | 10 | export class ProjectRepository { 11 | private db: Db; 12 | 13 | // Pass the database connection instance 14 | constructor(db: Db) { 15 | this.db = db; 16 | } 17 | 18 | /** 19 | * Creates a new project record in the database. 20 | * @param project - The project data to insert. 21 | * @throws {Error} If the database operation fails. 22 | */ 23 | public create(project: ProjectData): void { 24 | const sql = ` 25 | INSERT INTO projects (project_id, name, created_at) 26 | VALUES (@project_id, @name, @created_at) 27 | `; 28 | try { 29 | const stmt = this.db.prepare(sql); 30 | const info = stmt.run(project); 31 | logger.info(`[ProjectRepository] Created project ${project.project_id}, changes: ${info.changes}`); 32 | } catch (error) { 33 | logger.error(`[ProjectRepository] Failed to create project ${project.project_id}:`, error); 34 | // Re-throw the error to be handled by the service layer 35 | throw error; 36 | } 37 | } 38 | 39 | /** 40 | * Finds a project by its ID. 41 | * @param projectId - The ID of the project to find. 42 | * @returns The project data if found, otherwise undefined. 43 | */ 44 | public findById(projectId: string): ProjectData | undefined { 45 | const sql = `SELECT project_id, name, created_at FROM projects WHERE project_id = ?`; 46 | try { 47 | const stmt = this.db.prepare(sql); 48 | const project = stmt.get(projectId) as ProjectData | undefined; 49 | return project; 50 | } catch (error) { 51 | logger.error(`[ProjectRepository] Failed to find project ${projectId}:`, error); 52 | throw error; // Re-throw 53 | } 54 | } 55 | 56 | /** 57 | * Deletes a project by its ID. 58 | * Relies on ON DELETE CASCADE in the schema to remove associated tasks/dependencies. 59 | * @param projectId - The ID of the project to delete. 60 | * @returns The number of projects deleted (0 or 1). 61 | * @throws {Error} If the database operation fails. 62 | */ 63 | public deleteProject(projectId: string): number { 64 | const sql = `DELETE FROM projects WHERE project_id = ?`; 65 | try { 66 | const stmt = this.db.prepare(sql); 67 | const info = stmt.run(projectId); 68 | logger.info(`[ProjectRepository] Attempted to delete project ${projectId}. Rows affected: ${info.changes}`); 69 | // Cascade delete handles tasks/dependencies in the background via schema definition. 70 | return info.changes; 71 | } catch (error) { 72 | logger.error(`[ProjectRepository] Failed to delete project ${projectId}:`, error); 73 | throw error; // Re-throw 74 | } 75 | } 76 | 77 | // Add other methods as needed (e.g., update, list) 78 | } 79 | ``` -------------------------------------------------------------------------------- /src/tools/addTaskTool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Correct path for McpServer 2 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; // Correct path for Error types 3 | import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, AddTaskArgs } from "./addTaskParams.js"; 4 | import { TaskService } from "../services/TaskService.js"; // Assuming TaskService is exported via services/index.js 5 | import { logger } from '../utils/logger.js'; 6 | import { NotFoundError, ValidationError } from "../utils/errors.js"; // Import custom errors 7 | 8 | /** 9 | * Registers the addTask tool with the MCP server. 10 | * 11 | * @param server - The McpServer instance. 12 | * @param taskService - An instance of the TaskService. 13 | */ 14 | export const addTaskTool = (server: McpServer, taskService: TaskService): void => { 15 | 16 | // Define the asynchronous function that handles the actual tool logic 17 | const processRequest = async (args: AddTaskArgs) => { 18 | logger.info(`[${TOOL_NAME}] Received request with args:`, args); 19 | try { 20 | // Call the service method to add the task 21 | // The Zod schema handles basic type/format/length validation 22 | const newTask = await taskService.addTask({ 23 | project_id: args.project_id, 24 | description: args.description, 25 | dependencies: args.dependencies, // Pass optional fields 26 | priority: args.priority, 27 | status: args.status, 28 | }); 29 | 30 | // Format the successful response according to MCP standards 31 | // Return the full details of the created task as per spec FR-FS-011 32 | logger.info(`[${TOOL_NAME}] Task added successfully: ${newTask.task_id}`); 33 | return { 34 | content: [{ 35 | type: "text" as const, 36 | text: JSON.stringify(newTask) // Return the full task object 37 | }] 38 | }; 39 | } catch (error: unknown) { 40 | // Handle potential errors from the service layer 41 | logger.error(`[${TOOL_NAME}] Error processing request:`, error); 42 | 43 | if (error instanceof NotFoundError) { 44 | // Specific error if the project wasn't found - map to InvalidParams as project_id is invalid 45 | throw new McpError(ErrorCode.InvalidParams, error.message); 46 | } else if (error instanceof ValidationError) { 47 | // Specific error for validation issues within the service (e.g., dependency check if implemented) 48 | throw new McpError(ErrorCode.InvalidParams, error.message); 49 | } else { 50 | // Generic internal error for database issues or unexpected problems 51 | const message = error instanceof Error ? error.message : 'An unknown error occurred while adding the task.'; 52 | throw new McpError(ErrorCode.InternalError, message); 53 | } 54 | } 55 | }; 56 | 57 | // Register the tool with the server, passing the shape of the Zod object 58 | server.tool(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS.shape, processRequest); 59 | 60 | logger.info(`[${TOOL_NAME}] Tool registered successfully.`); 61 | }; 62 | ``` -------------------------------------------------------------------------------- /src/tools/updateTaskTool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 3 | // Import the base schema shape for registration and the refined schema for validation/types 4 | import { TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMS, UPDATE_TASK_BASE_SCHEMA, UpdateTaskArgs } from "./updateTaskParams.js"; 5 | import { TaskService, FullTaskData } from "../services/TaskService.js"; // Assuming TaskService is exported from index 6 | import { logger } from '../utils/logger.js'; 7 | import { NotFoundError, ValidationError } from "../utils/errors.js"; // Import custom errors 8 | 9 | /** 10 | * Registers the updateTask tool with the MCP server. 11 | * 12 | * @param server - The McpServer instance. 13 | * @param taskService - An instance of the TaskService. 14 | */ 15 | export const updateTaskTool = (server: McpServer, taskService: TaskService): void => { 16 | 17 | const processRequest = async (args: UpdateTaskArgs): Promise<{ content: { type: 'text', text: string }[] }> => { 18 | logger.info(`[${TOOL_NAME}] Received request with args:`, { ...args, dependencies: args.dependencies ? `[${args.dependencies.length} items]` : undefined }); // Avoid logging potentially large arrays 19 | try { 20 | // Call the service method to update the task 21 | // The service method now returns FullTaskData 22 | const updatedTask: FullTaskData = await taskService.updateTask({ 23 | project_id: args.project_id, 24 | task_id: args.task_id, 25 | description: args.description, 26 | priority: args.priority, 27 | dependencies: args.dependencies, 28 | }); 29 | 30 | // Format the successful response 31 | logger.info(`[${TOOL_NAME}] Successfully updated task ${args.task_id} in project ${args.project_id}`); 32 | return { 33 | content: [{ 34 | type: "text" as const, 35 | text: JSON.stringify(updatedTask) // Return the full updated task details 36 | }] 37 | }; 38 | } catch (error: unknown) { 39 | // Handle potential errors according to systemPatterns.md mapping 40 | logger.error(`[${TOOL_NAME}] Error processing request:`, error); 41 | 42 | if (error instanceof ValidationError) { 43 | // Validation error from service (e.g., no fields provided, invalid deps) 44 | throw new McpError(ErrorCode.InvalidParams, error.message); 45 | } else if (error instanceof NotFoundError) { 46 | // Project or task not found - Map to InvalidParams as per SDK limitations/convention 47 | throw new McpError(ErrorCode.InvalidParams, error.message); 48 | } else { 49 | // Generic internal error 50 | const message = error instanceof Error ? error.message : 'An unknown error occurred while updating the task.'; 51 | throw new McpError(ErrorCode.InternalError, message); 52 | } 53 | } 54 | }; 55 | 56 | // Register the tool with the server using the base schema's shape 57 | server.tool(TOOL_NAME, TOOL_DESCRIPTION, UPDATE_TASK_BASE_SCHEMA.shape, processRequest); 58 | 59 | logger.info(`[${TOOL_NAME}] Tool registered successfully.`); 60 | }; 61 | ``` -------------------------------------------------------------------------------- /src/tools/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { ConfigurationManager } from "../config/ConfigurationManager.js"; 3 | import { logger } from "../utils/index.js"; // Now using barrel file 4 | import { DatabaseManager } from "../db/DatabaseManager.js"; 5 | import { ProjectRepository } from "../repositories/ProjectRepository.js"; 6 | import { TaskRepository } from "../repositories/TaskRepository.js"; // Added TaskRepository import 7 | import { ProjectService, TaskService } from "../services/index.js"; // Using barrel file, added TaskService 8 | 9 | // Import tool registration functions 10 | // import { exampleTool } from "./exampleTool.js"; // Commenting out example 11 | import { createProjectTool } from "./createProjectTool.js"; 12 | import { addTaskTool } from "./addTaskTool.js"; 13 | import { listTasksTool } from "./listTasksTool.js"; 14 | import { showTaskTool } from "./showTaskTool.js"; 15 | import { setTaskStatusTool } from "./setTaskStatusTool.js"; 16 | import { expandTaskTool } from "./expandTaskTool.js"; 17 | import { getNextTaskTool } from "./getNextTaskTool.js"; 18 | import { exportProjectTool } from "./exportProjectTool.js"; 19 | import { importProjectTool } from "./importProjectTool.js"; 20 | import { updateTaskTool } from "./updateTaskTool.js"; // Import the new tool 21 | import { deleteTaskTool } from "./deleteTaskTool.js"; // Import deleteTask tool 22 | import { deleteProjectTool } from "./deleteProjectTool.js"; // Import deleteProject tool 23 | // import { yourTool } from "./yourTool.js"; // Add other new tool imports here 24 | 25 | /** 26 | * Register all defined tools with the MCP server instance. 27 | * This function centralizes tool registration logic. 28 | * It also instantiates necessary services and repositories. 29 | */ 30 | export function registerTools(server: McpServer): void { 31 | logger.info("Registering tools..."); 32 | const configManager = ConfigurationManager.getInstance(); 33 | 34 | // --- Instantiate Dependencies --- 35 | // Note: Consider dependency injection frameworks for larger applications 36 | try { 37 | const dbManager = DatabaseManager.getInstance(); 38 | const db = dbManager.getDb(); // Get the initialized DB connection 39 | 40 | // Instantiate Repositories 41 | const projectRepository = new ProjectRepository(db); 42 | const taskRepository = new TaskRepository(db); // Instantiate TaskRepository 43 | 44 | // Instantiate Services 45 | const projectService = new ProjectService(db, projectRepository, taskRepository); // Pass db and both repos 46 | const taskService = new TaskService(db, taskRepository, projectRepository); // Instantiate TaskService, passing db and repos 47 | 48 | // --- Register Tools --- 49 | // Register each tool, passing necessary services 50 | 51 | // exampleTool(server, configManager.getExampleServiceConfig()); // Example commented out 52 | 53 | createProjectTool(server, projectService); 54 | addTaskTool(server, taskService); 55 | listTasksTool(server, taskService); 56 | showTaskTool(server, taskService); 57 | setTaskStatusTool(server, taskService); 58 | expandTaskTool(server, taskService); 59 | getNextTaskTool(server, taskService); 60 | exportProjectTool(server, projectService); 61 | importProjectTool(server, projectService); // Register importProjectTool (uses ProjectService) 62 | updateTaskTool(server, taskService); // Register the new updateTask tool 63 | deleteTaskTool(server, taskService); // Register deleteTask tool 64 | deleteProjectTool(server, projectService); // Register deleteProject tool (uses ProjectService) 65 | // ... etc. 66 | 67 | logger.info("All tools registered successfully."); 68 | 69 | } catch (error) { 70 | logger.error("Failed to instantiate dependencies or register tools:", error); 71 | // Depending on the desired behavior, you might want to exit the process 72 | // process.exit(1); 73 | throw new Error("Failed to initialize server components during tool registration."); 74 | } 75 | } 76 | ``` -------------------------------------------------------------------------------- /src/db/DatabaseManager.ts: -------------------------------------------------------------------------------- ```typescript 1 | import Database, { Database as Db } from 'better-sqlite3'; 2 | import fs from 'node:fs'; 3 | import path from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; // Added for ES Module dirname 5 | import { ConfigurationManager } from '../config/ConfigurationManager.js'; 6 | import { logger } from '../utils/logger.js'; // Assuming logger exists 7 | 8 | export class DatabaseManager { 9 | private static instance: DatabaseManager; 10 | private db!: Db; // Added definite assignment assertion 11 | private dbPath: string; 12 | 13 | private constructor() { 14 | const configManager = ConfigurationManager.getInstance(); 15 | // TODO: Get path from configManager once implemented 16 | // For now, use a default relative path 17 | this.dbPath = configManager.getDatabasePath(); // Assuming this method exists 18 | logger.info(`[DatabaseManager] Using database path: ${this.dbPath}`); 19 | 20 | this.initializeDatabase(); 21 | } 22 | 23 | public static getInstance(): DatabaseManager { 24 | if (!DatabaseManager.instance) { 25 | DatabaseManager.instance = new DatabaseManager(); 26 | } 27 | return DatabaseManager.instance; 28 | } 29 | 30 | private initializeDatabase(): void { 31 | try { 32 | const dbDir = path.dirname(this.dbPath); 33 | if (!fs.existsSync(dbDir)) { 34 | logger.info(`[DatabaseManager] Creating database directory: ${dbDir}`); 35 | fs.mkdirSync(dbDir, { recursive: true }); 36 | } 37 | 38 | const dbExists = fs.existsSync(this.dbPath); 39 | logger.info(`[DatabaseManager] Database file ${this.dbPath} exists: ${dbExists}`); 40 | 41 | // Pass a wrapper function for verbose logging to match expected signature 42 | this.db = new Database(this.dbPath, { 43 | verbose: (message?: any, ...additionalArgs: any[]) => logger.debug({ sql: message, params: additionalArgs }, 'SQLite Query') 44 | }); 45 | 46 | // Always enable foreign keys and WAL mode upon connection 47 | this.db.pragma('foreign_keys = ON'); 48 | // Assert type for pragma result 49 | const journalMode = this.db.pragma('journal_mode = WAL') as [{ journal_mode: string }]; 50 | logger.info(`[DatabaseManager] Journal mode set to: ${journalMode[0]?.journal_mode ?? 'unknown'}`); 51 | 52 | 53 | // Check if initialization is needed (simple check: does 'projects' table exist?) 54 | const tableCheck = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='projects';").get(); 55 | 56 | if (!tableCheck) { 57 | logger.info('[DatabaseManager] Projects table not found. Initializing schema...'); 58 | // Revert to looking for schema.sql relative to the compiled JS file's directory (__dirname) 59 | const __filename = fileURLToPath(import.meta.url); 60 | const __dirname = path.dirname(__filename); // This will be dist/db when running compiled code 61 | const schemaPath = path.join(__dirname, 'schema.sql'); 62 | 63 | logger.info(`[DatabaseManager] Looking for schema file at: ${schemaPath}`); 64 | if (!fs.existsSync(schemaPath)) { 65 | logger.error(`[DatabaseManager] Schema file not found at ${schemaPath}. Ensure build process copied it correctly.`); 66 | throw new Error(`Schema file not found at ${schemaPath}. Build process might be incomplete.`); 67 | } 68 | const schemaSql = fs.readFileSync(schemaPath, 'utf8'); 69 | this.db.exec(schemaSql); 70 | logger.info('[DatabaseManager] Database schema initialized successfully.'); 71 | } else { 72 | logger.info('[DatabaseManager] Database schema already initialized.'); 73 | } 74 | } catch (error) { 75 | logger.error('[DatabaseManager] Failed to initialize database:', error); 76 | // Propagate the error to prevent the server from starting with a broken DB connection 77 | throw error; 78 | } 79 | } 80 | 81 | public getDb(): Db { 82 | if (!this.db) { 83 | // This should ideally not happen if constructor succeeded 84 | logger.error('[DatabaseManager] Database connection not available.'); 85 | throw new Error('Database connection not available.'); 86 | } 87 | return this.db; 88 | } 89 | 90 | // Optional: Add a close method for graceful shutdown 91 | public closeDb(): void { 92 | if (this.db) { 93 | this.db.close(); 94 | logger.info('[DatabaseManager] Database connection closed.'); 95 | } 96 | } 97 | } 98 | ``` -------------------------------------------------------------------------------- /src/config/ConfigurationManager.ts: -------------------------------------------------------------------------------- ```typescript 1 | // Import config types for services as they are added 2 | import { ExampleServiceConfig } from '../types/index.js'; 3 | 4 | // Define the structure for all configurations managed 5 | interface ManagedConfigs { 6 | exampleService: Required<ExampleServiceConfig>; 7 | // Add other service config types here: 8 | // yourService: Required<YourServiceConfig>; 9 | databasePath: string; // Added for database file location 10 | } 11 | 12 | /** 13 | * Centralized configuration management for all services. 14 | * Implements singleton pattern to ensure consistent configuration. 15 | */ 16 | export class ConfigurationManager { 17 | private static instance: ConfigurationManager | null = null; 18 | private static instanceLock = false; 19 | 20 | private config: ManagedConfigs; 21 | 22 | private constructor() { 23 | // Initialize with default configurations 24 | this.config = { 25 | exampleService: { 26 | // Define defaults for ExampleService 27 | greeting: "Hello", 28 | enableDetailedLogs: false, 29 | }, 30 | // Initialize other service configs with defaults: 31 | // yourService: { 32 | // someSetting: 'default value', 33 | // retryCount: 3, 34 | // }, 35 | // Default database path 36 | databasePath: './data/taskmanager.db', 37 | }; 38 | 39 | // Optional: Load overrides from environment variables or config files here 40 | this.loadEnvironmentOverrides(); 41 | } 42 | 43 | /** 44 | * Get the singleton instance of ConfigurationManager. 45 | * Basic lock to prevent race conditions during initial creation. 46 | */ 47 | public static getInstance(): ConfigurationManager { 48 | if (!ConfigurationManager.instance) { 49 | if (!ConfigurationManager.instanceLock) { 50 | ConfigurationManager.instanceLock = true; // Lock 51 | try { 52 | ConfigurationManager.instance = new ConfigurationManager(); 53 | } finally { 54 | ConfigurationManager.instanceLock = false; // Unlock 55 | } 56 | } else { 57 | // Basic busy wait if locked (consider a more robust async lock if high contention is expected) 58 | while (ConfigurationManager.instanceLock) { } 59 | // Re-check instance after wait 60 | if (!ConfigurationManager.instance) { 61 | // This path is less likely but handles edge cases if lock logic needs refinement 62 | return ConfigurationManager.getInstance(); 63 | } 64 | } 65 | } 66 | return ConfigurationManager.instance; 67 | } 68 | 69 | // --- Getters for specific configurations --- 70 | 71 | public getExampleServiceConfig(): Required<ExampleServiceConfig> { 72 | // Return a copy to prevent accidental modification of the internal state 73 | return { ...this.config.exampleService }; 74 | } 75 | 76 | // Add getters for other service configs: 77 | // public getYourServiceConfig(): Required<YourServiceConfig> { 78 | // return { ...this.config.yourService }; 79 | // } 80 | 81 | public getDatabasePath(): string { 82 | // Return a copy to prevent accidental modification (though less critical for a string) 83 | return this.config.databasePath; 84 | } 85 | 86 | // --- Updaters for specific configurations (if runtime updates are needed) --- 87 | 88 | public updateExampleServiceConfig(update: Partial<ExampleServiceConfig>): void { 89 | this.config.exampleService = { 90 | ...this.config.exampleService, 91 | ...update, 92 | }; 93 | // Optional: Notify relevant services about the config change 94 | } 95 | 96 | // Add updaters for other service configs: 97 | // public updateYourServiceConfig(update: Partial<YourServiceConfig>): void { 98 | // this.config.yourService = { 99 | // ...this.config.yourService, 100 | // ...update, 101 | // }; 102 | // } 103 | 104 | /** 105 | * Example method to load configuration overrides from environment variables. 106 | * Call this in the constructor. 107 | */ 108 | private loadEnvironmentOverrides(): void { 109 | // Example for ExampleService 110 | if (process.env.EXAMPLE_GREETING) { 111 | this.config.exampleService.greeting = process.env.EXAMPLE_GREETING; 112 | } 113 | if (process.env.EXAMPLE_ENABLE_LOGS) { 114 | this.config.exampleService.enableDetailedLogs = process.env.EXAMPLE_ENABLE_LOGS.toLowerCase() === 'true'; 115 | } 116 | 117 | // Override for Database Path 118 | if (process.env.DATABASE_PATH) { 119 | this.config.databasePath = process.env.DATABASE_PATH; 120 | } 121 | 122 | // Add logic for other services based on their environment variables 123 | // if (process.env.YOUR_SERVICE_RETRY_COUNT) { 124 | // const retryCount = parseInt(process.env.YOUR_SERVICE_RETRY_COUNT, 10); 125 | // if (!isNaN(retryCount)) { 126 | // this.config.yourService.retryCount = retryCount; 127 | // } 128 | // } 129 | } 130 | } 131 | ``` -------------------------------------------------------------------------------- /src/services/ProjectService.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { v4 as uuidv4 } from 'uuid'; 2 | import { Database as Db } from 'better-sqlite3'; // Import Db type 3 | import { ProjectRepository, ProjectData } from '../repositories/ProjectRepository.js'; 4 | import { TaskRepository, TaskData, DependencyData } from '../repositories/TaskRepository.js'; 5 | import { logger } from '../utils/logger.js'; 6 | import { NotFoundError, ValidationError, ConflictError } from '../utils/errors.js'; // Import errors 7 | 8 | // Define structure for the export/import JSON 9 | interface ExportTask extends TaskData { 10 | dependencies: string[]; // List of task IDs this task depends on 11 | subtasks: ExportTask[]; // Nested subtasks 12 | } 13 | 14 | interface ExportData { 15 | project_metadata: ProjectData; 16 | tasks: ExportTask[]; // Root tasks 17 | } 18 | 19 | 20 | export class ProjectService { 21 | private projectRepository: ProjectRepository; 22 | private taskRepository: TaskRepository; 23 | private db: Db; // Add db instance 24 | 25 | constructor( 26 | db: Db, // Inject Db instance 27 | projectRepository: ProjectRepository, 28 | taskRepository: TaskRepository 29 | ) { 30 | this.db = db; // Store db instance 31 | this.projectRepository = projectRepository; 32 | this.taskRepository = taskRepository; 33 | } 34 | 35 | /** 36 | * Creates a new project. 37 | */ 38 | public async createProject(projectName?: string): Promise<ProjectData> { 39 | const projectId = uuidv4(); 40 | const now = new Date().toISOString(); 41 | const finalProjectName = projectName?.trim() || `New Project ${now}`; 42 | const newProject: ProjectData = { 43 | project_id: projectId, 44 | name: finalProjectName, 45 | created_at: now, 46 | }; 47 | logger.info(`[ProjectService] Attempting to create project: ${projectId} with name "${finalProjectName}"`); 48 | try { 49 | this.projectRepository.create(newProject); 50 | logger.info(`[ProjectService] Successfully created project: ${projectId}`); 51 | return newProject; 52 | } catch (error) { 53 | logger.error(`[ProjectService] Error creating project ${projectId}:`, error); 54 | throw error; 55 | } 56 | } 57 | 58 | /** 59 | * Retrieves a project by its ID. 60 | */ 61 | public async getProjectById(projectId: string): Promise<ProjectData | undefined> { 62 | logger.info(`[ProjectService] Attempting to find project: ${projectId}`); 63 | try { 64 | const project = this.projectRepository.findById(projectId); 65 | if (project) { 66 | logger.info(`[ProjectService] Found project: ${projectId}`); 67 | } else { 68 | logger.warn(`[ProjectService] Project not found: ${projectId}`); 69 | } 70 | return project; 71 | } catch (error) { 72 | logger.error(`[ProjectService] Error finding project ${projectId}:`, error); 73 | throw error; 74 | } 75 | } 76 | 77 | /** 78 | * Exports all data for a given project as a JSON string. 79 | */ 80 | public async exportProject(projectId: string): Promise<string> { 81 | logger.info(`[ProjectService] Attempting to export project: ${projectId}`); 82 | const projectMetadata = this.projectRepository.findById(projectId); 83 | if (!projectMetadata) { 84 | logger.warn(`[ProjectService] Project not found for export: ${projectId}`); 85 | throw new NotFoundError(`Project with ID ${projectId} not found.`); 86 | } 87 | 88 | try { 89 | const allTasks = this.taskRepository.findAllTasksForProject(projectId); 90 | const allDependencies = this.taskRepository.findAllDependenciesForProject(projectId); 91 | 92 | const taskMap: Map<string, ExportTask> = new Map(); 93 | const rootTasks: ExportTask[] = []; 94 | const dependencyMap: Map<string, string[]> = new Map(); 95 | 96 | for (const dep of allDependencies) { 97 | if (!dependencyMap.has(dep.task_id)) { 98 | dependencyMap.set(dep.task_id, []); 99 | } 100 | dependencyMap.get(dep.task_id)!.push(dep.depends_on_task_id); 101 | } 102 | 103 | for (const task of allTasks) { 104 | taskMap.set(task.task_id, { 105 | ...task, 106 | dependencies: dependencyMap.get(task.task_id) || [], 107 | subtasks: [], 108 | }); 109 | } 110 | 111 | for (const task of allTasks) { 112 | const exportTask = taskMap.get(task.task_id)!; 113 | if (task.parent_task_id && taskMap.has(task.parent_task_id)) { 114 | const parent = taskMap.get(task.parent_task_id)!; 115 | if (!parent.subtasks) parent.subtasks = []; 116 | parent.subtasks.push(exportTask); 117 | } else if (!task.parent_task_id) { 118 | rootTasks.push(exportTask); 119 | } 120 | } 121 | 122 | const exportData: ExportData = { 123 | project_metadata: projectMetadata, 124 | tasks: rootTasks, 125 | }; 126 | 127 | const jsonString = JSON.stringify(exportData, null, 2); 128 | logger.info(`[ProjectService] Successfully prepared export data for project ${projectId}`); 129 | return jsonString; 130 | 131 | } catch (error) { 132 | logger.error(`[ProjectService] Error exporting project ${projectId}:`, error); 133 | throw error; 134 | } 135 | } 136 | 137 | /** 138 | * Imports project data from a JSON string, creating a new project. 139 | */ 140 | public async importProject(projectDataString: string, newProjectName?: string): Promise<{ project_id: string }> { 141 | logger.info(`[ProjectService] Attempting to import project...`); 142 | let importData: ExportData; 143 | try { 144 | if (projectDataString.length > 10 * 1024 * 1024) { // Example 10MB limit 145 | throw new ValidationError('Input data exceeds size limit (e.g., 10MB).'); 146 | } 147 | importData = JSON.parse(projectDataString); 148 | // TODO: Implement rigorous schema validation (Zod?) 149 | if (!importData || !importData.project_metadata || !Array.isArray(importData.tasks)) { 150 | throw new ValidationError('Invalid import data structure: Missing required fields.'); 151 | } 152 | logger.debug(`[ProjectService] Successfully parsed import data.`); 153 | } catch (error) { 154 | logger.error('[ProjectService] Failed to parse or validate import JSON:', error); 155 | if (error instanceof SyntaxError) { 156 | throw new ValidationError(`Invalid JSON format: ${error.message}`); 157 | } 158 | throw new ValidationError(`Invalid import data: ${error instanceof Error ? error.message : 'Unknown validation error'}`); 159 | } 160 | 161 | const importTransaction = this.db.transaction(() => { 162 | const newProjectId = uuidv4(); 163 | const now = new Date().toISOString(); 164 | const finalProjectName = newProjectName?.trim() || `${importData.project_metadata.name} (Imported ${now})`; 165 | const newProject: ProjectData = { 166 | project_id: newProjectId, 167 | name: finalProjectName.substring(0, 255), 168 | created_at: now, 169 | }; 170 | this.projectRepository.create(newProject); 171 | logger.info(`[ProjectService] Created new project ${newProjectId} for import.`); 172 | 173 | const idMap = new Map<string, string>(); 174 | const processTask = (task: ExportTask, parentDbId: string | null) => { 175 | const newTaskId = uuidv4(); 176 | idMap.set(task.task_id, newTaskId); 177 | const newTaskData: TaskData = { 178 | task_id: newTaskId, 179 | project_id: newProjectId, 180 | parent_task_id: parentDbId, 181 | description: task.description, 182 | status: task.status, 183 | priority: task.priority, 184 | created_at: task.created_at, 185 | updated_at: task.updated_at, 186 | }; 187 | this.taskRepository.create(newTaskData, []); // Create task first 188 | if (task.subtasks && task.subtasks.length > 0) { 189 | task.subtasks.forEach(subtask => processTask(subtask, newTaskId)); 190 | } 191 | }; 192 | importData.tasks.forEach(rootTask => processTask(rootTask, null)); 193 | logger.info(`[ProjectService] Processed ${idMap.size} tasks for import.`); 194 | 195 | const insertDependencyStmt = this.db.prepare(` 196 | INSERT INTO task_dependencies (task_id, depends_on_task_id) 197 | VALUES (?, ?) ON CONFLICT DO NOTHING 198 | `); 199 | let depCount = 0; 200 | const processDeps = (task: ExportTask) => { 201 | const newTaskId = idMap.get(task.task_id); 202 | if (newTaskId && task.dependencies && task.dependencies.length > 0) { 203 | for (const oldDepId of task.dependencies) { 204 | const newDepId = idMap.get(oldDepId); 205 | if (newDepId) { 206 | insertDependencyStmt.run(newTaskId, newDepId); 207 | depCount++; 208 | } else { 209 | logger.warn(`[ProjectService] Dependency task ID ${oldDepId} not found in import map for task ${task.task_id}. Skipping dependency.`); 210 | } 211 | } 212 | } 213 | if (task.subtasks && task.subtasks.length > 0) { 214 | task.subtasks.forEach(processDeps); 215 | } 216 | }; 217 | importData.tasks.forEach(processDeps); 218 | logger.info(`[ProjectService] Processed ${depCount} dependencies for import.`); 219 | 220 | return { project_id: newProjectId }; 221 | }); 222 | 223 | try { 224 | const result = importTransaction(); 225 | logger.info(`[ProjectService] Successfully imported project. New project ID: ${result.project_id}`); 226 | return result; 227 | } catch (error) { 228 | logger.error(`[ProjectService] Error during import transaction:`, error); 229 | if (error instanceof NotFoundError || error instanceof ValidationError || error instanceof ConflictError) { 230 | throw error; 231 | } 232 | throw new Error(`Failed to import project: ${error instanceof Error ? error.message : 'Unknown database error'}`); 233 | } 234 | } 235 | 236 | /** 237 | * Deletes a project and all its associated data (tasks, dependencies). 238 | * @param projectId - The ID of the project to delete. 239 | * @returns A boolean indicating success (true if deleted, false if not found initially). 240 | * @throws {NotFoundError} If the project is not found. 241 | * @throws {Error} If the database operation fails. 242 | */ 243 | public async deleteProject(projectId: string): Promise<boolean> { 244 | logger.info(`[ProjectService] Attempting to delete project: ${projectId}`); 245 | 246 | // 1. Validate Project Existence *before* attempting delete 247 | const projectExists = this.projectRepository.findById(projectId); 248 | if (!projectExists) { 249 | logger.warn(`[ProjectService] Project not found for deletion: ${projectId}`); 250 | throw new NotFoundError(`Project with ID ${projectId} not found.`); 251 | } 252 | 253 | // 2. Call Repository delete method 254 | try { 255 | // The repository method handles the actual DELETE operation on the projects table. 256 | // Cascade delete defined in the schema handles tasks and dependencies. 257 | const deletedCount = this.projectRepository.deleteProject(projectId); 258 | 259 | if (deletedCount !== 1) { 260 | // This shouldn't happen if findById succeeded, but log a warning if it does. 261 | logger.warn(`[ProjectService] Expected to delete 1 project, but repository reported ${deletedCount} deletions for project ${projectId}.`); 262 | // Still return true as the project is gone, but log indicates potential issue. 263 | } 264 | 265 | logger.info(`[ProjectService] Successfully deleted project ${projectId} and associated data.`); 266 | return true; // Indicate success 267 | 268 | } catch (error) { 269 | logger.error(`[ProjectService] Error deleting project ${projectId}:`, error); 270 | throw error; // Re-throw database or other errors 271 | } 272 | } 273 | } 274 | ``` -------------------------------------------------------------------------------- /src/repositories/TaskRepository.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Database as Db, Statement } from 'better-sqlite3'; 2 | import { logger } from '../utils/logger.js'; 3 | 4 | // Define the structure for task data in the database 5 | // Aligning with schema.sql and feature specs 6 | export interface TaskData { 7 | task_id: string; // UUID 8 | project_id: string; // UUID 9 | parent_task_id?: string | null; // UUID or null 10 | description: string; 11 | status: 'todo' | 'in-progress' | 'review' | 'done'; 12 | priority: 'high' | 'medium' | 'low'; 13 | created_at: string; // ISO8601 14 | updated_at: string; // ISO8601 15 | } 16 | 17 | // Define the structure for dependency data 18 | export interface DependencyData { 19 | task_id: string; 20 | depends_on_task_id: string; 21 | } 22 | 23 | export class TaskRepository { 24 | private db: Db; 25 | private insertTaskStmt: Statement | null = null; 26 | private insertDependencyStmt: Statement | null = null; 27 | 28 | constructor(db: Db) { 29 | this.db = db; 30 | // Prepare statements for efficiency 31 | this.prepareStatements(); 32 | } 33 | 34 | private prepareStatements(): void { 35 | try { 36 | this.insertTaskStmt = this.db.prepare(` 37 | INSERT INTO tasks ( 38 | task_id, project_id, parent_task_id, description, 39 | status, priority, created_at, updated_at 40 | ) VALUES ( 41 | @task_id, @project_id, @parent_task_id, @description, 42 | @status, @priority, @created_at, @updated_at 43 | ) 44 | `); 45 | 46 | this.insertDependencyStmt = this.db.prepare(` 47 | INSERT INTO task_dependencies (task_id, depends_on_task_id) 48 | VALUES (@task_id, @depends_on_task_id) 49 | ON CONFLICT(task_id, depends_on_task_id) DO NOTHING -- Ignore if dependency already exists 50 | `); 51 | } catch (error) { 52 | logger.error('[TaskRepository] Failed to prepare statements:', error); 53 | // Handle error appropriately, maybe re-throw or set a flag 54 | throw error; 55 | } 56 | } 57 | 58 | /** 59 | * Creates a new task and optionally its dependencies in the database. 60 | * Uses a transaction to ensure atomicity. 61 | * @param task - The core task data to insert. 62 | * @param dependencies - An array of dependency task IDs for this task. 63 | * @throws {Error} If the database operation fails. 64 | */ 65 | public create(task: TaskData, dependencies: string[] = []): void { 66 | if (!this.insertTaskStmt || !this.insertDependencyStmt) { 67 | logger.error('[TaskRepository] Statements not prepared. Cannot create task.'); 68 | throw new Error('TaskRepository statements not initialized.'); 69 | } 70 | 71 | // Use a transaction for atomicity 72 | const transaction = this.db.transaction((taskData: TaskData, deps: string[]) => { 73 | // Insert the main task 74 | const taskInfo = this.insertTaskStmt!.run(taskData); 75 | if (taskInfo.changes !== 1) { 76 | throw new Error(`Failed to insert task ${taskData.task_id}. Changes: ${taskInfo.changes}`); 77 | } 78 | 79 | // Insert dependencies 80 | for (const depId of deps) { 81 | const depData: DependencyData = { 82 | task_id: taskData.task_id, 83 | depends_on_task_id: depId, 84 | }; 85 | const depInfo = this.insertDependencyStmt!.run(depData); 86 | // We don't strictly need to check changes here due to ON CONFLICT DO NOTHING 87 | } 88 | return taskInfo.changes; // Indicate success 89 | }); 90 | 91 | try { 92 | transaction(task, dependencies); 93 | logger.info(`[TaskRepository] Created task ${task.task_id} with ${dependencies.length} dependencies.`); 94 | } catch (error) { 95 | logger.error(`[TaskRepository] Failed to create task ${task.task_id} transaction:`, error); 96 | throw error; // Re-throw to be handled by the service layer 97 | } 98 | } 99 | 100 | /** 101 | * Finds tasks by project ID, optionally filtering by status. 102 | * Does not handle subtask nesting directly in this query for V1 simplicity. 103 | * @param projectId - The ID of the project. 104 | * @param statusFilter - Optional status to filter by. 105 | * @returns An array of matching task data. 106 | */ 107 | public findByProjectId(projectId: string, statusFilter?: TaskData['status']): TaskData[] { 108 | let sql = ` 109 | SELECT task_id, project_id, parent_task_id, description, status, priority, created_at, updated_at 110 | FROM tasks 111 | WHERE project_id = ? 112 | `; 113 | const params: (string | null)[] = [projectId]; 114 | 115 | if (statusFilter) { 116 | sql += ` AND status = ?`; 117 | params.push(statusFilter); 118 | } 119 | 120 | // For simplicity in V1, we only fetch top-level tasks or all tasks depending on include_subtasks strategy in service 121 | // If we only wanted top-level: sql += ` AND parent_task_id IS NULL`; 122 | // If fetching all and structuring in service, this query is fine. 123 | 124 | sql += ` ORDER BY created_at ASC`; // Default sort order 125 | 126 | try { 127 | const stmt = this.db.prepare(sql); 128 | const tasks = stmt.all(...params) as TaskData[]; 129 | logger.debug(`[TaskRepository] Found ${tasks.length} tasks for project ${projectId} with status filter '${statusFilter || 'none'}'`); 130 | return tasks; 131 | } catch (error) { 132 | logger.error(`[TaskRepository] Failed to find tasks for project ${projectId}:`, error); 133 | throw error; // Re-throw 134 | } 135 | } 136 | 137 | /** 138 | * Finds a single task by its ID and project ID. 139 | * @param projectId - The project ID. 140 | * @param taskId - The task ID. 141 | * @returns The task data if found, otherwise undefined. 142 | */ 143 | public findById(projectId: string, taskId: string): TaskData | undefined { 144 | const sql = ` 145 | SELECT task_id, project_id, parent_task_id, description, status, priority, created_at, updated_at 146 | FROM tasks 147 | WHERE project_id = ? AND task_id = ? 148 | `; 149 | try { 150 | const stmt = this.db.prepare(sql); 151 | const task = stmt.get(projectId, taskId) as TaskData | undefined; 152 | logger.debug(`[TaskRepository] Found task ${taskId} in project ${projectId}: ${!!task}`); 153 | return task; 154 | } catch (error) { 155 | logger.error(`[TaskRepository] Failed to find task ${taskId} in project ${projectId}:`, error); 156 | throw error; 157 | } 158 | } 159 | 160 | /** 161 | * Finds the direct subtasks for a given parent task ID. 162 | * @param parentTaskId - The ID of the parent task. 163 | * @returns An array of direct subtask data. 164 | */ 165 | public findSubtasks(parentTaskId: string): TaskData[] { 166 | const sql = ` 167 | SELECT task_id, project_id, parent_task_id, description, status, priority, created_at, updated_at 168 | FROM tasks 169 | WHERE parent_task_id = ? 170 | ORDER BY created_at ASC 171 | `; 172 | try { 173 | const stmt = this.db.prepare(sql); 174 | const subtasks = stmt.all(parentTaskId) as TaskData[]; 175 | logger.debug(`[TaskRepository] Found ${subtasks.length} subtasks for parent ${parentTaskId}`); 176 | return subtasks; 177 | } catch (error) { 178 | logger.error(`[TaskRepository] Failed to find subtasks for parent ${parentTaskId}:`, error); 179 | throw error; 180 | } 181 | } 182 | 183 | /** 184 | * Finds the IDs of tasks that the given task depends on. 185 | * @param taskId - The ID of the task whose dependencies are needed. 186 | * @returns An array of task IDs that this task depends on. 187 | */ 188 | public findDependencies(taskId: string): string[] { 189 | const sql = `SELECT depends_on_task_id FROM task_dependencies WHERE task_id = ?`; 190 | try { 191 | const stmt = this.db.prepare(sql); 192 | // Ensure result is always an array of strings 193 | const results = stmt.all(taskId) as { depends_on_task_id: string }[]; 194 | const dependencyIds = results.map(row => row.depends_on_task_id); 195 | logger.debug(`[TaskRepository] Found ${dependencyIds.length} dependencies for task ${taskId}`); 196 | return dependencyIds; 197 | } catch (error) { 198 | logger.error(`[TaskRepository] Failed to find dependencies for task ${taskId}:`, error); 199 | throw error; 200 | } 201 | } 202 | 203 | 204 | /** 205 | * Updates the status and updated_at timestamp for a list of tasks within a project. 206 | * Assumes task existence has already been verified. 207 | * @param projectId - The project ID. 208 | * @param taskIds - An array of task IDs to update. 209 | * @param status - The new status to set. 210 | * @param timestamp - The ISO8601 timestamp for updated_at. 211 | * @returns The number of rows affected by the update. 212 | * @throws {Error} If the database operation fails. 213 | */ 214 | public updateStatus(projectId: string, taskIds: string[], status: TaskData['status'], timestamp: string): number { 215 | if (taskIds.length === 0) { 216 | return 0; 217 | } 218 | 219 | // Create placeholders for the IN clause 220 | const placeholders = taskIds.map(() => '?').join(','); 221 | const sql = ` 222 | UPDATE tasks 223 | SET status = ?, updated_at = ? 224 | WHERE project_id = ? AND task_id IN (${placeholders}) 225 | `; 226 | const params = [status, timestamp, projectId, ...taskIds]; 227 | 228 | try { 229 | const stmt = this.db.prepare(sql); 230 | const info = stmt.run(...params); 231 | logger.info(`[TaskRepository] Updated status for ${info.changes} tasks in project ${projectId} to ${status}.`); 232 | return info.changes; 233 | } catch (error) { 234 | logger.error(`[TaskRepository] Failed to update status for tasks in project ${projectId}:`, error); 235 | throw error; 236 | } 237 | } 238 | 239 | /** 240 | * Checks if all provided task IDs exist within the specified project. 241 | * @param projectId - The project ID. 242 | * @param taskIds - An array of task IDs to check. 243 | * @returns An object indicating if all exist and a list of missing IDs if not. 244 | * @throws {Error} If the database operation fails. 245 | */ 246 | public checkTasksExist(projectId: string, taskIds: string[]): { allExist: boolean; missingIds: string[] } { 247 | if (taskIds.length === 0) { 248 | return { allExist: true, missingIds: [] }; 249 | } 250 | 251 | const placeholders = taskIds.map(() => '?').join(','); 252 | const sql = ` 253 | SELECT task_id FROM tasks 254 | WHERE project_id = ? AND task_id IN (${placeholders}) 255 | `; 256 | const params = [projectId, ...taskIds]; 257 | 258 | try { 259 | const stmt = this.db.prepare(sql); 260 | const foundTasks = stmt.all(...params) as { task_id: string }[]; 261 | const foundIds = new Set(foundTasks.map(t => t.task_id)); 262 | 263 | const missingIds = taskIds.filter(id => !foundIds.has(id)); 264 | const allExist = missingIds.length === 0; 265 | 266 | if (!allExist) { 267 | logger.warn(`[TaskRepository] Missing tasks in project ${projectId}:`, missingIds); 268 | } 269 | return { allExist, missingIds }; 270 | 271 | } catch (error) { 272 | logger.error(`[TaskRepository] Failed to check task existence in project ${projectId}:`, error); 273 | throw error; 274 | } 275 | } 276 | 277 | /** 278 | * Deletes all direct subtasks of a given parent task. 279 | * @param parentTaskId - The ID of the parent task whose subtasks should be deleted. 280 | * @returns The number of subtasks deleted. 281 | * @throws {Error} If the database operation fails. 282 | */ 283 | public deleteSubtasks(parentTaskId: string): number { 284 | const sql = `DELETE FROM tasks WHERE parent_task_id = ?`; 285 | try { 286 | const stmt = this.db.prepare(sql); 287 | const info = stmt.run(parentTaskId); 288 | logger.info(`[TaskRepository] Deleted ${info.changes} subtasks for parent ${parentTaskId}.`); 289 | return info.changes; 290 | } catch (error) { 291 | logger.error(`[TaskRepository] Failed to delete subtasks for parent ${parentTaskId}:`, error); 292 | throw error; 293 | } 294 | } 295 | 296 | /** 297 | * Finds tasks that are ready to be worked on (status 'todo' and all dependencies 'done'). 298 | * Orders them by priority ('high', 'medium', 'low') then creation date. 299 | * @param projectId - The project ID. 300 | * @returns An array of ready task data, ordered by priority and creation date. 301 | */ 302 | public findReadyTasks(projectId: string): TaskData[] { 303 | // This query finds tasks in the project with status 'todo' 304 | // AND for which no dependency exists OR all existing dependencies have status 'done'. 305 | const sql = ` 306 | SELECT t.task_id, t.project_id, t.parent_task_id, t.description, t.status, t.priority, t.created_at, t.updated_at 307 | FROM tasks t 308 | WHERE t.project_id = ? AND t.status = 'todo' 309 | AND NOT EXISTS ( 310 | SELECT 1 311 | FROM task_dependencies td 312 | JOIN tasks dep_task ON td.depends_on_task_id = dep_task.task_id 313 | WHERE td.task_id = t.task_id AND dep_task.status != 'done' 314 | ) 315 | ORDER BY 316 | CASE t.priority 317 | WHEN 'high' THEN 1 318 | WHEN 'medium' THEN 2 319 | WHEN 'low' THEN 3 320 | ELSE 4 -- Should not happen based on CHECK constraint 321 | END ASC, 322 | t.created_at ASC 323 | `; 324 | try { 325 | const stmt = this.db.prepare(sql); 326 | const tasks = stmt.all(projectId) as TaskData[]; 327 | logger.debug(`[TaskRepository] Found ${tasks.length} ready tasks for project ${projectId}`); 328 | return tasks; 329 | } catch (error) { 330 | logger.error(`[TaskRepository] Failed to find ready tasks for project ${projectId}:`, error); 331 | throw error; 332 | } 333 | } 334 | 335 | /** 336 | * Finds ALL tasks for a given project ID, ordered by creation date. 337 | * @param projectId - The project ID. 338 | * @returns An array of all task data for the project. 339 | */ 340 | public findAllTasksForProject(projectId: string): TaskData[] { 341 | const sql = ` 342 | SELECT task_id, project_id, parent_task_id, description, status, priority, created_at, updated_at 343 | FROM tasks 344 | WHERE project_id = ? 345 | ORDER BY created_at ASC 346 | `; 347 | try { 348 | const stmt = this.db.prepare(sql); 349 | const tasks = stmt.all(projectId) as TaskData[]; 350 | logger.debug(`[TaskRepository] Found all ${tasks.length} tasks for project ${projectId}`); 351 | return tasks; 352 | } catch (error) { 353 | logger.error(`[TaskRepository] Failed to find all tasks for project ${projectId}:`, error); 354 | throw error; 355 | } 356 | } 357 | 358 | /** 359 | * Finds ALL dependencies for tasks within a given project ID. 360 | * @param projectId - The project ID. 361 | * @returns An array of all dependency relationships for the project. 362 | */ 363 | public findAllDependenciesForProject(projectId: string): DependencyData[] { 364 | // Select dependencies where the *dependent* task belongs to the project 365 | const sql = ` 366 | SELECT td.task_id, td.depends_on_task_id 367 | FROM task_dependencies td 368 | JOIN tasks t ON td.task_id = t.task_id 369 | WHERE t.project_id = ? 370 | `; 371 | try { 372 | const stmt = this.db.prepare(sql); 373 | const dependencies = stmt.all(projectId) as DependencyData[]; 374 | logger.debug(`[TaskRepository] Found ${dependencies.length} dependencies for project ${projectId}`); 375 | return dependencies; 376 | } catch (error) { 377 | logger.error(`[TaskRepository] Failed to find all dependencies for project ${projectId}:`, error); 378 | throw error; 379 | } 380 | } 381 | 382 | 383 | // --- Add other methods later --- 384 | /** 385 | * Updates a task's description, priority, and/or dependencies. 386 | * Handles dependency replacement atomically within a transaction. 387 | * @param projectId - The project ID. 388 | * @param taskId - The task ID to update. 389 | * @param updatePayload - Object containing optional fields to update. 390 | * @param timestamp - The ISO8601 timestamp for updated_at. 391 | * @returns The updated task data. 392 | * @throws {Error} If the task doesn't exist or the database operation fails. 393 | */ 394 | public updateTask( 395 | projectId: string, 396 | taskId: string, 397 | updatePayload: { description?: string; priority?: TaskData['priority']; dependencies?: string[] }, 398 | timestamp: string 399 | ): TaskData { 400 | 401 | const transaction = this.db.transaction(() => { 402 | const setClauses: string[] = []; 403 | const params: (string | null)[] = []; 404 | 405 | if (updatePayload.description !== undefined) { 406 | setClauses.push('description = ?'); 407 | params.push(updatePayload.description); 408 | } 409 | if (updatePayload.priority !== undefined) { 410 | setClauses.push('priority = ?'); 411 | params.push(updatePayload.priority); 412 | } 413 | 414 | // Always update the timestamp 415 | setClauses.push('updated_at = ?'); 416 | params.push(timestamp); 417 | 418 | // If nothing else to update, we still update the timestamp 419 | if (setClauses.length === 1 && updatePayload.dependencies === undefined) { 420 | logger.warn(`[TaskRepository] updateTask called for ${taskId} with no fields to update other than timestamp.`); 421 | // Or potentially throw an error if this shouldn't happen based on service validation 422 | } 423 | 424 | // Update the main task table if there are fields to update 425 | let changes = 0; 426 | if (setClauses.length > 0) { 427 | const updateSql = ` 428 | UPDATE tasks 429 | SET ${setClauses.join(', ')} 430 | WHERE project_id = ? AND task_id = ? 431 | `; 432 | params.push(projectId, taskId); 433 | 434 | const updateStmt = this.db.prepare(updateSql); 435 | const info = updateStmt.run(...params); 436 | changes = info.changes; 437 | 438 | if (changes !== 1) { 439 | // Check if the task actually exists before throwing generic error 440 | const exists = this.findById(projectId, taskId); 441 | if (!exists) { 442 | throw new Error(`Task ${taskId} not found in project ${projectId}.`); // Will be caught and mapped later 443 | } else { 444 | throw new Error(`Failed to update task ${taskId}. Expected 1 change, got ${changes}.`); 445 | } 446 | } 447 | logger.debug(`[TaskRepository] Updated task ${taskId} fields.`); 448 | } 449 | 450 | 451 | // Handle dependencies if provided (replaces existing) 452 | if (updatePayload.dependencies !== undefined) { 453 | if (!this.insertDependencyStmt) { 454 | throw new Error('TaskRepository insertDependencyStmt not initialized.'); 455 | } 456 | // 1. Delete existing dependencies for this task 457 | const deleteDepsStmt = this.db.prepare(`DELETE FROM task_dependencies WHERE task_id = ?`); 458 | const deleteInfo = deleteDepsStmt.run(taskId); 459 | logger.debug(`[TaskRepository] Deleted ${deleteInfo.changes} existing dependencies for task ${taskId}.`); 460 | 461 | // 2. Insert new dependencies 462 | const newDeps = updatePayload.dependencies; 463 | for (const depId of newDeps) { 464 | const depData: DependencyData = { 465 | task_id: taskId, 466 | depends_on_task_id: depId, 467 | }; 468 | // ON CONFLICT DO NOTHING handles duplicates or self-references if schema allows 469 | this.insertDependencyStmt.run(depData); 470 | } 471 | logger.debug(`[TaskRepository] Inserted ${newDeps.length} new dependencies for task ${taskId}.`); 472 | } 473 | 474 | // Fetch and return the updated task data 475 | const updatedTask = this.findById(projectId, taskId); 476 | if (!updatedTask) { 477 | // Should not happen if update succeeded, but safety check 478 | throw new Error(`Failed to retrieve updated task ${taskId} after update.`); 479 | } 480 | return updatedTask; 481 | }); 482 | 483 | try { 484 | const result = transaction(); 485 | logger.info(`[TaskRepository] Successfully updated task ${taskId}.`); 486 | return result; 487 | } catch (error) { 488 | logger.error(`[TaskRepository] Failed transaction for updating task ${taskId}:`, error); 489 | throw error; // Re-throw to be handled by the service layer 490 | } 491 | } 492 | 493 | 494 | /** 495 | * Deletes multiple tasks by their IDs within a specific project. 496 | * Relies on ON DELETE CASCADE for subtasks and dependencies. 497 | * @param projectId - The project ID. 498 | * @param taskIds - An array of task IDs to delete. 499 | * @returns The number of tasks deleted. 500 | * @throws {Error} If the database operation fails. 501 | */ 502 | public deleteTasks(projectId: string, taskIds: string[]): number { 503 | if (taskIds.length === 0) { 504 | return 0; 505 | } 506 | 507 | // Create placeholders for the IN clause 508 | const placeholders = taskIds.map(() => '?').join(','); 509 | const sql = ` 510 | DELETE FROM tasks 511 | WHERE project_id = ? AND task_id IN (${placeholders}) 512 | `; 513 | const params = [projectId, ...taskIds]; 514 | 515 | try { 516 | const stmt = this.db.prepare(sql); 517 | const info = stmt.run(...params); 518 | logger.info(`[TaskRepository] Deleted ${info.changes} tasks from project ${projectId}.`); 519 | // Note: Cascade deletes for subtasks/dependencies happen automatically via schema. 520 | return info.changes; 521 | } catch (error) { 522 | logger.error(`[TaskRepository] Failed to delete tasks from project ${projectId}:`, error); 523 | throw error; 524 | } 525 | } 526 | 527 | 528 | // --- Add other methods later --- 529 | // deleteById(taskId: string): void; 530 | } 531 | ``` -------------------------------------------------------------------------------- /src/services/TaskService.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { v4 as uuidv4 } from 'uuid'; 2 | import { TaskRepository, TaskData } from '../repositories/TaskRepository.js'; 3 | import { ProjectRepository } from '../repositories/ProjectRepository.js'; // Needed to check project existence 4 | import { logger } from '../utils/logger.js'; 5 | import { NotFoundError, ValidationError } from '../utils/errors.js'; // Using custom errors 6 | 7 | // Define the input structure for adding a task, based on feature spec 8 | export interface AddTaskInput { 9 | project_id: string; 10 | description: string; 11 | dependencies?: string[]; 12 | priority?: 'high' | 'medium' | 'low'; 13 | status?: 'todo' | 'in-progress' | 'review' | 'done'; 14 | } 15 | 16 | // Options for listing tasks 17 | export interface ListTasksOptions { 18 | project_id: string; 19 | status?: TaskData['status']; 20 | include_subtasks?: boolean; 21 | } 22 | 23 | // Type for task data potentially including nested subtasks 24 | export interface StructuredTaskData extends TaskData { 25 | subtasks?: StructuredTaskData[]; 26 | } 27 | 28 | // Type for full task details including dependencies and subtasks 29 | export interface FullTaskData extends TaskData { 30 | dependencies: string[]; 31 | subtasks: TaskData[]; // For V1 showTask, just return direct subtasks without their own nesting/deps 32 | } 33 | 34 | // Input for expanding a task 35 | export interface ExpandTaskInput { 36 | project_id: string; 37 | task_id: string; // Parent task ID 38 | subtask_descriptions: string[]; 39 | force?: boolean; 40 | } 41 | 42 | 43 | import { Database as Db } from 'better-sqlite3'; // Import Db type 44 | import { ConflictError } from '../utils/errors.js'; // Import ConflictError 45 | 46 | export class TaskService { 47 | private taskRepository: TaskRepository; 48 | private projectRepository: ProjectRepository; 49 | private db: Db; // Add db instance 50 | 51 | constructor( 52 | db: Db, // Inject Db instance 53 | taskRepository: TaskRepository, 54 | projectRepository: ProjectRepository 55 | ) { 56 | this.db = db; // Store db instance 57 | this.taskRepository = taskRepository; 58 | this.projectRepository = projectRepository; 59 | } 60 | 61 | /** 62 | * Adds a new task to a specified project. 63 | */ 64 | public async addTask(input: AddTaskInput): Promise<TaskData> { 65 | logger.info(`[TaskService] Attempting to add task to project: ${input.project_id}`); 66 | const projectExists = this.projectRepository.findById(input.project_id); 67 | if (!projectExists) { 68 | logger.warn(`[TaskService] Project not found: ${input.project_id}`); 69 | throw new NotFoundError(`Project with ID ${input.project_id} not found.`); 70 | } 71 | 72 | const taskId = uuidv4(); 73 | const now = new Date().toISOString(); 74 | const newTaskData: TaskData = { 75 | task_id: taskId, 76 | project_id: input.project_id, 77 | parent_task_id: null, 78 | description: input.description, 79 | status: input.status ?? 'todo', 80 | priority: input.priority ?? 'medium', 81 | created_at: now, 82 | updated_at: now, 83 | }; 84 | 85 | // TODO: Validate Dependency Existence 86 | 87 | try { 88 | this.taskRepository.create(newTaskData, input.dependencies); 89 | logger.info(`[TaskService] Successfully added task ${taskId} to project ${input.project_id}`); 90 | return newTaskData; 91 | } catch (error) { 92 | logger.error(`[TaskService] Error adding task to project ${input.project_id}:`, error); 93 | throw error; 94 | } 95 | } 96 | 97 | /** 98 | * Lists tasks for a project. 99 | */ 100 | public async listTasks(options: ListTasksOptions): Promise<TaskData[] | StructuredTaskData[]> { 101 | logger.info(`[TaskService] Attempting to list tasks for project: ${options.project_id}`, options); 102 | const projectExists = this.projectRepository.findById(options.project_id); 103 | if (!projectExists) { 104 | logger.warn(`[TaskService] Project not found: ${options.project_id}`); 105 | throw new NotFoundError(`Project with ID ${options.project_id} not found.`); 106 | } 107 | 108 | try { 109 | const allTasks = this.taskRepository.findByProjectId(options.project_id, options.status); 110 | 111 | if (!options.include_subtasks) { 112 | const topLevelTasks = allTasks.filter(task => !task.parent_task_id); 113 | logger.info(`[TaskService] Found ${topLevelTasks.length} top-level tasks for project ${options.project_id}`); 114 | return topLevelTasks; 115 | } else { 116 | const taskMap: Map<string, StructuredTaskData> = new Map(); 117 | const rootTasks: StructuredTaskData[] = []; 118 | for (const task of allTasks) { 119 | taskMap.set(task.task_id, { ...task, subtasks: [] }); 120 | } 121 | for (const task of allTasks) { 122 | if (task.parent_task_id && taskMap.has(task.parent_task_id)) { 123 | const parent = taskMap.get(task.parent_task_id)!; 124 | parent.subtasks!.push(taskMap.get(task.task_id)!); 125 | } else if (!task.parent_task_id) { 126 | rootTasks.push(taskMap.get(task.task_id)!); 127 | } 128 | } 129 | logger.info(`[TaskService] Found ${rootTasks.length} structured root tasks for project ${options.project_id}`); 130 | return rootTasks; 131 | } 132 | } catch (error) { 133 | logger.error(`[TaskService] Error listing tasks for project ${options.project_id}:`, error); 134 | throw error; 135 | } 136 | } 137 | 138 | /** 139 | * Retrieves the full details of a single task. 140 | */ 141 | public async getTaskById(projectId: string, taskId: string): Promise<FullTaskData> { 142 | logger.info(`[TaskService] Attempting to get task ${taskId} for project ${projectId}`); 143 | const task = this.taskRepository.findById(projectId, taskId); 144 | if (!task) { 145 | logger.warn(`[TaskService] Task ${taskId} not found in project ${projectId}`); 146 | throw new NotFoundError(`Task with ID ${taskId} not found in project ${projectId}.`); 147 | } 148 | 149 | try { 150 | const dependencies = this.taskRepository.findDependencies(taskId); 151 | const subtasks = this.taskRepository.findSubtasks(taskId); 152 | const fullTaskData: FullTaskData = { 153 | ...task, 154 | dependencies: dependencies, 155 | subtasks: subtasks, 156 | }; 157 | logger.info(`[TaskService] Successfully retrieved task ${taskId}`); 158 | return fullTaskData; 159 | } catch (error) { 160 | logger.error(`[TaskService] Error retrieving details for task ${taskId}:`, error); 161 | throw error; 162 | } 163 | } 164 | 165 | /** 166 | * Sets the status for one or more tasks within a project. 167 | */ 168 | public async setTaskStatus(projectId: string, taskIds: string[], status: TaskData['status']): Promise<number> { 169 | logger.info(`[TaskService] Attempting to set status to '${status}' for ${taskIds.length} tasks in project ${projectId}`); 170 | const projectExists = this.projectRepository.findById(projectId); 171 | if (!projectExists) { 172 | logger.warn(`[TaskService] Project not found: ${projectId}`); 173 | throw new NotFoundError(`Project with ID ${projectId} not found.`); 174 | } 175 | 176 | const existenceCheck = this.taskRepository.checkTasksExist(projectId, taskIds); 177 | if (!existenceCheck.allExist) { 178 | logger.warn(`[TaskService] One or more tasks not found in project ${projectId}:`, existenceCheck.missingIds); 179 | throw new NotFoundError(`One or more tasks not found in project ${projectId}: ${existenceCheck.missingIds.join(', ')}`); 180 | } 181 | 182 | try { 183 | const now = new Date().toISOString(); 184 | const updatedCount = this.taskRepository.updateStatus(projectId, taskIds, status, now); 185 | if (updatedCount !== taskIds.length) { 186 | logger.warn(`[TaskService] Expected to update ${taskIds.length} tasks, but ${updatedCount} were affected.`); 187 | } 188 | logger.info(`[TaskService] Successfully updated status for ${updatedCount} tasks in project ${projectId}`); 189 | return updatedCount; 190 | } catch (error) { 191 | logger.error(`[TaskService] Error setting status for tasks in project ${projectId}:`, error); 192 | throw error; 193 | } 194 | } 195 | 196 | 197 | /** 198 | * Expands a parent task by adding new subtasks. 199 | * Optionally deletes existing subtasks first if 'force' is true. 200 | * Uses a transaction to ensure atomicity. 201 | * @param input - Details including parent task ID, project ID, subtask descriptions, and force flag. 202 | * @returns The updated parent task details (including new subtasks). 203 | * @throws {NotFoundError} If the project or parent task is not found. 204 | * @throws {ConflictError} If subtasks exist and force is false. 205 | * @throws {Error} If the database operation fails. 206 | */ 207 | public async expandTask(input: ExpandTaskInput): Promise<FullTaskData> { 208 | const { project_id, task_id: parentTaskId, subtask_descriptions, force = false } = input; 209 | logger.info(`[TaskService] Attempting to expand task ${parentTaskId} in project ${project_id} with ${subtask_descriptions.length} subtasks (force=${force})`); 210 | 211 | // Use a transaction for the entire operation 212 | const expandTransaction = this.db.transaction(() => { 213 | // 1. Validate Parent Task Existence (within the transaction) 214 | const parentTask = this.taskRepository.findById(project_id, parentTaskId); 215 | if (!parentTask) { 216 | logger.warn(`[TaskService] Parent task ${parentTaskId} not found in project ${project_id}`); 217 | throw new NotFoundError(`Parent task with ID ${parentTaskId} not found in project ${project_id}.`); 218 | } 219 | 220 | // 2. Check for existing subtasks 221 | const existingSubtasks = this.taskRepository.findSubtasks(parentTaskId); 222 | 223 | // 3. Handle existing subtasks based on 'force' flag 224 | if (existingSubtasks.length > 0) { 225 | if (!force) { 226 | logger.warn(`[TaskService] Conflict: Task ${parentTaskId} already has subtasks and force=false.`); 227 | throw new ConflictError(`Task ${parentTaskId} already has subtasks. Use force=true to replace them.`); 228 | } else { 229 | logger.info(`[TaskService] Force=true: Deleting ${existingSubtasks.length} existing subtasks for parent ${parentTaskId}.`); 230 | this.taskRepository.deleteSubtasks(parentTaskId); 231 | // Note: Dependencies of deleted subtasks are implicitly handled by ON DELETE CASCADE in schema 232 | } 233 | } 234 | 235 | // 4. Create new subtasks 236 | const now = new Date().toISOString(); 237 | const createdSubtasks: TaskData[] = []; 238 | for (const description of subtask_descriptions) { 239 | const subtaskId = uuidv4(); 240 | const newSubtaskData: TaskData = { 241 | task_id: subtaskId, 242 | project_id: project_id, 243 | parent_task_id: parentTaskId, 244 | description: description, // Assuming length validation done by Zod 245 | status: 'todo', // Default status 246 | priority: 'medium', // Default priority 247 | created_at: now, 248 | updated_at: now, 249 | }; 250 | // Use the repository's create method (which handles its own transaction part for task+deps, but is fine here) 251 | // We pass an empty array for dependencies as expandTask doesn't set them for new subtasks 252 | this.taskRepository.create(newSubtaskData, []); 253 | createdSubtasks.push(newSubtaskData); 254 | } 255 | 256 | // 5. Fetch updated parent task details (including new subtasks and existing dependencies) 257 | // We re-fetch to get the consistent state after the transaction commits. 258 | // Note: This requires the transaction function to return the necessary data. 259 | // Alternatively, construct the FullTaskData manually here. Let's construct manually. 260 | const dependencies = this.taskRepository.findDependencies(parentTaskId); // Fetch parent's dependencies 261 | const finalParentData: FullTaskData = { 262 | ...parentTask, // Use data fetched at the start of transaction 263 | updated_at: now, // Update timestamp conceptually (though not saved unless status changes) 264 | dependencies: dependencies, 265 | subtasks: createdSubtasks, // Return the newly created subtasks 266 | }; 267 | return finalParentData; 268 | }); 269 | 270 | try { 271 | // Execute the transaction 272 | const result = expandTransaction(); 273 | logger.info(`[TaskService] Successfully expanded task ${parentTaskId} with ${subtask_descriptions.length} new subtasks.`); 274 | return result; 275 | } catch (error) { 276 | logger.error(`[TaskService] Error expanding task ${parentTaskId}:`, error); 277 | // Re-throw specific errors or generic internal error 278 | if (error instanceof NotFoundError || error instanceof ConflictError) { 279 | throw error; 280 | } 281 | throw new Error(`Failed to expand task: ${error instanceof Error ? error.message : 'Unknown error'}`); 282 | } 283 | } 284 | 285 | 286 | /** 287 | * Finds the next available task based on readiness (status 'todo', dependencies 'done') 288 | * and prioritization (priority, creation date). 289 | * @param projectId - The project ID. 290 | * @returns The full details of the next task, or null if no task is ready. 291 | * @throws {NotFoundError} If the project is not found. 292 | * @throws {Error} If the database operation fails. 293 | */ 294 | public async getNextTask(projectId: string): Promise<FullTaskData | null> { 295 | logger.info(`[TaskService] Attempting to get next task for project ${projectId}`); 296 | 297 | // 1. Validate Project Existence 298 | const projectExists = this.projectRepository.findById(projectId); 299 | if (!projectExists) { 300 | logger.warn(`[TaskService] Project not found: ${projectId}`); 301 | throw new NotFoundError(`Project with ID ${projectId} not found.`); 302 | } 303 | 304 | // 2. Find ready tasks using the repository method 305 | try { 306 | const readyTasks = this.taskRepository.findReadyTasks(projectId); 307 | 308 | if (readyTasks.length === 0) { 309 | logger.info(`[TaskService] No ready tasks found for project ${projectId}`); 310 | return null; // No task is ready 311 | } 312 | 313 | // 3. The first task in the list is the highest priority one due to repo ordering 314 | const nextTask = readyTasks[0]; 315 | logger.info(`[TaskService] Next task identified: ${nextTask.task_id}`); 316 | 317 | // 4. Fetch full details (dependencies, subtasks) for the selected task 318 | // We could potentially optimize this if findReadyTasks returned more details, 319 | // but for separation of concerns, we call getTaskById logic (or similar). 320 | // Re-using getTaskById logic: 321 | return await this.getTaskById(projectId, nextTask.task_id); 322 | 323 | } catch (error) { 324 | logger.error(`[TaskService] Error getting next task for project ${projectId}:`, error); 325 | throw error; // Re-throw repository or other errors 326 | } 327 | } 328 | 329 | /** 330 | * Updates specific fields of an existing task. 331 | * @param input - Contains project ID, task ID, and optional fields to update. 332 | * @returns The full details of the updated task. 333 | * @throws {ValidationError} If no update fields are provided or if dependencies are invalid. 334 | * @throws {NotFoundError} If the project, task, or any specified dependency task is not found. 335 | * @throws {Error} If the database operation fails. 336 | */ 337 | public async updateTask(input: { 338 | project_id: string; 339 | task_id: string; 340 | description?: string; 341 | priority?: TaskData['priority']; 342 | dependencies?: string[]; 343 | }): Promise<FullTaskData> { 344 | const { project_id, task_id } = input; 345 | logger.info(`[TaskService] Attempting to update task ${task_id} in project ${project_id}`); 346 | 347 | // 1. Validate that at least one field is being updated 348 | if (input.description === undefined && input.priority === undefined && input.dependencies === undefined) { 349 | throw new ValidationError("At least one field (description, priority, or dependencies) must be provided for update."); 350 | } 351 | 352 | // 2. Validate Project Existence (using repo method) 353 | const projectExists = this.projectRepository.findById(project_id); 354 | if (!projectExists) { 355 | logger.warn(`[TaskService] Project not found: ${project_id}`); 356 | throw new NotFoundError(`Project with ID ${project_id} not found.`); 357 | } 358 | 359 | // 3. Validate Task Existence (using repo method - findById also implicitly checks project scope) 360 | // We need the task data anyway if dependencies are involved, so fetch it now. 361 | const existingTask = this.taskRepository.findById(project_id, task_id); 362 | if (!existingTask) { 363 | logger.warn(`[TaskService] Task ${task_id} not found in project ${project_id}`); 364 | throw new NotFoundError(`Task with ID ${task_id} not found in project ${project_id}.`); 365 | } 366 | 367 | // 4. Validate Dependency Existence if provided 368 | if (input.dependencies !== undefined) { 369 | if (input.dependencies.length > 0) { 370 | const depCheck = this.taskRepository.checkTasksExist(project_id, input.dependencies); 371 | if (!depCheck.allExist) { 372 | logger.warn(`[TaskService] Invalid dependencies provided for task ${task_id}:`, depCheck.missingIds); 373 | throw new ValidationError(`One or more dependency tasks not found in project ${project_id}: ${depCheck.missingIds.join(', ')}`); 374 | } 375 | // Also check for self-dependency 376 | if (input.dependencies.includes(task_id)) { 377 | throw new ValidationError(`Task ${task_id} cannot depend on itself.`); 378 | } 379 | } 380 | // If input.dependencies is an empty array, it means "remove all dependencies" 381 | } 382 | 383 | // 5. Prepare payload for repository 384 | const updatePayload: { description?: string; priority?: TaskData['priority']; dependencies?: string[] } = {}; 385 | if (input.description !== undefined) updatePayload.description = input.description; 386 | if (input.priority !== undefined) updatePayload.priority = input.priority; 387 | if (input.dependencies !== undefined) updatePayload.dependencies = input.dependencies; 388 | 389 | // 6. Call Repository update method 390 | try { 391 | const now = new Date().toISOString(); 392 | // The repo method handles the transaction for task update + dependency replacement 393 | const updatedTaskData = this.taskRepository.updateTask(project_id, task_id, updatePayload, now); 394 | 395 | // 7. Fetch full details (including potentially updated dependencies and existing subtasks) 396 | // Re-use logic similar to getTaskById 397 | const finalDependencies = this.taskRepository.findDependencies(task_id); 398 | const finalSubtasks = this.taskRepository.findSubtasks(task_id); 399 | 400 | const fullUpdatedTask: FullTaskData = { 401 | ...updatedTaskData, // Use the data returned by the update method 402 | dependencies: finalDependencies, 403 | subtasks: finalSubtasks, 404 | }; 405 | 406 | logger.info(`[TaskService] Successfully updated task ${task_id} in project ${project_id}`); 407 | return fullUpdatedTask; 408 | 409 | } catch (error) { 410 | logger.error(`[TaskService] Error updating task ${task_id} in project ${project_id}:`, error); 411 | // Re-throw specific errors if needed, otherwise let the generic error propagate 412 | if (error instanceof Error && error.message.includes('not found')) { 413 | // Map repo's generic error for not found back to specific NotFoundError 414 | throw new NotFoundError(error.message); 415 | } 416 | throw error; // Re-throw other errors (like DB constraint errors or unexpected ones) 417 | } 418 | } 419 | 420 | 421 | /** 422 | * Deletes one or more tasks within a project. 423 | * @param projectId - The project ID. 424 | * @param taskIds - An array of task IDs to delete. 425 | * @returns The number of tasks successfully deleted. 426 | * @throws {NotFoundError} If the project or any of the specified tasks are not found. 427 | * @throws {Error} If the database operation fails. 428 | */ 429 | public async deleteTasks(projectId: string, taskIds: string[]): Promise<number> { 430 | logger.info(`[TaskService] Attempting to delete ${taskIds.length} tasks from project ${projectId}`); 431 | 432 | // 1. Validate Project Existence 433 | const projectExists = this.projectRepository.findById(projectId); 434 | if (!projectExists) { 435 | logger.warn(`[TaskService] Project not found: ${projectId}`); 436 | throw new NotFoundError(`Project with ID ${projectId} not found.`); 437 | } 438 | 439 | // 2. Validate Task Existence *before* attempting delete 440 | // This ensures we report an accurate count and catch non-existent IDs early. 441 | const existenceCheck = this.taskRepository.checkTasksExist(projectId, taskIds); 442 | if (!existenceCheck.allExist) { 443 | logger.warn(`[TaskService] Cannot delete: One or more tasks not found in project ${projectId}:`, existenceCheck.missingIds); 444 | // Throw NotFoundError here, as InvalidParams might be confusing if some IDs were valid 445 | throw new NotFoundError(`One or more tasks to delete not found in project ${projectId}: ${existenceCheck.missingIds.join(', ')}`); 446 | } 447 | 448 | // 3. Call Repository delete method 449 | try { 450 | // The repository method handles the actual DELETE operation 451 | const deletedCount = this.taskRepository.deleteTasks(projectId, taskIds); 452 | 453 | // Double-check count (optional, but good sanity check) 454 | if (deletedCount !== taskIds.length) { 455 | logger.warn(`[TaskService] Expected to delete ${taskIds.length} tasks, but repository reported ${deletedCount} deletions.`); 456 | // This might indicate a race condition or unexpected DB behavior, though unlikely with cascade. 457 | // For V1, we'll trust the repo count but log the warning. 458 | } 459 | 460 | logger.info(`[TaskService] Successfully deleted ${deletedCount} tasks from project ${projectId}`); 461 | return deletedCount; 462 | 463 | } catch (error) { 464 | logger.error(`[TaskService] Error deleting tasks from project ${projectId}:`, error); 465 | throw error; // Re-throw database or other errors 466 | } 467 | } 468 | 469 | 470 | // --- Add other task service methods later --- 471 | } 472 | ```