├── .cursor └── rules │ ├── MCP_clients.mdc │ ├── MCP_implementation.mdc │ ├── MCP_remote.mdc │ ├── cli-tests.mdc │ ├── errors.mdc │ ├── general.mdc │ └── tests.mdc ├── .github └── workflows │ └── npm-publish.yml ├── .gitignore ├── .npmignore ├── Dockerfile ├── LICENSE ├── README.md ├── babel.config.cjs ├── biome.json ├── jest.config.cjs ├── jest.resolver.mts ├── package-lock.json ├── package.json ├── smithery.yaml ├── src ├── client │ ├── cli.ts │ ├── errors.ts │ ├── index.ts │ └── taskFormattingUtils.ts ├── server │ ├── FileSystemService.ts │ ├── TaskManager.ts │ ├── index.ts │ ├── toolExecutors.ts │ └── tools.ts └── types │ ├── data.ts │ ├── errors.ts │ └── response.ts ├── tests ├── cli │ └── cli.integration.test.ts ├── mcp │ ├── e2e.integration.test.ts │ ├── test-helpers.ts │ └── tools │ │ ├── add-tasks-to-project.test.ts │ │ ├── approve-task.test.ts │ │ ├── create-project.test.ts │ │ ├── create-task.test.ts │ │ ├── delete-project.test.ts │ │ ├── delete-task.test.ts │ │ ├── finalize-project.test.ts │ │ ├── generate-project-plan.test.ts │ │ ├── get-next-task.test.ts │ │ ├── list-projects.test.ts │ │ ├── list-tasks.test.ts │ │ ├── read-project.test.ts │ │ ├── read-task.test.ts │ │ └── update-task.test.ts ├── setup.ts └── version-consistency.test.js └── tsconfig.json /.cursor/rules/MCP_clients.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: tests/integration/mcp-client.test.ts 4 | alwaysApply: false 5 | --- 6 | ### Writing MCP Clients 7 | 8 | The SDK provides a high-level client interface: 9 | 10 | ```typescript 11 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 12 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; 13 | 14 | const transport = new StdioClientTransport({ 15 | command: "node", 16 | args: ["server.js"] 17 | }); 18 | 19 | const client = new Client( 20 | { 21 | name: "example-client", 22 | version: "1.0.0" 23 | }, 24 | { 25 | capabilities: { 26 | prompts: {}, 27 | resources: {}, 28 | tools: {} 29 | } 30 | } 31 | ); 32 | 33 | await client.connect(transport); 34 | 35 | // List prompts 36 | const prompts = await client.listPrompts(); 37 | 38 | // Get a prompt 39 | const prompt = await client.getPrompt("example-prompt", { 40 | arg1: "value" 41 | }); 42 | 43 | // List resources 44 | const resources = await client.listResources(); 45 | 46 | // Read a resource 47 | const resource = await client.readResource("file:///example.txt"); 48 | 49 | // Call a tool 50 | const result = await client.callTool({ 51 | name: "example-tool", 52 | arguments: { 53 | arg1: "value" 54 | } 55 | }); 56 | ``` -------------------------------------------------------------------------------- /.cursor/rules/MCP_implementation.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: index.ts 4 | alwaysApply: false 5 | --- 6 | # MCP TypeScript SDK 7 | 8 | ## What is MCP? 9 | 10 | The Model Context Protocol lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but specifically designed for LLM interactions. MCP servers can: 11 | 12 | - Expose data through **Resources** (think of these sort of like GET endpoints; they are used to load information into the LLM's context) 13 | - Provide functionality through **Tools** (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect) 14 | - Define interaction patterns through **Prompts** (reusable templates for LLM interactions) 15 | 16 | ## Running Your Server 17 | 18 | MCP servers in TypeScript need to be connected to a transport to communicate with clients. 19 | 20 | ```typescript 21 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 22 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 23 | import { 24 | ListPromptsRequestSchema, 25 | GetPromptRequestSchema 26 | } from "@modelcontextprotocol/sdk/types.js"; 27 | 28 | const server = new Server( 29 | { 30 | name: "example-server", 31 | version: "1.0.0" 32 | }, 33 | { 34 | capabilities: { 35 | prompts: {} 36 | } 37 | } 38 | ); 39 | 40 | server.setRequestHandler(ListPromptsRequestSchema, async () => { 41 | return { 42 | prompts: [{ 43 | name: "example-prompt", 44 | description: "An example prompt template", 45 | arguments: [{ 46 | name: "arg1", 47 | description: "Example argument", 48 | required: true 49 | }] 50 | }] 51 | }; 52 | }); 53 | 54 | server.setRequestHandler(GetPromptRequestSchema, async (request) => { 55 | if (request.params.name !== "example-prompt") { 56 | throw new Error("Unknown prompt"); 57 | } 58 | return { 59 | description: "Example prompt", 60 | messages: [{ 61 | role: "user", 62 | content: { 63 | type: "text", 64 | text: "Example prompt text" 65 | } 66 | }] 67 | }; 68 | }); 69 | 70 | const transport = new StdioServerTransport(); 71 | await server.connect(transport); 72 | ``` -------------------------------------------------------------------------------- /.cursor/rules/MCP_remote.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | ### HTTP with SSE 7 | 8 | For remote servers, start a web server with a Server-Sent Events (SSE) endpoint, and a separate endpoint for the client to send its messages to: 9 | 10 | ```typescript 11 | import express from "express"; 12 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 13 | import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; 14 | 15 | const server = new McpServer({ 16 | name: "example-server", 17 | version: "1.0.0" 18 | }); 19 | 20 | // ... set up server resources, tools, and prompts ... 21 | 22 | const app = express(); 23 | 24 | app.get("/sse", async (req, res) => { 25 | const transport = new SSEServerTransport("/messages", res); 26 | await server.connect(transport); 27 | }); 28 | 29 | app.post("/messages", async (req, res) => { 30 | // Note: to support multiple simultaneous connections, these messages will 31 | // need to be routed to a specific matching transport. (This logic isn't 32 | // implemented here, for simplicity.) 33 | await transport.handlePostMessage(req, res); 34 | }); 35 | 36 | app.listen(3001); 37 | ``` -------------------------------------------------------------------------------- /.cursor/rules/cli-tests.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: tests/integration/cli.test.ts 4 | alwaysApply: false 5 | --- 6 | **CLI Testing**: 7 | - When testing CLI commands, pass the environment variable inline: 8 | ```typescript 9 | const { stdout } = await execAsync( 10 | `TASK_MANAGER_FILE_PATH=${tasksFilePath} tsx ${CLI_PATH} command` 11 | ); 12 | ``` 13 | - Use `tsx` instead of `node` for running TypeScript files directly -------------------------------------------------------------------------------- /.cursor/rules/errors.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: **/errors.ts 4 | alwaysApply: false 5 | --- 6 | # Error Flow 7 | 8 | ```mermaid 9 | graph TD 10 | subgraph Core_Logic 11 | FS[FileSystemService: e.g., FileReadError] --> TM[TaskManager: Throws App Errors, e.g., ProjectNotFound, TaskNotDone] 12 | TM -->|App Error with code ERR_xxxx| CLI_Handler["cli.ts Command Handler"] 13 | TM -->|App Error with code ERR_xxxx| ToolExec["toolExecutors.ts: execute"] 14 | end 15 | 16 | subgraph CLI_Path 17 | CLI_Handler -->|App Error| CLI_Catch["cli.ts catch block"] 18 | CLI_Catch -->|Error Object| FormatCLI["client errors.ts formatCliError"] 19 | FormatCLI -->|"Error [ERR_xxxx]: message"| ConsoleOut["console.error Output"] 20 | end 21 | 22 | subgraph MCP_Server_Path 23 | subgraph Validation_Layer 24 | ToolExecVal["toolExecutors.ts Validation"] -->|App Error, e.g., MissingParameter| ExecToolErrHandler 25 | end 26 | 27 | subgraph App_Execution 28 | ToolExec -->|App Error with code ERR_xxxx| ExecToolErrHandler["tools.ts executeToolAndHandleErrors catch block"] 29 | ExecToolErrHandler -->|Map AppError to Protocol Error or Tool Result| ErrorMapping 30 | ErrorMapping -->|"If validation error (ERR_1xxx)"| McpError["Create McpError with appropriate ErrorCode"] 31 | ErrorMapping -->|"If business logic error (ERR_2xxx+)"| FormatResult["Format as isError true result"] 32 | 33 | McpError -->|Throw| SDKHandler["server index.ts SDK Handler"] 34 | FormatResult -->|"{ content: [{ text: Error [ERR_xxxx]: message }], isError: true }"| SDKHandler 35 | end 36 | 37 | SDKHandler -- Protocol Error --> SDKFormatError["SDK Formats as JSON-RPC Error Response"] 38 | SDKHandler -- Tool Result --> SDKFormatResult["SDK Formats as JSON-RPC Success Response"] 39 | 40 | SDKFormatError -->|"{ error: { code: -326xx, message: ... } }"| MCPClient["MCP Client"] 41 | SDKFormatResult -->|"{ result: { content: [...], isError: true } }"| MCPClient 42 | end 43 | ``` 44 | 45 | **Explanation of Updated Error Flow and Transformations:** 46 | 47 | Errors are consistently through a unified `AppError` system: 48 | 49 | 1. **Validation Errors** (`ERR_1xxx` series) 50 | - Used for validation issues (e.g., MissingParameter, InvalidArgument) 51 | - Thrown by tool executors during parameter validation 52 | - Mapped to protocol-level McpErrors in `executeToolAndHandleErrors` 53 | 54 | 2. **Business Logic Errors** (`ERR_2xxx` and higher) 55 | - Used for all business logic and application-specific errors 56 | - Include specific error codes 57 | - Returned as serialized CallToolResults with `isError: true` -------------------------------------------------------------------------------- /.cursor/rules/general.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | Work step-by-step. If presented with an implementation plan, implement the plan exactly. If the plan presents more than one implementation option, consult with the human user to decide between options. If you are tempted to embellish or imporve upon the plan, consult with the human user. Always complete the current task and wait for human review before proceeding to the next task. 7 | 8 | In developing this codebase, we are doing test-driven development with an integration testing (as opposed to a unit testing) verification strategy. Before writing any code (except perhaps for empty function or class signatures), we will write tests and run them to make sure they fail. The red phase is not complete until the tests are failing correctly. -------------------------------------------------------------------------------- /.cursor/rules/tests.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Writing unit tests with `jest` 3 | globs: tests/**/* 4 | alwaysApply: false 5 | --- 6 | Make use of the helpers in tests/mcp/test-helpers.ts for writing tests. 7 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - '**' # Run on all branches 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-node@v4 12 | with: 13 | node-version: 'latest' 14 | - run: npm ci 15 | - run: npm install -g tsx 16 | - run: npm test 17 | 18 | publish: 19 | needs: test 20 | if: github.ref == 'refs/heads/main' # Only run this job on main branch 21 | permissions: 22 | packages: write 23 | contents: write 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 # Fetch all history for PR message extraction 29 | 30 | - uses: actions/setup-node@v4 31 | with: 32 | node-version: 'latest' 33 | 34 | - name: Get PR Message 35 | id: pr_message 36 | run: | 37 | PR_NUMBER=$(git log -1 --pretty=%B | grep -oP '#\K\d+' || echo "") 38 | if [ ! -z "$PR_NUMBER" ]; then 39 | PR_MESSAGE=$(gh pr view $PR_NUMBER --json body -q .body || echo "") 40 | echo "message<> $GITHUB_OUTPUT 41 | echo "$PR_MESSAGE" >> $GITHUB_OUTPUT 42 | echo "EOF" >> $GITHUB_OUTPUT 43 | else 44 | echo "message=No PR message found" >> $GITHUB_OUTPUT 45 | fi 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | 49 | - run: npm ci 50 | - run: npm install -g tsx 51 | - run: npm run build 52 | 53 | - name: Publish to NPM 54 | id: publish 55 | uses: JS-DevTools/npm-publish@v3 56 | with: 57 | token: ${{ secrets.NPM_TOKEN }} 58 | 59 | - name: Get package version 60 | if: steps.publish.outputs.type != 'none' 61 | id: package_version 62 | run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT 63 | 64 | - name: Create GitHub Release 65 | if: steps.publish.outputs.type != 'none' 66 | uses: ncipollo/release-action@v1 67 | with: 68 | token: ${{ secrets.GITHUB_TOKEN }} 69 | tag: v${{ steps.package_version.outputs.version }} 70 | name: Release v${{ steps.package_version.outputs.version }} 71 | body: | 72 | ${{ steps.pr_message.outputs.message }} 73 | 74 | Package published to npm: ${{ steps.publish.outputs.version }} 75 | draft: false 76 | prerelease: false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .specstory 4 | .DS_Store 5 | .idea 6 | .vscode 7 | .env 8 | .env.local 9 | artifacts 10 | repomix-output.txt 11 | 12 | # Task files 13 | tasks.json 14 | *.tasks.json -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Source files 2 | src/ 3 | tests/ 4 | artifacts/ 5 | 6 | # Config files 7 | .github/ 8 | .vscode/ 9 | .gitignore 10 | .npmrc 11 | tsconfig.json 12 | jest.config.js 13 | 14 | # Test files 15 | **/test/ 16 | **/tests/ 17 | **/*.test.ts 18 | **/*.spec.ts 19 | 20 | # Miscellaneous 21 | .DS_Store 22 | *.log 23 | node_modules/ 24 | coverage/ 25 | tmp/ 26 | temp/ 27 | .cursor/ 28 | .specstory/ 29 | repomix-output.txt -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | FROM node:lts-alpine 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Copy package files 8 | COPY package.json package-lock.json ./ 9 | 10 | # Install dependencies without running lifecycle scripts 11 | RUN npm install --ignore-scripts 12 | 13 | # Copy all source files 14 | COPY . . 15 | 16 | # Build the project 17 | RUN npm run build 18 | 19 | # Expose port if needed (optional, MCP may use stdio, so not exposing ports by default) 20 | 21 | # Command to run the server 22 | CMD ["node", "dist/src/server/index.js"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Christopher C. Smith 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCP Task Manager 2 | 3 | [![smithery badge](https://smithery.ai/badge/@chriscarrollsmith/taskqueue-mcp)](https://smithery.ai/server/@chriscarrollsmith/taskqueue-mcp) 4 | 5 | MCP Task Manager ([npm package: taskqueue-mcp](https://www.npmjs.com/package/taskqueue-mcp)) is a Model Context Protocol (MCP) server for AI task management. This tool helps AI assistants handle multi-step tasks in a structured way, with optional user approval checkpoints. 6 | 7 | ## Features 8 | 9 | - Task planning with multiple steps 10 | - Progress tracking 11 | - User approval of completed tasks 12 | - Project completion approval 13 | - Task details visualization 14 | - Task status state management 15 | - Enhanced CLI for task inspection and management 16 | 17 | ## Basic Setup 18 | 19 | Usually you will set the tool configuration in Claude Desktop, Cursor, or another MCP client as follows: 20 | 21 | ```json 22 | { 23 | "tools": { 24 | "taskqueue": { 25 | "command": "npx", 26 | "args": ["-y", "taskqueue-mcp"] 27 | } 28 | } 29 | } 30 | ``` 31 | 32 | To use the CLI utility, you can install the package globally and then use the following command: 33 | 34 | ```bash 35 | npx taskqueue --help 36 | ``` 37 | 38 | This will show the available commands and options. 39 | 40 | ### Advanced Configuration 41 | 42 | The task manager supports multiple LLM providers for generating project plans. You can configure one or more of the following environment variables depending on which providers you want to use: 43 | 44 | - `OPENAI_API_KEY`: Required for using OpenAI models (e.g., GPT-4) 45 | - `GOOGLE_GENERATIVE_AI_API_KEY`: Required for using Google's Gemini models 46 | - `DEEPSEEK_API_KEY`: Required for using Deepseek models 47 | 48 | To generate project plans using the CLI, set these environment variables in your shell: 49 | 50 | ```bash 51 | export OPENAI_API_KEY="your-api-key" 52 | export GOOGLE_GENERATIVE_AI_API_KEY="your-api-key" 53 | export DEEPSEEK_API_KEY="your-api-key" 54 | ``` 55 | 56 | Or you can include them in your MCP client configuration to generate project plans with MCP tool calls: 57 | 58 | ```json 59 | { 60 | "tools": { 61 | "taskqueue": { 62 | "command": "npx", 63 | "args": ["-y", "taskqueue-mcp"], 64 | "env": { 65 | "OPENAI_API_KEY": "your-api-key", 66 | "GOOGLE_GENERATIVE_AI_API_KEY": "your-api-key", 67 | "DEEPSEEK_API_KEY": "your-api-key" 68 | } 69 | } 70 | } 71 | } 72 | ``` 73 | 74 | ## Available MCP Tools 75 | 76 | The TaskManager now uses a direct tools interface with specific, purpose-built tools for each operation: 77 | 78 | ### Project Management Tools 79 | 80 | - `list_projects`: Lists all projects in the system 81 | - `read_project`: Gets details about a specific project 82 | - `create_project`: Creates a new project with initial tasks 83 | - `delete_project`: Removes a project 84 | - `add_tasks_to_project`: Adds new tasks to an existing project 85 | - `finalize_project`: Finalizes a project after all tasks are done 86 | 87 | ### Task Management Tools 88 | 89 | - `list_tasks`: Lists all tasks for a specific project 90 | - `read_task`: Gets details of a specific task 91 | - `create_task`: Creates a new task in a project 92 | - `update_task`: Modifies a task's properties (title, description, status) 93 | - `delete_task`: Removes a task from a project 94 | - `approve_task`: Approves a completed task 95 | - `get_next_task`: Gets the next pending task in a project 96 | - `mark_task_done`: Marks a task as completed with details 97 | 98 | ### Task Status and Workflows 99 | 100 | Tasks have a status field that can be one of: 101 | - `not started`: Task has not been started yet 102 | - `in progress`: Task is currently being worked on 103 | - `done`: Task has been completed (requires `completedDetails`) 104 | 105 | #### Status Transition Rules 106 | 107 | The system enforces the following rules for task status transitions: 108 | 109 | - Tasks follow a specific workflow with defined valid transitions: 110 | - From `not started`: Can only move to `in progress` 111 | - From `in progress`: Can move to either `done` or back to `not started` 112 | - From `done`: Can move back to `in progress` if additional work is needed 113 | - When a task is marked as "done", the `completedDetails` field must be provided to document what was completed 114 | - Approved tasks cannot be modified 115 | - A project can only be approved when all tasks are both done and approved 116 | 117 | These rules help maintain the integrity of task progress and ensure proper documentation of completed work. 118 | 119 | ### Usage Workflow 120 | 121 | A typical workflow for an LLM using this task manager would be: 122 | 123 | 1. `create_project`: Start a project with initial tasks 124 | 2. `get_next_task`: Get the first pending task 125 | 3. Work on the task 126 | 4. `mark_task_done`: Mark the task as complete with details 127 | 5. Wait for approval (user must call `approve_task` through the CLI) 128 | 6. `get_next_task`: Get the next pending task 129 | 7. Repeat steps 3-6 until all tasks are complete 130 | 8. `finalize_project`: Complete the project (requires user approval) 131 | 132 | ### CLI Commands 133 | 134 | To use the CLI, you will need to install the package globally: 135 | 136 | ```bash 137 | npm install -g taskqueue-mcp 138 | ``` 139 | 140 | Alternatively, you can run the CLI with `npx` using the `--package=taskqueue-mcp` flag to tell `npx` what package it's from. 141 | 142 | ```bash 143 | npx --package=taskqueue-mcp taskqueue --help 144 | ``` 145 | 146 | #### Task Approval 147 | 148 | By default, all tasks and projects will be auto-approved when marked "done" by the AI agent. To require manual human task approval, set `autoApprove` to `false` when creating a project. 149 | 150 | Task approval is controlled exclusively by the human user through the CLI: 151 | 152 | ```bash 153 | npx taskqueue approve-task -- 154 | ``` 155 | 156 | Options: 157 | - `-f, --force`: Force approval even if the task is not marked as done 158 | 159 | Note: Tasks must be marked as "done" with completed details by the AI agent before they can be approved (unless using --force). 160 | 161 | #### Listing Tasks and Projects 162 | 163 | The CLI provides a command to list all projects and tasks: 164 | 165 | ```bash 166 | npx taskqueue list-tasks 167 | ``` 168 | 169 | To view details of a specific project: 170 | 171 | ```bash 172 | npx taskqueue list-tasks -- -p 173 | ``` 174 | 175 | This command displays information about all projects in the system or a specific project, including: 176 | 177 | - Project ID and initial prompt 178 | - Completion status 179 | - Task details (title, description, status, approval) 180 | - Progress metrics (approved/completed/total tasks) 181 | 182 | ## Data Schema and Storage 183 | 184 | ### File Location 185 | 186 | The task manager stores data in a JSON file that must be accessible to both the server and CLI. 187 | 188 | The default platform-specific location is: 189 | - **Linux**: `~/.local/share/taskqueue-mcp/tasks.json` 190 | - **macOS**: `~/Library/Application Support/taskqueue-mcp/tasks.json` 191 | - **Windows**: `%APPDATA%\taskqueue-mcp\tasks.json` 192 | 193 | Using a custom file path for storing task data is not recommended, because you have to remember to set the same path for both the MCP server and the CLI, or they won't be able to coordinate with each other. But if you do want to use a custom path, you can set the `TASK_MANAGER_FILE_PATH` environment variable in your MCP client configuration: 194 | 195 | ```json 196 | { 197 | "tools": { 198 | "taskqueue": { 199 | "command": "npx", 200 | "args": ["-y", "taskqueue-mcp"], 201 | "env": { 202 | "TASK_MANAGER_FILE_PATH": "/path/to/tasks.json" 203 | } 204 | } 205 | } 206 | } 207 | ``` 208 | 209 | Then, before running the CLI, you should export the same path in your shell: 210 | 211 | ```bash 212 | export TASK_MANAGER_FILE_PATH="/path/to/tasks.json" 213 | ``` 214 | 215 | ### Data Schema 216 | 217 | The JSON file uses the following structure: 218 | 219 | ``` 220 | TaskManagerFile 221 | ├── projects: Project[] 222 | ├── projectId: string # Format: "proj-{number}" 223 | ├── initialPrompt: string # Original user request text 224 | ├── projectPlan: string # Additional project details 225 | ├── completed: boolean # Project completion status 226 | ├── autoApprove: boolean # Set `false` to require manual user approval 227 | └── tasks: Task[] # Array of tasks 228 | ├── id: string # Format: "task-{number}" 229 | ├── title: string # Short task title 230 | ├── description: string # Detailed task description 231 | ├── status: string # Task status: "not started", "in progress", or "done" 232 | ├── approved: boolean # Task approval status 233 | ├── completedDetails: string # Completion information (required when status is "done") 234 | ├── toolRecommendations: string # Suggested tools that might be helpful for this task 235 | └── ruleRecommendations: string # Suggested rules/guidelines to follow for this task 236 | ``` 237 | 238 | ## License 239 | 240 | MIT 241 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript', 5 | ], 6 | }; -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true 10 | } 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "space", 15 | "indentWidth": 2, 16 | "lineWidth": 80 17 | }, 18 | "javascript": { 19 | "formatter": { 20 | "quoteStyle": "double", 21 | "trailingComma": "es5", 22 | "semicolons": "always" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | moduleNameMapper: { 4 | '^(\\.{1,2}/.*)\\.js$': '$1' 5 | }, 6 | modulePathIgnorePatterns: ['/dist/'], 7 | // Force Jest to exit after all tests have completed 8 | forceExit: true, 9 | // Detect open handles and warn about them 10 | detectOpenHandles: true, 11 | // Extend the timeout to allow sufficient time for tests to complete 12 | testTimeout: 30000, 13 | }; -------------------------------------------------------------------------------- /jest.resolver.mts: -------------------------------------------------------------------------------- 1 | import type { SyncResolver } from 'jest-resolve'; 2 | 3 | const mjsResolver: SyncResolver = (path, options) => { 4 | const mjsExtRegex = /\.m?[jt]s$/i; 5 | const resolver = options.defaultResolver; 6 | if (mjsExtRegex.test(path)) { 7 | try { 8 | return resolver(path.replace(/\.mjs$/, '.mts').replace(/\.js$/, '.ts'), options); 9 | } catch { 10 | // use default resolver 11 | } 12 | } 13 | 14 | return resolver(path, options); 15 | }; 16 | 17 | export default mjsResolver; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "taskqueue-mcp", 3 | "version": "1.4.1", 4 | "description": "Task Queue MCP Server", 5 | "author": "Christopher C. Smith (christopher.smith@promptlytechnologies.com)", 6 | "main": "dist/src/server/index.js", 7 | "type": "module", 8 | "bin": { 9 | "taskqueue-mcp": "dist/src/server/index.js", 10 | "taskqueue": "dist/src/client/index.js" 11 | }, 12 | "files": [ 13 | "dist/src/**/*.js", 14 | "dist/src/**/*.d.ts", 15 | "dist/src/**/*.js.map" 16 | ], 17 | "scripts": { 18 | "build": "tsc", 19 | "start": "node dist/src/server/index.js", 20 | "dev": "tsc && node dist/src/server/index.js", 21 | "test": "tsc && NODE_OPTIONS=--experimental-vm-modules jest", 22 | "cli": "node dist/src/cli.js" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/chriscarrollsmith/taskqueue-mcp.git" 27 | }, 28 | "keywords": [ 29 | "taskqueue", 30 | "taskqueue-mcp", 31 | "taskqueue", 32 | "mcp", 33 | "claude" 34 | ], 35 | "license": "MIT", 36 | "publishConfig": { 37 | "access": "public" 38 | }, 39 | "dependencies": { 40 | "@ai-sdk/deepseek": "^0.2.4", 41 | "@ai-sdk/google": "^1.2.5", 42 | "@ai-sdk/openai": "^1.3.6", 43 | "@modelcontextprotocol/sdk": "^1.8.0", 44 | "ai": "^4.2.10", 45 | "chalk": "^5.4.1", 46 | "cli-table3": "^0.6.5", 47 | "commander": "^13.1.0", 48 | "glob": "^11.0.1", 49 | "zod": "^3.24.2", 50 | "zod-to-json-schema": "^3.24.5" 51 | }, 52 | "devDependencies": { 53 | "@babel/core": "^7.26.10", 54 | "@babel/preset-env": "^7.26.9", 55 | "@babel/preset-typescript": "^7.27.0", 56 | "@jest/globals": "^29.7.0", 57 | "@types/jest": "^29.5.14", 58 | "@types/json-schema": "^7.0.15", 59 | "@types/node": "^22.13.14", 60 | "babel-jest": "^29.7.0", 61 | "dotenv": "^16.4.7", 62 | "jest": "^29.7.0", 63 | "shx": "^0.4.0", 64 | "ts-jest": "^29.3.0", 65 | "typescript": "^5.8.2" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | properties: 9 | taskManagerFilePath: 10 | type: string 11 | default: "" 12 | description: Custom file path for the task manager JSON file. Defaults to 13 | OS-specific paths if not provided. 14 | openaiApiKey: 15 | type: string 16 | default: "" 17 | description: API key for OpenAI models (optional, if using OpenAI for project 18 | planning). 19 | googleGenerativeAiApiKey: 20 | type: string 21 | default: "" 22 | description: API key for Google's Generative AI models (optional). 23 | deepseekApiKey: 24 | type: string 25 | default: "" 26 | description: API key for Deepseek models (optional). 27 | commandFunction: 28 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio. 29 | |- 30 | (config) => ({ 31 | command: 'node', 32 | args: ['dist/src/server/index.js'], 33 | env: Object.assign({}, process.env, 34 | config.taskManagerFilePath ? { TASK_MANAGER_FILE_PATH: config.taskManagerFilePath } : {}, 35 | config.openaiApiKey ? { OPENAI_API_KEY: config.openaiApiKey } : {}, 36 | config.googleGenerativeAiApiKey ? { GOOGLE_GENERATIVE_AI_API_KEY: config.googleGenerativeAiApiKey } : {}, 37 | config.deepseekApiKey ? { DEEPSEEK_API_KEY: config.deepseekApiKey } : {} 38 | ) 39 | }) 40 | exampleConfig: 41 | taskManagerFilePath: /custom/path/to/tasks.json 42 | openaiApiKey: dummy-openai-key 43 | googleGenerativeAiApiKey: dummy-google-key 44 | deepseekApiKey: dummy-deepseek-key 45 | -------------------------------------------------------------------------------- /src/client/cli.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import chalk from "chalk"; 3 | import { 4 | TaskState, 5 | Task, 6 | Project 7 | } from "../types/data.js"; 8 | import { TaskManager } from "../server/TaskManager.js"; 9 | import { formatCliError } from "./errors.js"; 10 | import { formatProjectsList, formatTaskProgressTable } from "./taskFormattingUtils.js"; 11 | 12 | const program = new Command(); 13 | 14 | program 15 | .name("taskqueue") 16 | .description("CLI for the Task Manager MCP Server") 17 | .version("1.4.1") 18 | .option( 19 | '-f, --file-path ', 20 | 'Specify the path to the tasks JSON file. Overrides TASK_MANAGER_FILE_PATH env var.' 21 | ); 22 | 23 | let taskManager: TaskManager; 24 | 25 | program.hook('preAction', (thisCommand, actionCommand) => { 26 | const cliFilePath = program.opts().filePath; 27 | const envFilePath = process.env.TASK_MANAGER_FILE_PATH; 28 | const resolvedPath = cliFilePath || envFilePath || undefined; 29 | 30 | try { 31 | taskManager = new TaskManager(resolvedPath); 32 | } catch (error) { 33 | console.error(chalk.red(formatCliError(error as Error))); 34 | process.exit(1); 35 | } 36 | }); 37 | 38 | program 39 | .command("approve") 40 | .description("Approve a completed task") 41 | .argument("", "Project ID") 42 | .argument("", "Task ID") 43 | .option('-f, --force', 'Force approval even if task is not marked as done') 44 | .action(async (projectId, taskId, options) => { 45 | try { 46 | console.log(chalk.blue(`Attempting to approve task ${chalk.bold(taskId)} in project ${chalk.bold(projectId)}...`)); 47 | 48 | // First, verify the project and task exist and get their details 49 | let project: Project; 50 | let task: Task | undefined; 51 | try { 52 | project = await taskManager.readProject(projectId); 53 | task = project.tasks.find((t: Task) => t.id === taskId); 54 | 55 | if (!task) { 56 | console.error(chalk.red(`Task ${chalk.bold(taskId)} not found in project ${chalk.bold(projectId)}.`)); 57 | console.log(chalk.yellow('Available tasks in this project:')); 58 | project.tasks.forEach((t: Task) => { 59 | console.log(` - ${t.id}: ${t.title} (Status: ${t.status}, Approved: ${t.approved ? 'Yes' : 'No'})`); 60 | }); 61 | process.exit(1); 62 | } 63 | } catch (error) { 64 | console.error(chalk.red(formatCliError(error as Error))); 65 | process.exit(1); 66 | } 67 | 68 | // Pre-check task status if not using force 69 | if (task.status !== "done" && !options.force) { 70 | console.error(chalk.red(`Task ${chalk.bold(taskId)} is not marked as done yet. Current status: ${chalk.bold(task.status)}`)); 71 | console.log(chalk.yellow(`Use the --force flag to attempt approval anyway (may fail if underlying logic prevents it), or wait for the task to be marked as done.`)); 72 | process.exit(1); 73 | } 74 | 75 | if (task.approved) { 76 | console.log(chalk.yellow(`Task ${chalk.bold(taskId)} is already approved.`)); 77 | process.exit(0); 78 | } 79 | 80 | // Attempt to approve the task 81 | const approvedTask = await taskManager.approveTaskCompletion(projectId, taskId); 82 | console.log(chalk.green(`✅ Task ${chalk.bold(taskId)} in project ${chalk.bold(projectId)} has been approved.`)); 83 | 84 | // Fetch updated project data for display 85 | const updatedProject = await taskManager.readProject(projectId); 86 | const updatedTask = updatedProject.tasks.find((t: Task) => t.id === taskId); 87 | 88 | // Show task info 89 | if (updatedTask) { 90 | console.log(chalk.cyan('\n📋 Task details:')); 91 | console.log(` - ${chalk.bold('Title:')} ${updatedTask.title}`); 92 | console.log(` - ${chalk.bold('Description:')} ${updatedTask.description}`); 93 | console.log(` - ${chalk.bold('Status:')} ${updatedTask.status === 'done' ? chalk.green('Done ✓') : updatedTask.status === 'in progress' ? chalk.yellow('In Progress ⟳') : chalk.blue('Not Started ○')}`); 94 | console.log(` - ${chalk.bold('Completed details:')} ${updatedTask.completedDetails || chalk.gray("None")}`); 95 | console.log(` - ${chalk.bold('Approved:')} ${updatedTask.approved ? chalk.green('Yes ✓') : chalk.red('No ✗')}`); 96 | if (updatedTask.toolRecommendations) { 97 | console.log(` - ${chalk.bold('Tool Recommendations:')} ${updatedTask.toolRecommendations}`); 98 | } 99 | if (updatedTask.ruleRecommendations) { 100 | console.log(` - ${chalk.bold('Rule Recommendations:')} ${updatedTask.ruleRecommendations}`); 101 | } 102 | } 103 | 104 | // Show progress info 105 | const totalTasks = updatedProject.tasks.length; 106 | const completedTasks = updatedProject.tasks.filter((t: Task) => t.status === "done").length; 107 | const approvedTasks = updatedProject.tasks.filter((t: Task) => t.approved).length; 108 | 109 | console.log(chalk.cyan(`\n📊 Progress: ${chalk.bold(`${approvedTasks}/${completedTasks}/${totalTasks}`)} (approved/completed/total)`)); 110 | 111 | // Create a progress bar 112 | const bar = '▓'.repeat(approvedTasks) + '▒'.repeat(completedTasks - approvedTasks) + '░'.repeat(totalTasks - completedTasks); 113 | console.log(` ${bar}`); 114 | 115 | if (completedTasks === totalTasks && approvedTasks === totalTasks) { 116 | console.log(chalk.green('\n🎉 All tasks are completed and approved!')); 117 | console.log(chalk.blue(`The project can now be finalized using: taskqueue finalize ${projectId}`)); 118 | } else { 119 | if (totalTasks - completedTasks > 0) { 120 | console.log(chalk.yellow(`\n${totalTasks - completedTasks} tasks remaining to be completed.`)); 121 | } 122 | if (completedTasks - approvedTasks > 0) { 123 | console.log(chalk.yellow(`${completedTasks - approvedTasks} tasks remaining to be approved.`)); 124 | } 125 | } 126 | } catch (error) { 127 | console.error(chalk.red(formatCliError(error as Error))); 128 | process.exit(1); 129 | } 130 | }); 131 | 132 | program 133 | .command("finalize") 134 | .description("Mark a project as complete") 135 | .argument("", "Project ID") 136 | .action(async (projectId) => { 137 | try { 138 | console.log(chalk.blue(`Attempting to finalize project ${chalk.bold(projectId)}...`)); 139 | 140 | // First, verify the project exists and get its details 141 | let project: Project; 142 | try { 143 | project = await taskManager.readProject(projectId); 144 | } catch (error) { 145 | console.error(chalk.red(formatCliError(error as Error))); 146 | process.exit(1); 147 | } 148 | 149 | // Pre-check project status 150 | if (project.completed) { 151 | console.log(chalk.yellow(`Project ${chalk.bold(projectId)} is already marked as completed.`)); 152 | process.exit(0); 153 | } 154 | 155 | // Pre-check task status (for better user feedback before attempting finalization) 156 | const allDone = project.tasks.every((t: Task) => t.status === "done"); 157 | if (!allDone) { 158 | console.error(chalk.red(`Not all tasks in project ${chalk.bold(projectId)} are marked as done.`)); 159 | console.log(chalk.yellow('\nPending tasks:')); 160 | project.tasks.filter((t: Task) => t.status !== "done").forEach((t: Task) => { 161 | console.log(` - ${chalk.bold(t.id)}: ${t.title} (Status: ${t.status})`); 162 | }); 163 | process.exit(1); 164 | } 165 | 166 | const allApproved = project.tasks.every((t: Task) => t.approved); 167 | if (!allApproved) { 168 | console.error(chalk.red(`Not all tasks in project ${chalk.bold(projectId)} are approved yet.`)); 169 | console.log(chalk.yellow('\nUnapproved tasks:')); 170 | project.tasks.filter((t: Task) => !t.approved).forEach((t: Task) => { 171 | console.log(` - ${chalk.bold(t.id)}: ${t.title}`); 172 | }); 173 | process.exit(1); 174 | } 175 | 176 | // Attempt to finalize the project 177 | await taskManager.approveProjectCompletion(projectId); 178 | console.log(chalk.green(`✅ Project ${chalk.bold(projectId)} has been approved and marked as complete.`)); 179 | 180 | // Fetch updated project data for display 181 | const updatedProject = await taskManager.readProject(projectId); 182 | 183 | // Show project info 184 | console.log(chalk.cyan('\n📋 Project details:')); 185 | console.log(` - ${chalk.bold('Initial Prompt:')} ${updatedProject.initialPrompt}`); 186 | if (updatedProject.projectPlan && updatedProject.projectPlan !== updatedProject.initialPrompt) { 187 | console.log(` - ${chalk.bold('Project Plan:')} ${updatedProject.projectPlan}`); 188 | } 189 | console.log(` - ${chalk.bold('Status:')} ${chalk.green('Completed ✓')}`); 190 | 191 | // Show progress info 192 | const totalTasks = updatedProject.tasks.length; 193 | const completedTasks = updatedProject.tasks.filter((t: Task) => t.status === "done").length; 194 | const approvedTasks = updatedProject.tasks.filter((t: Task) => t.approved).length; 195 | 196 | console.log(chalk.cyan(`\n📊 Final Progress: ${chalk.bold(`${approvedTasks}/${completedTasks}/${totalTasks}`)} (approved/completed/total)`)); 197 | 198 | // Create a progress bar 199 | const bar = '▓'.repeat(approvedTasks) + '▒'.repeat(completedTasks - approvedTasks) + '░'.repeat(totalTasks - completedTasks); 200 | console.log(` ${bar}`); 201 | 202 | console.log(chalk.green('\n🎉 Project successfully completed and approved!')); 203 | console.log(chalk.gray('You can view the project details anytime using:')); 204 | console.log(chalk.blue(` taskqueue list -p ${projectId}`)); 205 | 206 | } catch (error) { 207 | console.error(chalk.red(formatCliError(error as Error))); 208 | process.exit(1); 209 | } 210 | }); 211 | 212 | program 213 | .command("list") 214 | .description("List project summaries, or list tasks for a specific project") 215 | .option('-p, --project ', 'Show details and tasks for a specific project') 216 | .option('-s, --state ', "Filter by task/project state (open, pending_approval, completed, all)") 217 | .action(async (options) => { 218 | try { 219 | // Validate state option if provided 220 | const validStates = ['open', 'pending_approval', 'completed', 'all'] as const; 221 | const stateOption = options.state as TaskState | undefined | 'all'; 222 | if (stateOption && !validStates.includes(stateOption)) { 223 | console.error(chalk.red(`Invalid state value: ${options.state}`)); 224 | console.log(chalk.yellow(`Valid states are: ${validStates.join(', ')}`)); 225 | process.exit(1); 226 | } 227 | const filterState = (stateOption === 'all' || !stateOption) ? undefined : stateOption as TaskState; 228 | 229 | if (options.project) { 230 | // Show details for a specific project 231 | const projectId = options.project; 232 | try { 233 | const project = await taskManager.readProject(projectId); 234 | 235 | // Filter tasks based on state if provided 236 | const tasksToList = filterState 237 | ? project.tasks.filter((task: Task) => { 238 | if (filterState === 'open') return !task.approved; 239 | if (filterState === 'pending_approval') return task.status === 'done' && !task.approved; 240 | if (filterState === 'completed') return task.status === 'done' && task.approved; 241 | return true; // Should not happen 242 | }) 243 | : project.tasks; 244 | 245 | // Use the formatter for the progress table - it now includes the header 246 | const projectForTableDisplay = { ...project, tasks: tasksToList }; 247 | console.log(formatTaskProgressTable(projectForTableDisplay)); 248 | 249 | if (tasksToList.length === 0) { 250 | console.log(chalk.yellow(`\nNo tasks found${filterState ? ` matching state '${filterState}'` : ''} in project ${projectId}.`)); 251 | } else if (filterState) { 252 | console.log(chalk.dim(`(Filtered by state: ${filterState})`)); 253 | } 254 | 255 | } catch (error) { 256 | console.error(chalk.red(formatCliError(error as Error))); 257 | process.exit(1); 258 | } 259 | } else { 260 | // List all projects, potentially filtered 261 | const projects = await taskManager.listProjects(filterState); 262 | 263 | if (projects.projects.length === 0) { 264 | console.log(chalk.yellow(`No projects found${filterState ? ` matching state '${filterState}'` : ''}.`)); 265 | return; 266 | } 267 | 268 | // Use the formatter directly with the summary data 269 | console.log(chalk.cyan(formatProjectsList(projects.projects))); 270 | if (filterState) { 271 | console.log(chalk.dim(`(Filtered by state: ${filterState})`)); 272 | } 273 | } 274 | } catch (error) { 275 | console.error(chalk.red(formatCliError(error as Error))); 276 | process.exit(1); 277 | } 278 | }); 279 | 280 | program 281 | .command("generate-plan") 282 | .description("Generate a project plan using an LLM") 283 | .requiredOption("--prompt ", "Prompt text to feed to the LLM") 284 | .option("--model ", "LLM model to use", "gpt-4-turbo") 285 | .option("--provider ", "LLM provider to use (openai, google, or deepseek)", "openai") 286 | .option("--attachment ", "File to attach as context (can be specified multiple times)", collect, []) 287 | .action(async (options) => { 288 | try { 289 | console.log(chalk.blue(`Generating project plan from prompt...`)); 290 | 291 | // Pass attachment filenames directly to the server 292 | const result = await taskManager.generateProjectPlan({ 293 | prompt: options.prompt, 294 | provider: options.provider, 295 | model: options.model, 296 | attachments: options.attachment 297 | }); 298 | 299 | // Display the results 300 | console.log(chalk.green(`✅ Project plan generated successfully!`)); 301 | console.log(chalk.cyan('\n📋 Project details:')); 302 | console.log(` - ${chalk.bold('Project ID:')} ${result.projectId}`); 303 | console.log(` - ${chalk.bold('Total Tasks:')} ${result.totalTasks}`); 304 | 305 | console.log(chalk.cyan('\n📝 Tasks:')); 306 | result.tasks.forEach((task) => { 307 | console.log(`\n ${chalk.bold(task.id)}:`); 308 | console.log(` Title: ${task.title}`); 309 | console.log(` Description: ${task.description}`); 310 | }); 311 | 312 | if (result.message) { 313 | console.log(`\n${result.message}`); 314 | } 315 | } catch (error) { 316 | console.error(chalk.red(formatCliError(error as Error))); 317 | process.exit(1); 318 | } 319 | }); 320 | 321 | // Helper function for collecting multiple values for the same option 322 | function collect(value: string, previous: string[]) { 323 | return previous.concat([value]); 324 | } 325 | 326 | // Export program for testing purposes 327 | export { program }; -------------------------------------------------------------------------------- /src/client/errors.ts: -------------------------------------------------------------------------------- 1 | import { AppError } from "../types/errors.js"; 2 | 3 | /** 4 | * Formats an error message for CLI output 5 | */ 6 | export function formatCliError(error: Error): string { 7 | // Handle our custom file system errors by prefixing the error code 8 | if (error instanceof AppError) { 9 | let details = ''; 10 | if (error.details) { 11 | const detailsStr = typeof error.details === 'string' ? error.details : String(error.details); 12 | details = `\n-> Details: ${detailsStr.replace(/^AppError:\s*/, '')}`; 13 | } 14 | return `[${error.code}] ${error.message}${details}`; 15 | } 16 | 17 | // For unknown errors, just return the error message 18 | return error.message; 19 | } -------------------------------------------------------------------------------- /src/client/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { program } from './cli.js'; 4 | 5 | // Parse the command line arguments 6 | program.parse(process.argv); -------------------------------------------------------------------------------- /src/client/taskFormattingUtils.ts: -------------------------------------------------------------------------------- 1 | import Table from 'cli-table3'; // Import the library 2 | import chalk from 'chalk'; // Import chalk for consistent styling 3 | import { ListProjectsSuccessData } from "../types/response.js"; 4 | import { Project } from "../types/data.js"; 5 | 6 | /** 7 | * Formats the project details and a progress table for its tasks using cli-table3. 8 | * @param project - The project object containing the details and tasks. 9 | * @returns A string representing the formatted project details and task progress table. 10 | */ 11 | export function formatTaskProgressTable(project: Project | undefined): string { 12 | if (!project) return "Project not found"; 13 | 14 | // Build the project details header 15 | let header = chalk.cyan(`\n📋 Project ${chalk.bold(project.projectId)} details:\n`); 16 | header += ` - ${chalk.bold('Initial Prompt:')} ${project.initialPrompt}\n`; 17 | if (project.projectPlan && project.projectPlan !== project.initialPrompt) { 18 | header += ` - ${chalk.bold('Project Plan:')} ${project.projectPlan}\n`; 19 | } 20 | header += ` - ${chalk.bold('Status:')} ${project.completed ? chalk.green('Completed ✓') : chalk.yellow('In Progress')}\n`; 21 | 22 | 23 | const table = new Table({ 24 | head: ['ID', 'Title', 'Description', 'Status', 'Approved', 'Tools', 'Rules'], 25 | colWidths: [10, 25, 40, 15, 10, 7, 7], // Adjust widths as needed 26 | wordWrap: true, // Enable word wrapping for long descriptions 27 | style: { head: ['cyan'] } // Optional styling 28 | }); 29 | 30 | if (project.tasks.length === 0) { 31 | table.push([{ colSpan: 7, content: 'No tasks in this project.', hAlign: 'center' }]); 32 | } else { 33 | for (const task of project.tasks) { 34 | const statusText = task.status === "done" ? "Done" : (task.status === "in progress" ? "In Prog" : "Pending"); 35 | const approvedText = task.approved ? "Yes" : "No"; 36 | const toolsText = task.toolRecommendations ? "[+]" : "[-]"; // Simpler indicators 37 | const rulesText = task.ruleRecommendations ? "[+]" : "[-]"; 38 | // No need to manually truncate description if wordWrap is true and colWidths are set 39 | 40 | table.push([ 41 | task.id, 42 | task.title, 43 | task.description, 44 | statusText, 45 | approvedText, 46 | toolsText, 47 | rulesText 48 | ]); 49 | } 50 | } 51 | 52 | return header + '\n' + table.toString(); // Combine header and table 53 | } 54 | 55 | /** 56 | * Formats a list of project summaries into a markdown table using cli-table3. 57 | * @param projects - An array of project summary objects, matching the structure of ListProjectsSuccessData["projects"]. 58 | * @returns A string representing the formatted projects list table. 59 | */ 60 | export function formatProjectsList(projects: ListProjectsSuccessData["projects"]): string { 61 | 62 | const table = new Table({ 63 | head: ['Project ID', 'Initial Prompt', 'Total', 'Done', 'Approved'], 64 | colWidths: [15, 40, 8, 8, 10], // Adjust widths as needed 65 | wordWrap: true, 66 | style: { head: ['cyan'] } // Optional styling 67 | }); 68 | 69 | if (projects.length === 0) { 70 | table.push([{ colSpan: 5, content: 'No projects found.', hAlign: 'center' }]); 71 | } else { 72 | for (const proj of projects) { 73 | // Truncate long initial prompts manually if desired, even with wordWrap 74 | const shortPrompt = proj.initialPrompt.length > 60 ? proj.initialPrompt.substring(0, 57) + "..." : proj.initialPrompt; 75 | table.push([ 76 | proj.projectId, 77 | shortPrompt, // Use truncated prompt 78 | proj.totalTasks, 79 | proj.completedTasks, 80 | proj.approvedTasks 81 | ]); 82 | } 83 | } 84 | 85 | return '\nProjects List:\n' + table.toString(); 86 | } 87 | -------------------------------------------------------------------------------- /src/server/FileSystemService.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile, mkdir } from 'node:fs/promises'; 2 | import { dirname, join, resolve } from "node:path"; 3 | import { homedir } from "node:os"; 4 | import { AppError, AppErrorCode } from "../types/errors.js"; 5 | import { TaskManagerFile } from "../types/data.js"; 6 | import * as fs from 'node:fs'; 7 | 8 | export interface InitializedTaskData { 9 | data: TaskManagerFile; 10 | maxProjectId: number; 11 | maxTaskId: number; 12 | } 13 | 14 | export class FileSystemService { 15 | private filePath: string; 16 | private lockFilePath: string; 17 | 18 | constructor(filePath: string) { 19 | this.filePath = filePath; 20 | this.lockFilePath = `${filePath}.lock`; 21 | } 22 | 23 | /** 24 | * Gets the platform-appropriate app data directory 25 | */ 26 | public static getAppDataDir(): string { 27 | const platform = process.platform; 28 | 29 | if (platform === 'darwin') { 30 | // macOS: ~/Library/Application Support/taskqueue-mcp 31 | return join(homedir(), 'Library', 'Application Support', 'taskqueue-mcp'); 32 | } else if (platform === 'win32') { 33 | // Windows: %APPDATA%\taskqueue-mcp (usually C:\Users\\AppData\Roaming\taskqueue-mcp) 34 | return join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'taskqueue-mcp'); 35 | } else { 36 | // Linux/Unix/Others: Use XDG Base Directory if available, otherwise ~/.local/share/taskqueue-mcp 37 | const xdgDataHome = process.env.XDG_DATA_HOME; 38 | const linuxDefaultDir = join(homedir(), '.local', 'share', 'taskqueue-mcp'); 39 | return xdgDataHome ? join(xdgDataHome, 'taskqueue-mcp') : linuxDefaultDir; 40 | } 41 | } 42 | 43 | /** 44 | * Acquires a file system lock 45 | */ 46 | private async acquireLock(): Promise { 47 | while (true) { 48 | try { 49 | // Try to create lock file 50 | const fd = fs.openSync(this.lockFilePath, 'wx'); 51 | fs.closeSync(fd); 52 | return; 53 | } catch (error: any) { 54 | if (error.code === 'EEXIST') { 55 | // Lock file exists, wait and retry 56 | await new Promise(resolve => setTimeout(resolve, 100)); 57 | continue; 58 | } 59 | throw error; 60 | } 61 | } 62 | } 63 | 64 | /** 65 | * Releases the file system lock 66 | */ 67 | private async releaseLock(): Promise { 68 | try { 69 | await fs.promises.unlink(this.lockFilePath); 70 | } catch (error: any) { 71 | if (error.code !== 'ENOENT') { 72 | throw error; 73 | } 74 | } 75 | } 76 | 77 | /** 78 | * Execute a file operation with file system lock 79 | */ 80 | private async executeOperation(operation: () => Promise): Promise { 81 | await this.acquireLock(); 82 | try { 83 | return await operation(); 84 | } finally { 85 | await this.releaseLock(); 86 | } 87 | } 88 | 89 | /** 90 | * Loads and initializes task data from the JSON file 91 | */ 92 | public async loadAndInitializeTasks(): Promise { 93 | return this.executeOperation(async () => { 94 | const data = await this.loadTasks(); 95 | const { maxProjectId, maxTaskId } = this.calculateMaxIds(data); 96 | 97 | return { 98 | data, 99 | maxProjectId, 100 | maxTaskId 101 | }; 102 | }); 103 | } 104 | 105 | /** 106 | * Explicitly reloads task data from the disk 107 | */ 108 | public async reloadTasks(): Promise { 109 | return this.executeOperation(async () => { 110 | return this.loadTasks(); 111 | }); 112 | } 113 | 114 | /** 115 | * Calculate max IDs from task data 116 | */ 117 | public calculateMaxIds(data: TaskManagerFile): { maxProjectId: number; maxTaskId: number } { 118 | const allTaskIds: number[] = []; 119 | const allProjectIds: number[] = []; 120 | 121 | for (const proj of data.projects) { 122 | const projNum = Number.parseInt(proj.projectId.replace("proj-", ""), 10); 123 | if (!Number.isNaN(projNum)) { 124 | allProjectIds.push(projNum); 125 | } 126 | for (const t of proj.tasks) { 127 | const tNum = Number.parseInt(t.id.replace("task-", ""), 10); 128 | if (!Number.isNaN(tNum)) { 129 | allTaskIds.push(tNum); 130 | } 131 | } 132 | } 133 | 134 | return { 135 | maxProjectId: allProjectIds.length > 0 ? Math.max(...allProjectIds) : 0, 136 | maxTaskId: allTaskIds.length > 0 ? Math.max(...allTaskIds) : 0 137 | }; 138 | } 139 | 140 | /** 141 | * Loads raw task data from the JSON file 142 | */ 143 | private async loadTasks(): Promise { 144 | try { 145 | const data = await readFile(this.filePath, "utf-8"); 146 | return JSON.parse(data); 147 | } catch (error) { 148 | if (error instanceof Error) { 149 | if (error.message.includes('ENOENT')) { 150 | // If file doesn't exist, return empty data 151 | return { projects: [] }; 152 | } 153 | throw new AppError(`Failed to read tasks file: ${error.message}`, AppErrorCode.FileReadError, error); 154 | } 155 | throw new AppError('Unknown error reading tasks file', AppErrorCode.FileReadError, error); 156 | } 157 | } 158 | 159 | /** 160 | * Saves task data to the JSON file with file system lock 161 | */ 162 | public async saveTasks(data: TaskManagerFile): Promise { 163 | return this.executeOperation(async () => { 164 | try { 165 | // Ensure directory exists before writing 166 | const dir = dirname(this.filePath); 167 | await mkdir(dir, { recursive: true }); 168 | 169 | // Write to the file 170 | await writeFile( 171 | this.filePath, 172 | JSON.stringify(data, null, 2), 173 | "utf-8" 174 | ); 175 | } catch (error) { 176 | if (error instanceof Error && error.message.includes("EROFS")) { 177 | throw new AppError("Cannot save tasks: read-only file system", AppErrorCode.ReadOnlyFileSystem, error); 178 | } 179 | throw new AppError("Failed to save tasks file", AppErrorCode.FileWriteError, error); 180 | } 181 | }); 182 | } 183 | 184 | /** 185 | * Reads an attachment file from the current working directory 186 | * @param filename The name of the file to read (relative to cwd) 187 | * @returns The contents of the file as a string 188 | * @throws {FileReadError} If the file cannot be read 189 | */ 190 | public async readAttachmentFile(filename: string): Promise { 191 | try { 192 | const filePath = resolve(process.cwd(), filename); 193 | return await readFile(filePath, 'utf-8'); 194 | } catch (error) { 195 | if (error instanceof Error && error.message.includes('ENOENT')) { 196 | throw new AppError(`Attachment file not found: ${filename}`, AppErrorCode.FileReadError, error); 197 | } 198 | throw new AppError(`Failed to read attachment file: ${filename}`, AppErrorCode.FileReadError, error); 199 | } 200 | } 201 | } -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 4 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 5 | import { TaskManager } from "./TaskManager.js"; 6 | import { ALL_TOOLS, executeToolAndHandleErrors } from "./tools.js"; 7 | import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js"; 8 | 9 | // Create server with capabilities BEFORE setting up handlers 10 | const server = new Server( 11 | { 12 | name: "task-manager-server", 13 | version: "1.4.1" 14 | }, 15 | { 16 | capabilities: { 17 | tools: { 18 | list: true, 19 | call: true 20 | } 21 | } 22 | } 23 | ); 24 | 25 | // Create task manager instance 26 | const taskManager = new TaskManager(); 27 | 28 | // Set up request handlers AFTER capabilities are configured 29 | server.setRequestHandler(ListToolsRequestSchema, async () => { 30 | return { 31 | tools: ALL_TOOLS 32 | }; 33 | }); 34 | 35 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 36 | // Directly call the handler. It either returns a result object (success or isError:true) 37 | // OR it throws a tagged protocol error. 38 | return await executeToolAndHandleErrors( 39 | request.params.name, 40 | request.params.arguments || {}, 41 | taskManager 42 | ); 43 | // SDK automatically handles: 44 | // - Wrapping the returned value (success data or isError:true object) in `result: { ... }` 45 | // - Catching re-thrown protocol errors and formatting the top-level `error: { ... }` 46 | }); 47 | 48 | // Start the server 49 | const transport = new StdioServerTransport(); 50 | server.connect(transport); 51 | -------------------------------------------------------------------------------- /src/server/toolExecutors.ts: -------------------------------------------------------------------------------- 1 | import { TaskManager } from "./TaskManager.js"; 2 | import { AppError, AppErrorCode } from "../types/errors.js"; 3 | 4 | /** 5 | * Interface defining the contract for tool executors. 6 | * Each tool executor is responsible for executing a specific tool's logic 7 | * and handling its input validation. 8 | */ 9 | interface ToolExecutor { 10 | /** The name of the tool this executor handles */ 11 | name: string; 12 | 13 | /** 14 | * Executes the tool's logic with the given arguments 15 | * @param taskManager The TaskManager instance to use for task-related operations 16 | * @param args The arguments passed to the tool as a key-value record 17 | * @returns A promise that resolves to the raw data from TaskManager 18 | */ 19 | execute: ( 20 | taskManager: TaskManager, 21 | args: Record 22 | ) => Promise; 23 | } 24 | 25 | // ---------------------- UTILITY FUNCTIONS ---------------------- 26 | 27 | /** 28 | * Throws an AppError if a required parameter is not present or not a string. 29 | */ 30 | function validateRequiredStringParam(param: unknown, paramName: string): string { 31 | if (typeof param !== "string" || !param) { 32 | throw new AppError( 33 | `Invalid or missing required parameter: ${paramName} (Expected string)`, 34 | AppErrorCode.MissingParameter 35 | ); 36 | } 37 | return param; 38 | } 39 | 40 | /** 41 | * Validates that a project ID parameter exists and is a string. 42 | */ 43 | function validateProjectId(projectId: unknown): string { 44 | return validateRequiredStringParam(projectId, "projectId"); 45 | } 46 | 47 | /** 48 | * Validates that a task ID parameter exists and is a string. 49 | */ 50 | function validateTaskId(taskId: unknown): string { 51 | return validateRequiredStringParam(taskId, "taskId"); 52 | } 53 | 54 | /** 55 | * Throws an AppError if tasks is not defined or not an array. 56 | */ 57 | function validateTaskList(tasks: unknown): void { 58 | if (!Array.isArray(tasks)) { 59 | throw new AppError( 60 | "Invalid or missing required parameter: tasks (Expected array)", 61 | AppErrorCode.InvalidArgument 62 | ); 63 | } 64 | } 65 | 66 | /** 67 | * Validates an optional "state" parameter against the allowed states. 68 | */ 69 | function validateOptionalStateParam( 70 | state: unknown, 71 | validStates: Array 72 | ): string | undefined { 73 | if (state === undefined) return undefined; 74 | if (typeof state === "string" && validStates.includes(state)) return state; 75 | throw new AppError( 76 | `Invalid state parameter. Must be one of: ${validStates.join(", ")}`, 77 | AppErrorCode.InvalidState 78 | ); 79 | } 80 | 81 | /** 82 | * Validates an array of task objects, ensuring each has required fields. 83 | */ 84 | function validateTaskObjects( 85 | tasks: unknown, 86 | errorPrefix?: string 87 | ): Array<{ 88 | title: string; 89 | description: string; 90 | toolRecommendations?: string; 91 | ruleRecommendations?: string; 92 | }> { 93 | validateTaskList(tasks); 94 | const taskArray = tasks as Array; 95 | 96 | return taskArray.map((task, index) => { 97 | if (!task || typeof task !== "object") { 98 | throw new AppError( 99 | `${errorPrefix || "Task"} at index ${index} must be an object`, 100 | AppErrorCode.InvalidArgument 101 | ); 102 | } 103 | 104 | const t = task as Record; 105 | const title = validateRequiredStringParam(t.title, `title in task at index ${index}`); 106 | const description = validateRequiredStringParam(t.description, `description in task at index ${index}`); 107 | 108 | return { 109 | title, 110 | description, 111 | toolRecommendations: t.toolRecommendations ? String(t.toolRecommendations) : undefined, 112 | ruleRecommendations: t.ruleRecommendations ? String(t.ruleRecommendations) : undefined, 113 | }; 114 | }); 115 | } 116 | 117 | // ---------------------- TOOL EXECUTOR MAP ---------------------- 118 | 119 | export const toolExecutorMap: Map = new Map(); 120 | 121 | // ---------------------- TOOL EXECUTORS ---------------------- 122 | 123 | /** 124 | * Tool executor for listing projects with optional state filtering 125 | */ 126 | const listProjectsToolExecutor: ToolExecutor = { 127 | name: "list_projects", 128 | async execute(taskManager, args) { 129 | // 1. Argument Validation 130 | const state = validateOptionalStateParam(args.state, [ 131 | "open", 132 | "pending_approval", 133 | "completed", 134 | "all", 135 | ]); 136 | 137 | // 2. Core Logic Execution 138 | const resultData = await taskManager.listProjects(state as any); 139 | 140 | // 3. Return raw success data 141 | return resultData; 142 | }, 143 | }; 144 | toolExecutorMap.set(listProjectsToolExecutor.name, listProjectsToolExecutor); 145 | 146 | /** 147 | * Tool executor for creating new projects with tasks 148 | */ 149 | const createProjectToolExecutor: ToolExecutor = { 150 | name: "create_project", 151 | async execute(taskManager, args) { 152 | const initialPrompt = validateRequiredStringParam(args.initialPrompt, "initialPrompt"); 153 | const validatedTasks = validateTaskObjects(args.tasks); 154 | const projectPlan = args.projectPlan !== undefined ? String(args.projectPlan) : undefined; 155 | const autoApprove = args.autoApprove as boolean | undefined; 156 | 157 | if (args.projectPlan !== undefined && typeof args.projectPlan !== 'string') { 158 | throw new AppError( 159 | "Invalid type for optional parameter 'projectPlan' (Expected string)", 160 | AppErrorCode.InvalidArgument 161 | ); 162 | } 163 | if (args.autoApprove !== undefined && typeof args.autoApprove !== 'boolean') { 164 | throw new AppError( 165 | "Invalid type for optional parameter 'autoApprove' (Expected boolean)", 166 | AppErrorCode.InvalidArgument 167 | ); 168 | } 169 | 170 | const resultData = await taskManager.createProject( 171 | initialPrompt, 172 | validatedTasks, 173 | projectPlan, 174 | autoApprove 175 | ); 176 | 177 | return resultData; 178 | }, 179 | }; 180 | toolExecutorMap.set(createProjectToolExecutor.name, createProjectToolExecutor); 181 | 182 | /** 183 | * Tool executor for generating project plans using an LLM 184 | */ 185 | const generateProjectPlanToolExecutor: ToolExecutor = { 186 | name: "generate_project_plan", 187 | async execute(taskManager, args) { 188 | // 1. Argument Validation 189 | const prompt = validateRequiredStringParam(args.prompt, "prompt"); 190 | const provider = validateRequiredStringParam(args.provider, "provider"); 191 | const model = validateRequiredStringParam(args.model, "model"); 192 | 193 | // Validate optional attachments 194 | let attachments: string[] = []; 195 | if (args.attachments !== undefined) { 196 | if (!Array.isArray(args.attachments)) { 197 | throw new AppError( 198 | "Invalid attachments: must be an array of strings", 199 | AppErrorCode.InvalidArgument 200 | ); 201 | } 202 | attachments = args.attachments.map((att, index) => { 203 | if (typeof att !== "string") { 204 | throw new AppError( 205 | `Invalid attachment at index ${index}: must be a string`, 206 | AppErrorCode.InvalidArgument 207 | ); 208 | } 209 | return att; 210 | }); 211 | } 212 | 213 | // 2. Core Logic Execution 214 | const resultData = await taskManager.generateProjectPlan({ 215 | prompt, 216 | provider, 217 | model, 218 | attachments, 219 | }); 220 | 221 | // 3. Return raw success data 222 | return resultData; 223 | }, 224 | }; 225 | toolExecutorMap.set(generateProjectPlanToolExecutor.name, generateProjectPlanToolExecutor); 226 | 227 | /** 228 | * Tool executor for getting the next task in a project 229 | */ 230 | const getNextTaskToolExecutor: ToolExecutor = { 231 | name: "get_next_task", 232 | async execute(taskManager, args) { 233 | // 1. Argument Validation 234 | const projectId = validateProjectId(args.projectId); 235 | 236 | // 2. Core Logic Execution 237 | const resultData = await taskManager.getNextTask(projectId); 238 | 239 | // 3. Return raw success data 240 | return resultData; 241 | }, 242 | }; 243 | toolExecutorMap.set(getNextTaskToolExecutor.name, getNextTaskToolExecutor); 244 | 245 | /** 246 | * Tool executor for updating a task 247 | */ 248 | const updateTaskToolExecutor: ToolExecutor = { 249 | name: "update_task", 250 | async execute(taskManager, args) { 251 | const projectId = validateProjectId(args.projectId); 252 | const taskId = validateTaskId(args.taskId); 253 | const updates: Record = {}; 254 | 255 | if (args.title !== undefined) { 256 | updates.title = validateRequiredStringParam(args.title, "title"); 257 | } 258 | if (args.description !== undefined) { 259 | updates.description = validateRequiredStringParam(args.description, "description"); 260 | } 261 | if (args.toolRecommendations !== undefined) { 262 | if (typeof args.toolRecommendations !== "string") { 263 | throw new AppError( 264 | "Invalid toolRecommendations: must be a string", 265 | AppErrorCode.InvalidArgument 266 | ); 267 | } 268 | updates.toolRecommendations = args.toolRecommendations; 269 | } 270 | if (args.ruleRecommendations !== undefined) { 271 | if (typeof args.ruleRecommendations !== "string") { 272 | throw new AppError( 273 | "Invalid ruleRecommendations: must be a string", 274 | AppErrorCode.InvalidArgument 275 | ); 276 | } 277 | updates.ruleRecommendations = args.ruleRecommendations; 278 | } 279 | 280 | if (args.status !== undefined) { 281 | const status = args.status; 282 | if ( 283 | typeof status !== "string" || 284 | !["not started", "in progress", "done"].includes(status) 285 | ) { 286 | throw new AppError( 287 | "Invalid status: must be one of 'not started', 'in progress', 'done'", 288 | AppErrorCode.InvalidArgument 289 | ); 290 | } 291 | if (status === "done") { 292 | updates.completedDetails = validateRequiredStringParam( 293 | args.completedDetails, 294 | "completedDetails (required when status = 'done')" 295 | ); 296 | } 297 | updates.status = status; 298 | } 299 | 300 | const resultData = await taskManager.updateTask(projectId, taskId, updates); 301 | return resultData; 302 | }, 303 | }; 304 | toolExecutorMap.set(updateTaskToolExecutor.name, updateTaskToolExecutor); 305 | 306 | /** 307 | * Tool executor for reading project details 308 | */ 309 | const readProjectToolExecutor: ToolExecutor = { 310 | name: "read_project", 311 | async execute(taskManager, args) { 312 | // 1. Argument Validation 313 | const projectId = validateProjectId(args.projectId); 314 | 315 | // 2. Core Logic Execution 316 | const resultData = await taskManager.readProject(projectId); 317 | 318 | // 3. Return raw success data 319 | return resultData; 320 | }, 321 | }; 322 | toolExecutorMap.set(readProjectToolExecutor.name, readProjectToolExecutor); 323 | 324 | /** 325 | * Tool executor for deleting projects 326 | */ 327 | const deleteProjectToolExecutor: ToolExecutor = { 328 | name: "delete_project", 329 | async execute(taskManager, args) { 330 | const projectId = validateProjectId(args.projectId); 331 | 332 | const projectIndex = taskManager["data"].projects.findIndex( 333 | (p) => p.projectId === projectId 334 | ); 335 | if (projectIndex === -1) { 336 | throw new AppError( 337 | `Project not found: ${projectId}`, 338 | AppErrorCode.ProjectNotFound 339 | ); 340 | } 341 | 342 | taskManager["data"].projects.splice(projectIndex, 1); 343 | await taskManager["saveTasks"](); 344 | 345 | return { 346 | status: "project_deleted", 347 | message: `Project ${projectId} has been deleted.`, 348 | }; 349 | }, 350 | }; 351 | toolExecutorMap.set(deleteProjectToolExecutor.name, deleteProjectToolExecutor); 352 | 353 | /** 354 | * Tool executor for adding tasks to a project 355 | */ 356 | const addTasksToProjectToolExecutor: ToolExecutor = { 357 | name: "add_tasks_to_project", 358 | async execute(taskManager, args) { 359 | // 1. Argument Validation 360 | const projectId = validateProjectId(args.projectId); 361 | const tasks = validateTaskObjects(args.tasks); 362 | 363 | // 2. Core Logic Execution 364 | const resultData = await taskManager.addTasksToProject(projectId, tasks); 365 | 366 | // 3. Return raw success data 367 | return resultData; 368 | }, 369 | }; 370 | toolExecutorMap.set(addTasksToProjectToolExecutor.name, addTasksToProjectToolExecutor); 371 | 372 | /** 373 | * Tool executor for finalizing (completing) projects 374 | */ 375 | const finalizeProjectToolExecutor: ToolExecutor = { 376 | name: "finalize_project", 377 | async execute(taskManager, args) { 378 | // 1. Argument Validation 379 | const projectId = validateProjectId(args.projectId); 380 | 381 | // 2. Core Logic Execution 382 | const resultData = await taskManager.approveProjectCompletion(projectId); 383 | 384 | // 3. Return raw success data 385 | return resultData; 386 | }, 387 | }; 388 | toolExecutorMap.set(finalizeProjectToolExecutor.name, finalizeProjectToolExecutor); 389 | 390 | /** 391 | * Tool executor for listing tasks with optional projectId and state 392 | */ 393 | const listTasksToolExecutor: ToolExecutor = { 394 | name: "list_tasks", 395 | async execute(taskManager, args) { 396 | // 1. Argument Validation 397 | const projectId = args.projectId !== undefined ? validateProjectId(args.projectId) : undefined; 398 | const state = validateOptionalStateParam(args.state, [ 399 | "open", 400 | "pending_approval", 401 | "completed", 402 | "all", 403 | ]); 404 | 405 | // 2. Core Logic Execution 406 | const resultData = await taskManager.listTasks(projectId, state as any); 407 | 408 | // 3. Return raw success data 409 | return resultData; 410 | }, 411 | }; 412 | toolExecutorMap.set(listTasksToolExecutor.name, listTasksToolExecutor); 413 | 414 | /** 415 | * Tool executor for reading task details 416 | */ 417 | const readTaskToolExecutor: ToolExecutor = { 418 | name: "read_task", 419 | async execute(taskManager, args) { 420 | // 1. Argument Validation 421 | const projectId = validateProjectId(args.projectId); 422 | const taskId = validateTaskId(args.taskId); 423 | 424 | // 2. Core Logic Execution 425 | const resultData = await taskManager.openTaskDetails(projectId, taskId); 426 | 427 | // 3. Return raw success data 428 | return resultData; 429 | }, 430 | }; 431 | toolExecutorMap.set(readTaskToolExecutor.name, readTaskToolExecutor); 432 | 433 | /** 434 | * Tool executor for creating an individual task in a project 435 | */ 436 | const createTaskToolExecutor: ToolExecutor = { 437 | name: "create_task", 438 | async execute(taskManager, args) { 439 | const projectId = validateProjectId(args.projectId); 440 | const title = validateRequiredStringParam(args.title, "title"); 441 | const description = validateRequiredStringParam(args.description, "description"); 442 | 443 | if (args.toolRecommendations !== undefined && typeof args.toolRecommendations !== "string") { 444 | throw new AppError( 445 | "Invalid type for optional parameter 'toolRecommendations' (Expected string)", 446 | AppErrorCode.InvalidArgument 447 | ); 448 | } 449 | if (args.ruleRecommendations !== undefined && typeof args.ruleRecommendations !== "string") { 450 | throw new AppError( 451 | "Invalid type for optional parameter 'ruleRecommendations' (Expected string)", 452 | AppErrorCode.InvalidArgument 453 | ); 454 | } 455 | 456 | const singleTask = { 457 | title, 458 | description, 459 | toolRecommendations: args.toolRecommendations ? String(args.toolRecommendations) : undefined, 460 | ruleRecommendations: args.ruleRecommendations ? String(args.ruleRecommendations) : undefined, 461 | }; 462 | 463 | const resultData = await taskManager.addTasksToProject(projectId, [singleTask]); 464 | return resultData; 465 | }, 466 | }; 467 | toolExecutorMap.set(createTaskToolExecutor.name, createTaskToolExecutor); 468 | 469 | /** 470 | * Tool executor for deleting tasks 471 | */ 472 | const deleteTaskToolExecutor: ToolExecutor = { 473 | name: "delete_task", 474 | async execute(taskManager, args) { 475 | // 1. Argument Validation 476 | const projectId = validateProjectId(args.projectId); 477 | const taskId = validateTaskId(args.taskId); 478 | 479 | // 2. Core Logic Execution 480 | const resultData = await taskManager.deleteTask(projectId, taskId); 481 | 482 | // 3. Return raw success data 483 | return resultData; 484 | }, 485 | }; 486 | toolExecutorMap.set(deleteTaskToolExecutor.name, deleteTaskToolExecutor); 487 | 488 | /** 489 | * Tool executor for approving completed tasks 490 | */ 491 | const approveTaskToolExecutor: ToolExecutor = { 492 | name: "approve_task", 493 | async execute(taskManager, args) { 494 | // 1. Argument Validation 495 | const projectId = validateProjectId(args.projectId); 496 | const taskId = validateTaskId(args.taskId); 497 | 498 | // 2. Core Logic Execution 499 | const resultData = await taskManager.approveTaskCompletion(projectId, taskId); 500 | 501 | // 3. Return raw success data 502 | return resultData; 503 | }, 504 | }; 505 | toolExecutorMap.set(approveTaskToolExecutor.name, approveTaskToolExecutor); -------------------------------------------------------------------------------- /src/types/data.ts: -------------------------------------------------------------------------------- 1 | // Task and Project Interfaces 2 | export interface Task { 3 | id: string; 4 | title: string; 5 | description: string; 6 | status: "not started" | "in progress" | "done"; 7 | approved: boolean; 8 | completedDetails: string; 9 | toolRecommendations?: string; 10 | ruleRecommendations?: string; 11 | } 12 | 13 | export interface Project { 14 | projectId: string; 15 | initialPrompt: string; 16 | projectPlan: string; 17 | tasks: Task[]; 18 | completed: boolean; 19 | autoApprove?: boolean; 20 | } 21 | 22 | export interface TaskManagerFile { 23 | projects: Project[]; 24 | } 25 | 26 | // Define valid task status transitions 27 | export const VALID_STATUS_TRANSITIONS = { 28 | "not started": ["in progress"], 29 | "in progress": ["done", "not started"], 30 | "done": ["in progress"] 31 | } as const; 32 | 33 | export type TaskState = "open" | "pending_approval" | "completed" | "all"; 34 | -------------------------------------------------------------------------------- /src/types/errors.ts: -------------------------------------------------------------------------------- 1 | // Error Codes 2 | export enum AppErrorCode { 3 | // Protocol Errors (ERR_1xxx) 4 | MissingParameter = 'ERR_1000', // General missing param (mapped to protocol -32602) 5 | InvalidArgument = 'ERR_1002', // Extra param / invalid type (mapped to protocol -32602) 6 | 7 | // Validation / Resource Not Found (ERR_2xxx) 8 | ConfigurationError = 'ERR_2000', // e.g., Missing API Key for generate_project_plan 9 | ProjectNotFound = 'ERR_2001', 10 | TaskNotFound = 'ERR_2002', 11 | InvalidState = 'ERR_2003', // e.g., invalid state filter 12 | InvalidProvider = 'ERR_2004', // e.g., invalid model provider 13 | InvalidModel = 'ERR_2005', // e.g., invalid model name or model not accessible 14 | 15 | // No need for EmptyTaskFile code, handle during load 16 | 17 | // Business Logic / State Rules (ERR_3xxx) 18 | TaskNotDone = 'ERR_3000', // Cannot approve/finalize if task not done 19 | ProjectAlreadyCompleted = 'ERR_3001', 20 | // No need for CannotDeleteCompletedTask, handle in logic 21 | TasksNotAllDone = 'ERR_3003', // Cannot finalize project 22 | TasksNotAllApproved = 'ERR_3004', // Cannot finalize project 23 | CannotModifyApprovedTask = 'ERR_3005', // Added for clarity 24 | TaskAlreadyApproved = 'ERR_3006', // Added for clarity 25 | 26 | // File System (ERR_4xxx) 27 | FileReadError = 'ERR_4000', // Includes not found, permission denied etc. 28 | FileWriteError = 'ERR_4001', 29 | FileParseError = 'ERR_4002', // If needed during JSON parsing 30 | ReadOnlyFileSystem = 'ERR_4003', 31 | 32 | // LLM Interaction Errors (ERR_5xxx) 33 | LLMGenerationError = 'ERR_5000', 34 | LLMConfigurationError = 'ERR_5001', // Auth, key issues specifically with LLM provider call 35 | 36 | // Unknown / Catch-all (ERR_9xxx) 37 | Unknown = 'ERR_9999' 38 | } 39 | 40 | // Add a base AppError class 41 | export class AppError extends Error { 42 | public readonly code: AppErrorCode; 43 | public readonly details?: unknown; 44 | 45 | constructor(message: string, code: AppErrorCode, details?: unknown) { 46 | super(message); 47 | this.name = this.constructor.name; // Set name to the specific error class name 48 | this.code = code; 49 | this.details = details; 50 | 51 | // Fix prototype chain for instanceof to work correctly 52 | Object.setPrototypeOf(this, AppError.prototype); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/types/response.ts: -------------------------------------------------------------------------------- 1 | import { Task } from "./data.js"; 2 | 3 | // Define the structure for createProject success data 4 | export interface ProjectCreationSuccessData { 5 | projectId: string; 6 | totalTasks: number; 7 | tasks: Array<{ id: string; title: string; description: string }>; 8 | message: string; 9 | } 10 | 11 | // --- Success Data Interfaces --- 12 | 13 | export interface ApproveTaskSuccessData { 14 | projectId: string; 15 | task: { 16 | id: string; 17 | title: string; 18 | description: string; 19 | completedDetails: string; 20 | approved: boolean; 21 | }; 22 | } 23 | 24 | export interface ApproveProjectSuccessData { 25 | projectId: string; 26 | message: string; 27 | } 28 | 29 | export interface OpenTaskSuccessData { 30 | projectId: string; 31 | task: Task; 32 | } 33 | 34 | export interface ListProjectsSuccessData { 35 | message: string; 36 | projects: Array<{ 37 | projectId: string; 38 | initialPrompt: string; 39 | totalTasks: number; 40 | completedTasks: number; 41 | approvedTasks: number; 42 | }>; 43 | } 44 | 45 | export interface ListTasksSuccessData { 46 | message: string; 47 | tasks: Task[]; // Use the full Task type 48 | } 49 | 50 | export interface AddTasksSuccessData { 51 | message: string; 52 | newTasks: Array<{ id: string; title: string; description: string }>; 53 | } 54 | 55 | export interface DeleteTaskSuccessData { 56 | message: string; 57 | } 58 | 59 | export interface ReadProjectSuccessData { 60 | projectId: string; 61 | initialPrompt: string; 62 | projectPlan: string; 63 | completed: boolean; 64 | autoApprove?: boolean; 65 | tasks: Task[]; 66 | } 67 | 68 | // Add the new interface for update_task success 69 | export interface UpdateTaskSuccessData { 70 | task: Task; // The updated task object 71 | message?: string; // Optional message (e.g., approval reminder) 72 | } 73 | -------------------------------------------------------------------------------- /tests/cli/cli.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | import * as fs from "node:fs/promises"; 3 | import * as path from "node:path"; 4 | import * as os from "node:os"; 5 | import { promisify } from "util"; 6 | 7 | const execAsync = promisify(exec); 8 | const CLI_PATH = path.resolve(process.cwd(), "dist/src/client/index.js"); 9 | 10 | describe("CLI Integration Tests", () => { 11 | let tempDir: string; 12 | let tasksFilePath: string; 13 | 14 | beforeEach(async () => { 15 | tempDir = path.join(os.tmpdir(), `taskqueue-test-${Date.now()}`); 16 | await fs.mkdir(tempDir, { recursive: true }); 17 | tasksFilePath = path.join(tempDir, "test-tasks.json"); 18 | process.env.TASK_MANAGER_FILE_PATH = tasksFilePath; 19 | 20 | // Create initial task file with projects in different states 21 | const initialTasks = { 22 | projects: [ 23 | { 24 | projectId: "proj-1", 25 | initialPrompt: "open project", 26 | projectPlan: "test", 27 | completed: false, 28 | tasks: [ 29 | { 30 | id: "task-1", 31 | title: "open task", 32 | description: "test", 33 | status: "not started", 34 | approved: false, 35 | completedDetails: "" 36 | } 37 | ] 38 | }, 39 | { 40 | projectId: "proj-2", 41 | initialPrompt: "pending approval project", 42 | projectPlan: "test", 43 | completed: false, 44 | tasks: [ 45 | { 46 | id: "task-2", 47 | title: "pending approval task", 48 | description: "test", 49 | status: "done", 50 | approved: false, 51 | completedDetails: "completed" 52 | } 53 | ] 54 | }, 55 | { 56 | projectId: "proj-3", 57 | initialPrompt: "completed project", 58 | projectPlan: "test", 59 | completed: true, 60 | tasks: [ 61 | { 62 | id: "task-3", 63 | title: "completed task", 64 | description: "test", 65 | status: "done", 66 | approved: true, 67 | completedDetails: "completed" 68 | } 69 | ] 70 | } 71 | ] 72 | }; 73 | await fs.writeFile(tasksFilePath, JSON.stringify(initialTasks)); 74 | }); 75 | 76 | afterEach(async () => { 77 | await fs.rm(tempDir, { recursive: true, force: true }); 78 | delete process.env.TASK_MANAGER_FILE_PATH; 79 | }); 80 | 81 | it("should list only open projects via CLI", async () => { 82 | const { stdout } = await execAsync(`TASK_MANAGER_FILE_PATH=${tasksFilePath} tsx ${CLI_PATH} list -s open`); 83 | expect(stdout).toContain("proj-1"); 84 | expect(stdout).toContain("proj-2"); 85 | expect(stdout).not.toContain("proj-3"); 86 | }, 5000); 87 | 88 | it("should list only pending approval projects via CLI", async () => { 89 | const { stdout } = await execAsync(`TASK_MANAGER_FILE_PATH=${tasksFilePath} tsx ${CLI_PATH} list -s pending_approval`); 90 | expect(stdout).toContain("proj-2"); 91 | expect(stdout).not.toContain("proj-1"); 92 | expect(stdout).not.toContain("proj-3"); 93 | }, 5000); 94 | 95 | it("should list only completed projects via CLI", async () => { 96 | const { stdout } = await execAsync(`TASK_MANAGER_FILE_PATH=${tasksFilePath} tsx ${CLI_PATH} list -s completed`); 97 | expect(stdout).toContain("proj-3"); 98 | expect(stdout).not.toContain("proj-1"); 99 | expect(stdout).not.toContain("proj-2"); 100 | }, 5000); 101 | 102 | it("should list all projects when no state is specified", async () => { 103 | const { stdout } = await execAsync(`TASK_MANAGER_FILE_PATH=${tasksFilePath} tsx ${CLI_PATH} list`); 104 | expect(stdout).toContain("proj-1"); 105 | expect(stdout).toContain("proj-2"); 106 | expect(stdout).toContain("proj-3"); 107 | }, 5000); 108 | 109 | it("should list tasks for a specific project filtered by state", async () => { 110 | // Test open tasks 111 | const openResult = await execAsync(`TASK_MANAGER_FILE_PATH=${tasksFilePath} tsx ${CLI_PATH} list -p proj-1 -s open`); 112 | expect(openResult.stdout).toContain("task-1"); 113 | expect(openResult.stdout).not.toContain("task-2"); 114 | expect(openResult.stdout).not.toContain("task-3"); 115 | 116 | // Test pending approval tasks 117 | const pendingResult = await execAsync(`TASK_MANAGER_FILE_PATH=${tasksFilePath} tsx ${CLI_PATH} list -p proj-2 -s pending_approval`); 118 | expect(pendingResult.stdout).toContain("task-2"); 119 | expect(pendingResult.stdout).not.toContain("task-1"); 120 | expect(pendingResult.stdout).not.toContain("task-3"); 121 | 122 | // Test completed tasks 123 | const completedResult = await execAsync(`TASK_MANAGER_FILE_PATH=${tasksFilePath} tsx ${CLI_PATH} list -p proj-3 -s completed`); 124 | expect(completedResult.stdout).toContain("task-3"); 125 | expect(completedResult.stdout).not.toContain("task-1"); 126 | expect(completedResult.stdout).not.toContain("task-2"); 127 | }, 5000); 128 | 129 | it("should handle no matching items gracefully", async () => { 130 | // Test no matching projects with open state 131 | const { stdout: noProjects } = await execAsync(`TASK_MANAGER_FILE_PATH=${tasksFilePath} tsx ${CLI_PATH} list -s open -p proj-3`); 132 | expect(noProjects).toContain("No tasks found matching state 'open' in project proj-3"); 133 | 134 | // Test no matching tasks with completed state 135 | const { stdout: noTasks } = await execAsync(`TASK_MANAGER_FILE_PATH=${tasksFilePath} tsx ${CLI_PATH} list -s completed -p proj-1`); 136 | expect(noTasks).toContain("No tasks found matching state 'completed' in project proj-1"); 137 | }, 5000); 138 | 139 | describe("generate-plan command", () => { 140 | beforeEach(() => { 141 | // Set mock API keys for testing 142 | process.env.OPENAI_API_KEY = 'test-key'; 143 | process.env.GOOGLE_GENERATIVE_AI_API_KEY = 'test-key'; 144 | process.env.DEEPSEEK_API_KEY = 'test-key'; 145 | }); 146 | 147 | afterEach(() => { 148 | delete process.env.OPENAI_API_KEY; 149 | delete process.env.GOOGLE_GENERATIVE_AI_API_KEY; 150 | delete process.env.DEEPSEEK_API_KEY; 151 | }); 152 | 153 | it("should handle missing API key gracefully", async () => { 154 | delete process.env.OPENAI_API_KEY; 155 | 156 | const { stderr } = await execAsync( 157 | `TASK_MANAGER_FILE_PATH=${tasksFilePath} tsx ${CLI_PATH} generate-plan --prompt "Create a todo app" --provider openai` 158 | ).catch(error => error); 159 | 160 | // Verify we get an error with the error code format 161 | expect(stderr).toContain("[ERR_"); 162 | // The actual error should contain "API key" text 163 | expect(stderr).toContain("API key"); 164 | }, 5000); 165 | 166 | it("should handle invalid file attachments gracefully", async () => { 167 | const { stdout, stderr } = await execAsync( 168 | `TASK_MANAGER_FILE_PATH=${tasksFilePath} tsx ${CLI_PATH} generate-plan --prompt "Create app" --attachment nonexistent.txt` 169 | ).catch(error => ({ stdout: error.stdout, stderr: error.stderr })); 170 | 171 | // Updated assertion to match the formatCliError output 172 | expect(stderr).toContain("[ERR_4000] Failed to read attachment file: nonexistent.txt"); 173 | expect(stderr).toContain("-> Details: Attachment file not found: nonexistent.txt"); 174 | }, 5000); 175 | }); 176 | }); -------------------------------------------------------------------------------- /tests/mcp/e2e.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from '@jest/globals'; 2 | import { setupTestContext, teardownTestContext } from './test-helpers.js'; 3 | import type { TestContext } from './test-helpers.js'; 4 | 5 | describe('MCP Client Integration', () => { 6 | let context: TestContext; 7 | 8 | beforeAll(async () => { 9 | context = await setupTestContext(); 10 | }); 11 | 12 | afterAll(async () => { 13 | await teardownTestContext(context); 14 | }); 15 | 16 | it('should list available tools', async () => { 17 | const response = await context.client.listTools(); 18 | expect(response).toBeDefined(); 19 | expect(response).toHaveProperty('tools'); 20 | expect(Array.isArray(response.tools)).toBe(true); 21 | expect(response.tools.length).toBeGreaterThan(0); 22 | 23 | // Check for essential tools 24 | const toolNames = response.tools.map(tool => tool.name); 25 | expect(toolNames).toContain('list_projects'); 26 | expect(toolNames).toContain('create_project'); 27 | expect(toolNames).toContain('read_project'); 28 | expect(toolNames).toContain('get_next_task'); 29 | }); 30 | }); -------------------------------------------------------------------------------- /tests/mcp/test-helpers.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 2 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; 3 | import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; 4 | import { Task, Project, TaskManagerFile } from "../../src/types/data.js"; 5 | import { FileSystemService } from "../../src/server/FileSystemService.js"; 6 | import * as path from 'node:path'; 7 | import * as os from 'node:os'; 8 | import * as fs from 'node:fs/promises'; 9 | import process from 'node:process'; 10 | import dotenv from 'dotenv'; 11 | 12 | // Load environment variables from .env file 13 | dotenv.config(); 14 | 15 | export interface TestContext { 16 | client: Client; 17 | transport: StdioClientTransport; 18 | tempDir: string; 19 | testFilePath: string; 20 | taskCounter: number; 21 | fileService: FileSystemService; 22 | } 23 | 24 | /** 25 | * Sets up a test context with MCP client, transport, and temp directory 26 | */ 27 | export async function setupTestContext( 28 | customFilePath?: string, 29 | skipFileInit: boolean = false, 30 | customEnv?: Record 31 | ): Promise { 32 | // Create a unique temp directory for test 33 | const tempDir = path.join(os.tmpdir(), `mcp-client-integration-test-${Date.now()}-${Math.floor(Math.random() * 10000)}`); 34 | await fs.mkdir(tempDir, { recursive: true }); 35 | const testFilePath = customFilePath || path.join(tempDir, 'test-tasks.json'); 36 | 37 | // Create FileSystemService instance 38 | const fileService = new FileSystemService(testFilePath); 39 | 40 | // Initialize empty task manager file (skip for error testing) 41 | if (!skipFileInit) { 42 | await fileService.saveTasks({ projects: [] }); 43 | } 44 | 45 | // Set up the transport with environment variable for test file 46 | const transport = new StdioClientTransport({ 47 | command: process.execPath, // Use full path to current Node.js executable 48 | args: ["dist/src/server/index.js"], 49 | env: { 50 | TASK_MANAGER_FILE_PATH: testFilePath, 51 | NODE_ENV: "test", 52 | DEBUG: "mcp:*", // Enable MCP debug logging 53 | // Use custom env if provided, otherwise use default API keys 54 | ...(customEnv || { 55 | OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? '', 56 | GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? '', 57 | DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? '' 58 | }) 59 | } 60 | }); 61 | 62 | // Set up the client 63 | const client = new Client( 64 | { 65 | name: "test-client", 66 | version: "1.0.0" 67 | }, 68 | { 69 | capabilities: { 70 | tools: { 71 | list: true, 72 | call: true 73 | } 74 | } 75 | } 76 | ); 77 | 78 | try { 79 | // Connect to the server with a timeout 80 | const connectPromise = client.connect(transport); 81 | const timeoutPromise = new Promise((_, reject) => { 82 | setTimeout(() => reject(new Error('Connection timeout')), 5000); 83 | }); 84 | 85 | await Promise.race([connectPromise, timeoutPromise]); 86 | 87 | // Small delay to ensure server is ready 88 | await new Promise(resolve => setTimeout(resolve, 1000)); 89 | } catch (error) { 90 | throw error; 91 | } 92 | 93 | return { client, transport, tempDir, testFilePath, taskCounter: 0, fileService }; 94 | } 95 | 96 | /** 97 | * Cleans up test context by closing transport and removing temp directory 98 | */ 99 | export async function teardownTestContext(context: TestContext) { 100 | try { 101 | // Ensure transport is properly closed 102 | if (context.transport) { 103 | context.transport.close(); 104 | } 105 | } catch (err) { 106 | console.error('Error closing transport:', err); 107 | } 108 | 109 | // Clean up temp files 110 | try { 111 | await fs.rm(context.tempDir, { recursive: true, force: true }); 112 | } catch (err) { 113 | console.error('Error cleaning up temp directory:', err); 114 | } 115 | } 116 | 117 | /** 118 | * Verifies that a tool response matches the MCP spec format 119 | */ 120 | export function verifyCallToolResult(response: CallToolResult) { 121 | expect(response).toBeDefined(); 122 | expect(response).toHaveProperty('content'); 123 | expect(Array.isArray(response.content)).toBe(true); 124 | expect(response.content.length).toBeGreaterThan(0); 125 | 126 | // Verify each content item matches MCP spec 127 | response.content.forEach(item => { 128 | expect(item).toHaveProperty('type'); 129 | expect(item).toHaveProperty('text'); 130 | expect(typeof item.type).toBe('string'); 131 | expect(typeof item.text).toBe('string'); 132 | }); 133 | 134 | // If it's an error response, verify error format 135 | if (response.isError) { 136 | expect(response.content[0].text).toMatch(/^(Error|Failed|Invalid|Tool execution failed)/); 137 | } 138 | } 139 | 140 | /** 141 | * Verifies that a protocol error matches the MCP spec format 142 | */ 143 | export function verifyProtocolError(error: any, expectedCode: number, expectedMessagePattern: string) { 144 | expect(error).toBeDefined(); 145 | expect(error.code).toBe(expectedCode); 146 | expect(error.message).toMatch(expectedMessagePattern); 147 | } 148 | 149 | /** 150 | * Verifies that a tool execution error matches the expected format 151 | */ 152 | export function verifyToolExecutionError(response: CallToolResult, expectedMessagePattern: string | RegExp) { 153 | verifyCallToolResult(response); // Verify basic CallToolResult format 154 | expect(response.isError).toBe(true); 155 | const errorMessage = response.content[0]?.text; 156 | expect(typeof errorMessage).toBe('string'); 157 | expect(errorMessage).toMatch(expectedMessagePattern); 158 | } 159 | 160 | /** 161 | * Verifies that a successful tool response contains valid JSON data 162 | */ 163 | export function verifyToolSuccessResponse(response: CallToolResult): T { 164 | verifyCallToolResult(response); 165 | expect(response.isError).toBeFalsy(); 166 | const jsonText = response.content[0]?.text; 167 | expect(typeof jsonText).toBe('string'); 168 | return JSON.parse(jsonText as string); 169 | } 170 | 171 | /** 172 | * Creates a test project and returns its ID 173 | */ 174 | export async function createTestProject(client: Client, options: { 175 | initialPrompt?: string; 176 | tasks?: Array<{ title: string; description: string }>; 177 | autoApprove?: boolean; 178 | } = {}): Promise { 179 | const createResult = await client.callTool({ 180 | name: "create_project", 181 | arguments: { 182 | initialPrompt: options.initialPrompt || "Test Project", 183 | tasks: options.tasks || [ 184 | { title: "Task 1", description: "First test task" } 185 | ], 186 | autoApprove: options.autoApprove 187 | } 188 | }) as CallToolResult; 189 | 190 | const responseData = verifyToolSuccessResponse<{ projectId: string }>(createResult); 191 | return responseData.projectId; 192 | } 193 | 194 | /** 195 | * Gets the first task ID from a project 196 | */ 197 | export async function getFirstTaskId(client: Client, projectId: string): Promise { 198 | const nextTaskResult = await client.callTool({ 199 | name: "get_next_task", 200 | arguments: { projectId } 201 | }) as CallToolResult; 202 | 203 | const nextTask = verifyToolSuccessResponse<{ task: { id: string } }>(nextTaskResult); 204 | return nextTask.task.id; 205 | } 206 | 207 | /** 208 | * Reads and parses the task manager file 209 | */ 210 | export async function readTaskManagerFile(filePath: string): Promise { 211 | const fileService = new FileSystemService(filePath); 212 | return fileService.reloadTasks(); 213 | } 214 | 215 | /** 216 | * Writes data to the task manager file 217 | */ 218 | export async function writeTaskManagerFile(filePath: string, data: TaskManagerFile): Promise { 219 | const fileService = new FileSystemService(filePath); 220 | await fileService.saveTasks(data); 221 | } 222 | 223 | /** 224 | * Verifies a project exists in the task manager file and matches expected data 225 | */ 226 | export async function verifyProjectInFile(filePath: string, projectId: string, expectedData: Partial): Promise { 227 | const data = await readTaskManagerFile(filePath); 228 | const project = data.projects.find(p => p.projectId === projectId); 229 | 230 | expect(project).toBeDefined(); 231 | Object.entries(expectedData).forEach(([key, value]) => { 232 | expect(project).toHaveProperty(key, value); 233 | }); 234 | } 235 | 236 | /** 237 | * Verifies a task exists in a project and matches expected data 238 | */ 239 | export async function verifyTaskInFile(filePath: string, projectId: string, taskId: string, expectedData: Partial): Promise { 240 | const data = await readTaskManagerFile(filePath); 241 | const project = data.projects.find(p => p.projectId === projectId); 242 | expect(project).toBeDefined(); 243 | 244 | const task = project?.tasks.find(t => t.id === taskId); 245 | expect(task).toBeDefined(); 246 | Object.entries(expectedData).forEach(([key, value]) => { 247 | expect(task).toHaveProperty(key, value); 248 | }); 249 | } 250 | 251 | /** 252 | * Creates a test project directly in the file (bypassing the tool) 253 | */ 254 | export async function createTestProjectInFile(filePath: string, project: Partial): Promise { 255 | const data = await readTaskManagerFile(filePath); 256 | const newProject: Project = { 257 | projectId: `proj-${Date.now()}`, 258 | initialPrompt: "Test Project", 259 | projectPlan: "", 260 | completed: false, 261 | tasks: [], 262 | ...project 263 | }; 264 | 265 | data.projects.push(newProject); 266 | await writeTaskManagerFile(filePath, data); 267 | return newProject; 268 | } 269 | 270 | /** 271 | * Creates a test task directly in the file (bypassing the tool) 272 | */ 273 | export async function createTestTaskInFile(filePath: string, projectId: string, task: Partial): Promise { 274 | const data = await readTaskManagerFile(filePath); 275 | const project = data.projects.find(p => p.projectId === projectId); 276 | if (!project) { 277 | throw new Error(`Project ${projectId} not found`); 278 | } 279 | 280 | // Find the highest task ID number in the file to ensure unique IDs 281 | const maxTaskId = data.projects 282 | .flatMap(p => p.tasks) 283 | .map(t => parseInt(t.id.replace('task-', ''))) 284 | .reduce((max, curr) => Math.max(max, curr), 0); 285 | 286 | const newTask: Task = { 287 | id: `task-${maxTaskId + 1}`, // Use incrementing number instead of timestamp 288 | title: "Test Task", 289 | description: "Test Description", 290 | status: "not started", 291 | approved: false, 292 | completedDetails: "", 293 | toolRecommendations: "", 294 | ruleRecommendations: "", 295 | ...task 296 | }; 297 | 298 | project.tasks.push(newTask); 299 | await writeTaskManagerFile(filePath, data); 300 | return newTask; 301 | } -------------------------------------------------------------------------------- /tests/mcp/tools/add-tasks-to-project.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from '@jest/globals'; 2 | import { setupTestContext, teardownTestContext, TestContext, createTestProject, verifyCallToolResult, verifyTaskInFile, verifyToolExecutionError, verifyProtocolError } from '../test-helpers.js'; 3 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 4 | 5 | describe('add_tasks_to_project Tool', () => { 6 | let context: TestContext; 7 | let projectId: string; 8 | 9 | beforeEach(async () => { 10 | context = await setupTestContext(); 11 | // Create a test project for each test case 12 | projectId = await createTestProject(context.client); 13 | }); 14 | 15 | afterEach(async () => { 16 | await teardownTestContext(context); 17 | }); 18 | 19 | describe('Success Cases', () => { 20 | it('should add a single task to project', async () => { 21 | const result = await context.client.callTool({ 22 | name: "add_tasks_to_project", 23 | arguments: { 24 | projectId, 25 | tasks: [ 26 | { title: "New Task", description: "A task to add" } 27 | ] 28 | } 29 | }) as CallToolResult; 30 | 31 | verifyCallToolResult(result); 32 | expect(result.isError).toBeFalsy(); 33 | 34 | // Parse and verify response 35 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 36 | expect(responseData).toHaveProperty('message'); 37 | expect(responseData).toHaveProperty('newTasks'); 38 | expect(responseData.newTasks).toHaveLength(1); 39 | const newTask = responseData.newTasks[0]; 40 | 41 | // Verify task was added to file 42 | await verifyTaskInFile(context.testFilePath, projectId, newTask.id, { 43 | title: "New Task", 44 | description: "A task to add", 45 | status: "not started", 46 | approved: false 47 | }); 48 | }); 49 | 50 | it('should add multiple tasks to project', async () => { 51 | const tasks = [ 52 | { title: "Task 1", description: "First task to add" }, 53 | { title: "Task 2", description: "Second task to add" }, 54 | { title: "Task 3", description: "Third task to add" } 55 | ]; 56 | 57 | const result = await context.client.callTool({ 58 | name: "add_tasks_to_project", 59 | arguments: { 60 | projectId, 61 | tasks 62 | } 63 | }) as CallToolResult; 64 | 65 | verifyCallToolResult(result); 66 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 67 | expect(responseData.newTasks).toHaveLength(3); 68 | 69 | // Verify all tasks were added 70 | for (let i = 0; i < tasks.length; i++) { 71 | await verifyTaskInFile(context.testFilePath, projectId, responseData.newTasks[i].id, { 72 | title: tasks[i].title, 73 | description: tasks[i].description, 74 | status: "not started" 75 | }); 76 | } 77 | }); 78 | 79 | it('should add tasks with tool and rule recommendations', async () => { 80 | const result = await context.client.callTool({ 81 | name: "add_tasks_to_project", 82 | arguments: { 83 | projectId, 84 | tasks: [{ 85 | title: "Task with Recommendations", 86 | description: "Task with specific recommendations", 87 | toolRecommendations: "Use tool A and B", 88 | ruleRecommendations: "Follow rules X and Y" 89 | }] 90 | } 91 | }) as CallToolResult; 92 | 93 | verifyCallToolResult(result); 94 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 95 | const newTask = responseData.newTasks[0]; 96 | 97 | await verifyTaskInFile(context.testFilePath, projectId, newTask.id, { 98 | title: "Task with Recommendations", 99 | description: "Task with specific recommendations", 100 | toolRecommendations: "Use tool A and B", 101 | ruleRecommendations: "Follow rules X and Y" 102 | }); 103 | }); 104 | 105 | it('should handle empty tasks array', async () => { 106 | const result = await context.client.callTool({ 107 | name: "add_tasks_to_project", 108 | arguments: { 109 | projectId, 110 | tasks: [] 111 | } 112 | }) as CallToolResult; 113 | 114 | verifyCallToolResult(result); 115 | expect(result.isError).toBeFalsy(); 116 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 117 | expect(responseData.newTasks).toHaveLength(0); 118 | }); 119 | }); 120 | 121 | describe('Error Cases', () => { 122 | it('should return error for missing required parameters', async () => { 123 | try { 124 | await context.client.callTool({ 125 | name: "add_tasks_to_project", 126 | arguments: { 127 | projectId 128 | // Missing tasks array 129 | } 130 | }); 131 | expect(true).toBe(false); // This line should never be reached 132 | } catch (error) { 133 | verifyProtocolError(error, -32602, 'Invalid or missing required parameter'); 134 | } 135 | }); 136 | 137 | it('should return error for invalid project ID', async () => { 138 | const result = await context.client.callTool({ 139 | name: "add_tasks_to_project", 140 | arguments: { 141 | projectId: "non-existent-project", 142 | tasks: [{ title: "Test Task", description: "Test Description" }] 143 | } 144 | }) as CallToolResult; 145 | 146 | verifyToolExecutionError(result, /Project non-existent-project not found/); 147 | }); 148 | 149 | it('should return error for task with empty title', async () => { 150 | try { 151 | await context.client.callTool({ 152 | name: "add_tasks_to_project", 153 | arguments: { 154 | projectId, 155 | tasks: [{ title: "", description: "Test Description" }] 156 | } 157 | }); 158 | expect(true).toBe(false); // This line should never be reached 159 | } catch (error) { 160 | verifyProtocolError(error, -32602, 'Invalid or missing required parameter: title'); 161 | } 162 | }); 163 | 164 | it('should return error for task with empty description', async () => { 165 | try { 166 | await context.client.callTool({ 167 | name: "add_tasks_to_project", 168 | arguments: { 169 | projectId, 170 | tasks: [{ title: "Test Task", description: "" }] 171 | } 172 | }); 173 | expect(true).toBe(false); // This line should never be reached 174 | } catch (error) { 175 | verifyProtocolError(error, -32602, 'Invalid or missing required parameter: description'); 176 | } 177 | }); 178 | }); 179 | }); -------------------------------------------------------------------------------- /tests/mcp/tools/approve-task.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; 2 | import { 3 | setupTestContext, 4 | teardownTestContext, 5 | verifyCallToolResult, 6 | createTestProjectInFile, 7 | createTestTaskInFile, 8 | verifyTaskInFile, 9 | TestContext 10 | } from '../test-helpers.js'; 11 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 12 | 13 | describe('approve_task Tool', () => { 14 | let context: TestContext; 15 | 16 | beforeAll(async () => { 17 | context = await setupTestContext(); 18 | }); 19 | 20 | afterAll(async () => { 21 | await teardownTestContext(context); 22 | }); 23 | 24 | describe('Success Cases', () => { 25 | it('should approve a completed task', async () => { 26 | // Create a project with a completed task 27 | const project = await createTestProjectInFile(context.testFilePath, { 28 | initialPrompt: "Test Project" 29 | }); 30 | const task = await createTestTaskInFile(context.testFilePath, project.projectId, { 31 | title: "Test Task", 32 | status: "done", 33 | completedDetails: "Task completed in test" 34 | }); 35 | 36 | // Approve the task 37 | const result = await context.client.callTool({ 38 | name: "approve_task", 39 | arguments: { 40 | projectId: project.projectId, 41 | taskId: task.id 42 | } 43 | }) as CallToolResult; 44 | 45 | // Verify response 46 | verifyCallToolResult(result); 47 | expect(result.isError).toBeFalsy(); 48 | 49 | // Verify task was approved in file 50 | await verifyTaskInFile(context.testFilePath, project.projectId, task.id, { 51 | approved: true, 52 | status: "done" 53 | }); 54 | }); 55 | 56 | it('should handle auto-approved tasks', async () => { 57 | // Create a project with auto-approve enabled 58 | const project = await createTestProjectInFile(context.testFilePath, { 59 | initialPrompt: "Auto-approve Project", 60 | autoApprove: true 61 | }); 62 | const task = await createTestTaskInFile(context.testFilePath, project.projectId, { 63 | title: "Auto Task", 64 | status: "done", 65 | completedDetails: "Auto-approved task completed" 66 | }); 67 | 68 | // Try to approve an auto-approved task 69 | const result = await context.client.callTool({ 70 | name: "approve_task", 71 | arguments: { 72 | projectId: project.projectId, 73 | taskId: task.id 74 | } 75 | }) as CallToolResult; 76 | 77 | verifyCallToolResult(result); 78 | expect(result.isError).toBeFalsy(); 79 | 80 | // Verify task was auto-approved 81 | await verifyTaskInFile(context.testFilePath, project.projectId, task.id, { 82 | approved: true, 83 | status: "done" 84 | }); 85 | }); 86 | 87 | it('should allow approving multiple tasks in sequence', async () => { 88 | const project = await createTestProjectInFile(context.testFilePath, { 89 | initialPrompt: "Multi-task Project" 90 | }); 91 | 92 | // Create tasks sequentially 93 | const task1 = await createTestTaskInFile(context.testFilePath, project.projectId, { 94 | title: "Task 1", 95 | status: "done", 96 | completedDetails: "First task done" 97 | }); 98 | 99 | const task2 = await createTestTaskInFile(context.testFilePath, project.projectId, { 100 | title: "Task 2", 101 | status: "done", 102 | completedDetails: "Second task done" 103 | }); 104 | 105 | const tasks = [task1, task2]; 106 | 107 | // Approve tasks in sequence 108 | for (const task of tasks) { 109 | const result = await context.client.callTool({ 110 | name: "approve_task", 111 | arguments: { 112 | projectId: project.projectId, 113 | taskId: task.id 114 | } 115 | }) as CallToolResult; 116 | 117 | verifyCallToolResult(result); 118 | expect(result.isError).toBeFalsy(); 119 | 120 | await verifyTaskInFile(context.testFilePath, project.projectId, task.id, { 121 | approved: true 122 | }); 123 | } 124 | }); 125 | }); 126 | 127 | describe('Error Cases', () => { 128 | it('should return error for non-existent project', async () => { 129 | const result = await context.client.callTool({ 130 | name: "approve_task", 131 | arguments: { 132 | projectId: "non_existent_project", 133 | taskId: "task-1" 134 | } 135 | }) as CallToolResult; 136 | 137 | verifyCallToolResult(result); 138 | expect(result.isError).toBe(true); 139 | expect(result.content[0].text).toContain('Tool execution failed: Project non_existent_project not found'); 140 | }); 141 | 142 | it('should return error for non-existent task', async () => { 143 | const project = await createTestProjectInFile(context.testFilePath, { 144 | initialPrompt: "Test Project" 145 | }); 146 | 147 | const result = await context.client.callTool({ 148 | name: "approve_task", 149 | arguments: { 150 | projectId: project.projectId, 151 | taskId: "non_existent_task" 152 | } 153 | }) as CallToolResult; 154 | 155 | verifyCallToolResult(result); 156 | expect(result.isError).toBe(true); 157 | expect(result.content[0].text).toContain('Tool execution failed: Task non_existent_task not found'); 158 | }); 159 | 160 | it('should return error when approving incomplete task', async () => { 161 | const project = await createTestProjectInFile(context.testFilePath, { 162 | initialPrompt: "Test Project" 163 | }); 164 | const task = await createTestTaskInFile(context.testFilePath, project.projectId, { 165 | title: "Incomplete Task", 166 | status: "in progress" 167 | }); 168 | 169 | const result = await context.client.callTool({ 170 | name: "approve_task", 171 | arguments: { 172 | projectId: project.projectId, 173 | taskId: task.id 174 | } 175 | }) as CallToolResult; 176 | 177 | verifyCallToolResult(result); 178 | expect(result.isError).toBe(true); 179 | expect(result.content[0].text).toContain('Tool execution failed: Task not done yet'); 180 | }); 181 | 182 | it('should return error when approving already approved task', async () => { 183 | const project = await createTestProjectInFile(context.testFilePath, { 184 | initialPrompt: "Test Project" 185 | }); 186 | const task = await createTestTaskInFile(context.testFilePath, project.projectId, { 187 | title: "Approved Task", 188 | status: "done", 189 | approved: true, 190 | completedDetails: "Already approved" 191 | }); 192 | 193 | const result = await context.client.callTool({ 194 | name: "approve_task", 195 | arguments: { 196 | projectId: project.projectId, 197 | taskId: task.id 198 | } 199 | }) as CallToolResult; 200 | 201 | verifyCallToolResult(result); 202 | expect(result.isError).toBe(true); 203 | expect(result.content[0].text).toContain('Tool execution failed: Task is already approved'); 204 | }); 205 | }); 206 | }); -------------------------------------------------------------------------------- /tests/mcp/tools/create-project.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; 2 | import { 3 | setupTestContext, 4 | teardownTestContext, 5 | verifyCallToolResult, 6 | verifyProjectInFile, 7 | verifyTaskInFile, 8 | readTaskManagerFile, 9 | TestContext 10 | } from '../test-helpers.js'; 11 | import { CallToolResult, McpError } from '@modelcontextprotocol/sdk/types.js'; 12 | 13 | describe('create_project Tool', () => { 14 | let context: TestContext; 15 | 16 | beforeAll(async () => { 17 | context = await setupTestContext(); 18 | }); 19 | 20 | afterAll(async () => { 21 | await teardownTestContext(context); 22 | }); 23 | 24 | describe('Success Cases', () => { 25 | it('should create a project with minimal parameters', async () => { 26 | const result = await context.client.callTool({ 27 | name: "create_project", 28 | arguments: { 29 | initialPrompt: "Test Project", 30 | tasks: [ 31 | { title: "Task 1", description: "First test task" } 32 | ] 33 | } 34 | }) as CallToolResult; 35 | 36 | verifyCallToolResult(result); 37 | expect(result.isError).toBeFalsy(); 38 | 39 | // Parse and verify response 40 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 41 | expect(responseData).toHaveProperty('projectId'); 42 | const projectId = responseData.projectId; 43 | 44 | // Verify project was created in file 45 | await verifyProjectInFile(context.testFilePath, projectId, { 46 | initialPrompt: "Test Project", 47 | completed: false 48 | }); 49 | 50 | // Verify task was created 51 | await verifyTaskInFile(context.testFilePath, projectId, responseData.tasks[0].id, { 52 | title: "Task 1", 53 | description: "First test task", 54 | status: "not started", 55 | approved: false 56 | }); 57 | }); 58 | 59 | it('should create a project with no tasks', async () => { 60 | const result = await context.client.callTool({ 61 | name: "create_project", 62 | arguments: { 63 | initialPrompt: "Project with No Tasks", 64 | tasks: [] 65 | } 66 | }) as CallToolResult; 67 | 68 | verifyCallToolResult(result); 69 | expect(result.isError).toBeFalsy(); 70 | 71 | // Parse and verify response 72 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 73 | expect(responseData).toHaveProperty('projectId'); 74 | const projectId = responseData.projectId; 75 | 76 | // Verify project was created in file 77 | await verifyProjectInFile(context.testFilePath, projectId, { 78 | initialPrompt: "Project with No Tasks", 79 | completed: false 80 | }); 81 | 82 | // Verify no tasks were created 83 | const data = await readTaskManagerFile(context.testFilePath); 84 | const project = data.projects.find(p => p.projectId === projectId); 85 | expect(project?.tasks).toHaveLength(0); 86 | }); 87 | 88 | it('should create a project with multiple tasks', async () => { 89 | const result = await context.client.callTool({ 90 | name: "create_project", 91 | arguments: { 92 | initialPrompt: "Multi-task Project", 93 | tasks: [ 94 | { title: "Task 1", description: "First task" }, 95 | { title: "Task 2", description: "Second task" }, 96 | { title: "Task 3", description: "Third task" } 97 | ] 98 | } 99 | }) as CallToolResult; 100 | 101 | verifyCallToolResult(result); 102 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 103 | const projectId = responseData.projectId; 104 | 105 | // Verify all tasks were created 106 | const data = await readTaskManagerFile(context.testFilePath); 107 | const project = data.projects.find(p => p.projectId === projectId); 108 | expect(project?.tasks).toHaveLength(3); 109 | expect(project?.tasks.map(t => t.title)).toEqual([ 110 | "Task 1", 111 | "Task 2", 112 | "Task 3" 113 | ]); 114 | expect(project).toHaveProperty('autoApprove', true); 115 | }); 116 | 117 | it('should create a project with auto-approve enabled', async () => { 118 | const result = await context.client.callTool({ 119 | name: "create_project", 120 | arguments: { 121 | initialPrompt: "Auto-approve Project", 122 | tasks: [ 123 | { title: "Auto Task", description: "This task will be auto-approved" } 124 | ], 125 | autoApprove: true 126 | } 127 | }) as CallToolResult; 128 | 129 | verifyCallToolResult(result); 130 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 131 | const projectId = responseData.projectId; 132 | 133 | // Verify project was created with auto-approve 134 | const data = await readTaskManagerFile(context.testFilePath); 135 | const project = data.projects.find(p => p.projectId === projectId); 136 | expect(project).toHaveProperty('autoApprove', true); 137 | }); 138 | 139 | it('should create a project with project plan', async () => { 140 | const result = await context.client.callTool({ 141 | name: "create_project", 142 | arguments: { 143 | initialPrompt: "Planned Project", 144 | projectPlan: "Detailed plan for the project execution", 145 | tasks: [ 146 | { title: "Planned Task", description: "Task with a plan" } 147 | ] 148 | } 149 | }) as CallToolResult; 150 | 151 | verifyCallToolResult(result); 152 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 153 | const projectId = responseData.projectId; 154 | 155 | await verifyProjectInFile(context.testFilePath, projectId, { 156 | initialPrompt: "Planned Project", 157 | projectPlan: "Detailed plan for the project execution" 158 | }); 159 | }); 160 | 161 | it('should create tasks with tool and rule recommendations', async () => { 162 | const result = await context.client.callTool({ 163 | name: "create_project", 164 | arguments: { 165 | initialPrompt: "Project with Recommendations", 166 | tasks: [{ 167 | title: "Task with Recommendations", 168 | description: "Task description", 169 | toolRecommendations: "Use tool X and Y", 170 | ruleRecommendations: "Follow rules A and B" 171 | }] 172 | } 173 | }) as CallToolResult; 174 | 175 | verifyCallToolResult(result); 176 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 177 | const projectId = responseData.projectId; 178 | const taskId = responseData.tasks[0].id; 179 | 180 | await verifyTaskInFile(context.testFilePath, projectId, taskId, { 181 | toolRecommendations: "Use tool X and Y", 182 | ruleRecommendations: "Follow rules A and B" 183 | }); 184 | }); 185 | }); 186 | 187 | describe('Error Cases', () => { 188 | it('should return error for missing required parameters', async () => { 189 | try { 190 | await context.client.callTool({ 191 | name: "create_project", 192 | arguments: { 193 | // Missing initialPrompt and tasks 194 | } 195 | }); 196 | fail('Expected McpError to be thrown'); 197 | } catch (error) { 198 | expect(error instanceof McpError).toBe(true); 199 | expect((error as McpError).message).toContain('Invalid or missing required parameter: initialPrompt'); 200 | } 201 | }); 202 | 203 | it('should return error for invalid task data', async () => { 204 | try { 205 | await context.client.callTool({ 206 | name: "create_project", 207 | arguments: { 208 | initialPrompt: "Invalid Task Project", 209 | tasks: [ 210 | { title: "Task 1" } // Missing required description 211 | ] 212 | } 213 | }); 214 | fail('Expected McpError to be thrown'); 215 | } catch (error) { 216 | expect(error instanceof McpError).toBe(true); 217 | expect((error as McpError).message).toContain('Invalid or missing required parameter: description'); 218 | } 219 | }); 220 | }); 221 | }); -------------------------------------------------------------------------------- /tests/mcp/tools/create-task.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from '@jest/globals'; 2 | import { setupTestContext, teardownTestContext, TestContext, createTestProject, verifyCallToolResult, verifyTaskInFile, verifyToolExecutionError, verifyProtocolError } from '../test-helpers.js'; 3 | import { CallToolResult, McpError } from '@modelcontextprotocol/sdk/types.js'; 4 | 5 | describe('create_task Tool', () => { 6 | let context: TestContext; 7 | let projectId: string; 8 | 9 | beforeEach(async () => { 10 | context = await setupTestContext(); 11 | // Create a test project for each test case 12 | projectId = await createTestProject(context.client); 13 | }); 14 | 15 | afterEach(async () => { 16 | await teardownTestContext(context); 17 | }); 18 | 19 | describe('Success Cases', () => { 20 | it('should create a task with minimal parameters', async () => { 21 | const result = await context.client.callTool({ 22 | name: "create_task", 23 | arguments: { 24 | projectId, 25 | title: "New Test Task", 26 | description: "A simple test task" 27 | } 28 | }) as CallToolResult; 29 | 30 | verifyCallToolResult(result); 31 | expect(result.isError).toBeFalsy(); 32 | 33 | // Parse and verify response 34 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 35 | expect(responseData).toHaveProperty('message'); 36 | expect(responseData).toHaveProperty('newTasks'); 37 | expect(responseData.newTasks).toHaveLength(1); 38 | const newTask = responseData.newTasks[0]; 39 | 40 | // Verify task was created in file 41 | await verifyTaskInFile(context.testFilePath, projectId, newTask.id, { 42 | title: "New Test Task", 43 | description: "A simple test task", 44 | status: "not started", 45 | approved: false 46 | }); 47 | }); 48 | 49 | it('should create a task with tool and rule recommendations', async () => { 50 | const result = await context.client.callTool({ 51 | name: "create_task", 52 | arguments: { 53 | projectId, 54 | title: "Task with Recommendations", 55 | description: "Task with specific recommendations", 56 | toolRecommendations: "Use tool A and B", 57 | ruleRecommendations: "Follow rules X and Y" 58 | } 59 | }) as CallToolResult; 60 | 61 | verifyCallToolResult(result); 62 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 63 | const newTask = responseData.newTasks[0]; 64 | 65 | await verifyTaskInFile(context.testFilePath, projectId, newTask.id, { 66 | title: "Task with Recommendations", 67 | description: "Task with specific recommendations", 68 | toolRecommendations: "Use tool A and B", 69 | ruleRecommendations: "Follow rules X and Y" 70 | }); 71 | }); 72 | 73 | it('should create multiple tasks in sequence', async () => { 74 | const tasks = [ 75 | { title: "First Task", description: "Task 1 description" }, 76 | { title: "Second Task", description: "Task 2 description" }, 77 | { title: "Third Task", description: "Task 3 description" } 78 | ]; 79 | 80 | const taskIds = []; 81 | 82 | for (const task of tasks) { 83 | const result = await context.client.callTool({ 84 | name: "create_task", 85 | arguments: { 86 | projectId, 87 | ...task 88 | } 89 | }) as CallToolResult; 90 | 91 | verifyCallToolResult(result); 92 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 93 | taskIds.push(responseData.newTasks[0].id); 94 | } 95 | 96 | // Verify all tasks were created 97 | for (let i = 0; i < tasks.length; i++) { 98 | await verifyTaskInFile(context.testFilePath, projectId, taskIds[i], { 99 | title: tasks[i].title, 100 | description: tasks[i].description, 101 | status: "not started" 102 | }); 103 | } 104 | }); 105 | }); 106 | 107 | describe('Error Cases', () => { 108 | it('should return error for missing required parameters', async () => { 109 | try { 110 | await context.client.callTool({ 111 | name: "create_task", 112 | arguments: { 113 | projectId 114 | // Missing title and description 115 | } 116 | }); 117 | expect(true).toBe(false); // This line should never be reached 118 | } catch (error) { 119 | verifyProtocolError(error, -32602, 'Invalid or missing required parameter'); 120 | } 121 | }); 122 | 123 | it('should return error for invalid project ID', async () => { 124 | const result = await context.client.callTool({ 125 | name: "create_task", 126 | arguments: { 127 | projectId: "non-existent-project", 128 | title: "Test Task", 129 | description: "Test Description" 130 | } 131 | }) as CallToolResult; 132 | 133 | verifyToolExecutionError(result, /Project non-existent-project not found/); 134 | }); 135 | 136 | it('should return error for empty title', async () => { 137 | try { 138 | await context.client.callTool({ 139 | name: "create_task", 140 | arguments: { 141 | projectId, 142 | title: "", 143 | description: "Test Description" 144 | } 145 | }); 146 | expect(true).toBe(false); // This line should never be reached 147 | } catch (error) { 148 | verifyProtocolError(error, -32602, 'Invalid or missing required parameter: title'); 149 | } 150 | }); 151 | 152 | it('should return error for empty description', async () => { 153 | try { 154 | await context.client.callTool({ 155 | name: "create_task", 156 | arguments: { 157 | projectId, 158 | title: "Test Task", 159 | description: "" 160 | } 161 | }); 162 | expect(true).toBe(false); // This line should never be reached 163 | } catch (error) { 164 | verifyProtocolError(error, -32602, 'Invalid or missing required parameter: description'); 165 | } 166 | }); 167 | }); 168 | }); -------------------------------------------------------------------------------- /tests/mcp/tools/delete-project.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; 2 | import { 3 | setupTestContext, 4 | teardownTestContext, 5 | verifyToolExecutionError, 6 | verifyToolSuccessResponse, 7 | createTestProject, 8 | TestContext 9 | } from '../test-helpers.js'; 10 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 11 | 12 | describe('delete_project Tool', () => { 13 | let context: TestContext; 14 | 15 | beforeEach(async () => { 16 | context = await setupTestContext(); 17 | }); 18 | 19 | afterEach(async () => { 20 | await teardownTestContext(context); 21 | }); 22 | 23 | describe('Success Cases', () => { 24 | it('should successfully delete an empty project', async () => { 25 | // Create a project using the actual create_project tool 26 | const projectId = await createTestProject(context.client, { 27 | initialPrompt: "Test Project", 28 | tasks: [] // No tasks 29 | }); 30 | 31 | const result = await context.client.callTool({ 32 | name: "delete_project", 33 | arguments: { 34 | projectId 35 | } 36 | }) as CallToolResult; 37 | 38 | verifyToolSuccessResponse(result); 39 | 40 | // Verify project is deleted by attempting to read a task from it 41 | const readResult = await context.client.callTool({ 42 | name: "get_next_task", 43 | arguments: { 44 | projectId 45 | } 46 | }) as CallToolResult; 47 | 48 | verifyToolExecutionError(readResult, /Tool execution failed: Project .* not found/); 49 | }); 50 | 51 | it('should successfully delete a project with non-approved tasks', async () => { 52 | // Create a project with non-approved tasks using the actual create_project tool 53 | const projectId = await createTestProject(context.client, { 54 | initialPrompt: "Test Project with Tasks", 55 | tasks: [ 56 | { title: "Task 1", description: "First task" }, 57 | { title: "Task 2", description: "Second task" } 58 | ] 59 | }); 60 | 61 | const result = await context.client.callTool({ 62 | name: "delete_project", 63 | arguments: { 64 | projectId 65 | } 66 | }) as CallToolResult; 67 | 68 | verifyToolSuccessResponse(result); 69 | 70 | // Verify project is deleted 71 | const readResult = await context.client.callTool({ 72 | name: "get_next_task", 73 | arguments: { 74 | projectId 75 | } 76 | }) as CallToolResult; 77 | 78 | verifyToolExecutionError(readResult, /Tool execution failed: Project .* not found/); 79 | }); 80 | 81 | it('should successfully delete a project with approved tasks', async () => { 82 | // Create a project with tasks 83 | const projectId = await createTestProject(context.client, { 84 | initialPrompt: "Project with Tasks", 85 | tasks: [ 86 | { title: "Task to Approve", description: "This task will be approved" } 87 | ] 88 | }); 89 | 90 | // Get the task ID 91 | const nextTaskResult = await context.client.callTool({ 92 | name: "get_next_task", 93 | arguments: { projectId } 94 | }) as CallToolResult; 95 | 96 | const taskData = verifyToolSuccessResponse<{ task: { id: string } }>(nextTaskResult); 97 | const taskId = taskData.task.id; 98 | 99 | // Mark task as done 100 | await context.client.callTool({ 101 | name: "update_task", 102 | arguments: { 103 | projectId, 104 | taskId, 105 | status: "done", 106 | completedDetails: "Task completed" 107 | } 108 | }); 109 | 110 | // Approve the task 111 | await context.client.callTool({ 112 | name: "approve_task", 113 | arguments: { 114 | projectId, 115 | taskId 116 | } 117 | }); 118 | 119 | const result = await context.client.callTool({ 120 | name: "delete_project", 121 | arguments: { 122 | projectId 123 | } 124 | }) as CallToolResult; 125 | 126 | verifyToolSuccessResponse(result); 127 | 128 | // Verify project is deleted 129 | const readResult = await context.client.callTool({ 130 | name: "get_next_task", 131 | arguments: { 132 | projectId 133 | } 134 | }) as CallToolResult; 135 | 136 | verifyToolExecutionError(readResult, /Tool execution failed: Project .* not found/); 137 | }); 138 | 139 | it('should successfully delete a completed project', async () => { 140 | // Create a project and complete all its tasks 141 | const projectId = await createTestProject(context.client, { 142 | initialPrompt: "Project to Complete", 143 | tasks: [ 144 | { title: "Task 1", description: "Task to complete" } 145 | ] 146 | }); 147 | 148 | // Get the task ID 149 | const nextTaskResult = await context.client.callTool({ 150 | name: "get_next_task", 151 | arguments: { projectId } 152 | }) as CallToolResult; 153 | 154 | const taskData = verifyToolSuccessResponse<{ task: { id: string } }>(nextTaskResult); 155 | const taskId = taskData.task.id; 156 | 157 | // Mark task as done 158 | await context.client.callTool({ 159 | name: "update_task", 160 | arguments: { 161 | projectId, 162 | taskId, 163 | status: "done", 164 | completedDetails: "Task completed" 165 | } 166 | }); 167 | 168 | // Approve the task 169 | await context.client.callTool({ 170 | name: "approve_task", 171 | arguments: { 172 | projectId, 173 | taskId 174 | } 175 | }); 176 | 177 | // Mark project as completed 178 | await context.client.callTool({ 179 | name: "finalize_project", 180 | arguments: { 181 | projectId 182 | } 183 | }); 184 | 185 | const result = await context.client.callTool({ 186 | name: "delete_project", 187 | arguments: { 188 | projectId 189 | } 190 | }) as CallToolResult; 191 | 192 | verifyToolSuccessResponse(result); 193 | 194 | // Verify project is deleted 195 | const readResult = await context.client.callTool({ 196 | name: "get_next_task", 197 | arguments: { 198 | projectId 199 | } 200 | }) as CallToolResult; 201 | 202 | verifyToolExecutionError(readResult, /Tool execution failed: Project .* not found/); 203 | }); 204 | }); 205 | 206 | describe('Error Cases', () => { 207 | it('should return error for non-existent project', async () => { 208 | const result = await context.client.callTool({ 209 | name: "delete_project", 210 | arguments: { 211 | projectId: "non_existent_project" 212 | } 213 | }) as CallToolResult; 214 | 215 | verifyToolExecutionError(result, /Tool execution failed: Project not found: non_existent_project/); 216 | }); 217 | 218 | it('should return error for invalid project ID format', async () => { 219 | const result = await context.client.callTool({ 220 | name: "delete_project", 221 | arguments: { 222 | projectId: "invalid-format" 223 | } 224 | }) as CallToolResult; 225 | 226 | verifyToolExecutionError(result, /Tool execution failed: Project not found: invalid-format/); 227 | }); 228 | }); 229 | }); -------------------------------------------------------------------------------- /tests/mcp/tools/delete-task.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; 2 | import { 3 | setupTestContext, 4 | teardownTestContext, 5 | verifyToolExecutionError, 6 | verifyToolSuccessResponse, 7 | createTestProjectInFile, 8 | createTestTaskInFile, 9 | TestContext 10 | } from '../test-helpers.js'; 11 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 12 | 13 | describe('delete_task Tool', () => { 14 | let context: TestContext; 15 | 16 | beforeEach(async () => { 17 | context = await setupTestContext(); 18 | }); 19 | 20 | afterEach(async () => { 21 | await teardownTestContext(context); 22 | }); 23 | 24 | describe('Success Cases', () => { 25 | it('should successfully delete an existing task', async () => { 26 | // Create a project with a task 27 | const project = await createTestProjectInFile(context.testFilePath, { 28 | initialPrompt: "Test Project" 29 | }); 30 | 31 | const task = await createTestTaskInFile(context.testFilePath, project.projectId, { 32 | title: "Test Task", 33 | description: "Task to be deleted", 34 | status: "not started" 35 | }); 36 | 37 | const result = await context.client.callTool({ 38 | name: "delete_task", 39 | arguments: { 40 | projectId: project.projectId, 41 | taskId: task.id 42 | } 43 | }) as CallToolResult; 44 | 45 | verifyToolSuccessResponse(result); 46 | 47 | // Verify task is deleted by attempting to read it 48 | const readResult = await context.client.callTool({ 49 | name: "read_task", 50 | arguments: { 51 | projectId: project.projectId, 52 | taskId: task.id 53 | } 54 | }) as CallToolResult; 55 | 56 | verifyToolExecutionError(readResult, /Tool execution failed: Task .* not found/); 57 | }); 58 | }); 59 | 60 | describe('Error Cases', () => { 61 | it('should return error for non-existent project', async () => { 62 | const result = await context.client.callTool({ 63 | name: "delete_task", 64 | arguments: { 65 | projectId: "non_existent_project", 66 | taskId: "task-1" 67 | } 68 | }) as CallToolResult; 69 | 70 | verifyToolExecutionError(result, /Tool execution failed: Project non_existent_project not found/); 71 | }); 72 | 73 | it('should return error for non-existent task in existing project', async () => { 74 | const project = await createTestProjectInFile(context.testFilePath, { 75 | initialPrompt: "Test Project" 76 | }); 77 | 78 | const result = await context.client.callTool({ 79 | name: "delete_task", 80 | arguments: { 81 | projectId: project.projectId, 82 | taskId: "non-existent-task" 83 | } 84 | }) as CallToolResult; 85 | 86 | verifyToolExecutionError(result, /Tool execution failed: Task non-existent-task not found/); 87 | }); 88 | 89 | it('should return error for invalid project ID format', async () => { 90 | const result = await context.client.callTool({ 91 | name: "delete_task", 92 | arguments: { 93 | projectId: "invalid-format", 94 | taskId: "task-1" 95 | } 96 | }) as CallToolResult; 97 | 98 | verifyToolExecutionError(result, /Tool execution failed: Project invalid-format not found/); 99 | }); 100 | 101 | it('should return error for invalid task ID format', async () => { 102 | const project = await createTestProjectInFile(context.testFilePath, { 103 | initialPrompt: "Test Project" 104 | }); 105 | 106 | const result = await context.client.callTool({ 107 | name: "delete_task", 108 | arguments: { 109 | projectId: project.projectId, 110 | taskId: "invalid-task-id" 111 | } 112 | }) as CallToolResult; 113 | 114 | verifyToolExecutionError(result, /Tool execution failed: Task invalid-task-id not found/); 115 | }); 116 | 117 | it('should return error when trying to delete an approved task', async () => { 118 | const project = await createTestProjectInFile(context.testFilePath, { 119 | initialPrompt: "Project with Completed Task" 120 | }); 121 | 122 | const task = await createTestTaskInFile(context.testFilePath, project.projectId, { 123 | title: "Completed Task", 124 | description: "A finished task to delete", 125 | status: "done", 126 | approved: true, 127 | completedDetails: "Task was completed successfully" 128 | }); 129 | 130 | const result = await context.client.callTool({ 131 | name: "delete_task", 132 | arguments: { 133 | projectId: project.projectId, 134 | taskId: task.id 135 | } 136 | }) as CallToolResult; 137 | 138 | verifyToolExecutionError(result, /Tool execution failed: Cannot delete an approved task/); 139 | }); 140 | }); 141 | }); -------------------------------------------------------------------------------- /tests/mcp/tools/finalize-project.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; 2 | import { 3 | setupTestContext, 4 | teardownTestContext, 5 | verifyCallToolResult, 6 | createTestProjectInFile, 7 | createTestTaskInFile, 8 | verifyProjectInFile, 9 | verifyToolExecutionError, 10 | TestContext 11 | } from '../test-helpers.js'; 12 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 13 | describe('finalize_project Tool', () => { 14 | let context: TestContext; 15 | 16 | beforeAll(async () => { 17 | context = await setupTestContext(); 18 | }); 19 | 20 | afterAll(async () => { 21 | await teardownTestContext(context); 22 | }); 23 | 24 | describe('Success Cases', () => { 25 | it('should finalize a project with all tasks completed and approved', async () => { 26 | // Create a project with completed and approved tasks 27 | const project = await createTestProjectInFile(context.testFilePath, { 28 | initialPrompt: "Test Project", 29 | completed: false 30 | }); 31 | 32 | // Add completed and approved tasks 33 | await Promise.all([ 34 | createTestTaskInFile(context.testFilePath, project.projectId, { 35 | title: "Task 1", 36 | description: "First task", 37 | status: "done", 38 | approved: true, 39 | completedDetails: "Task 1 completed" 40 | }), 41 | createTestTaskInFile(context.testFilePath, project.projectId, { 42 | title: "Task 2", 43 | description: "Second task", 44 | status: "done", 45 | approved: true, 46 | completedDetails: "Task 2 completed" 47 | }) 48 | ]); 49 | 50 | // Finalize the project 51 | const result = await context.client.callTool({ 52 | name: "finalize_project", 53 | arguments: { 54 | projectId: project.projectId 55 | } 56 | }) as CallToolResult; 57 | 58 | // Verify response 59 | verifyCallToolResult(result); 60 | expect(result.isError).toBeFalsy(); 61 | 62 | // Verify project state in file 63 | await verifyProjectInFile(context.testFilePath, project.projectId, { 64 | completed: true 65 | }); 66 | }); 67 | 68 | it('should finalize a project with auto-approved tasks', async () => { 69 | // Create a project with auto-approve enabled 70 | const project = await createTestProjectInFile(context.testFilePath, { 71 | initialPrompt: "Auto-approve Project", 72 | autoApprove: true, 73 | completed: false 74 | }); 75 | 76 | // Add completed tasks (they should be auto-approved) 77 | await Promise.all([ 78 | createTestTaskInFile(context.testFilePath, project.projectId, { 79 | title: "Auto Task 1", 80 | description: "First auto-approved task", 81 | status: "done", 82 | approved: true, 83 | completedDetails: "Auto task 1 completed" 84 | }), 85 | createTestTaskInFile(context.testFilePath, project.projectId, { 86 | title: "Auto Task 2", 87 | description: "Second auto-approved task", 88 | status: "done", 89 | approved: true, 90 | completedDetails: "Auto task 2 completed" 91 | }) 92 | ]); 93 | 94 | const result = await context.client.callTool({ 95 | name: "finalize_project", 96 | arguments: { 97 | projectId: project.projectId 98 | } 99 | }) as CallToolResult; 100 | 101 | verifyCallToolResult(result); 102 | expect(result.isError).toBeFalsy(); 103 | 104 | await verifyProjectInFile(context.testFilePath, project.projectId, { 105 | completed: true, 106 | autoApprove: true 107 | }); 108 | }); 109 | }); 110 | 111 | describe('Error Cases', () => { 112 | it('should return error when project has incomplete tasks', async () => { 113 | const project = await createTestProjectInFile(context.testFilePath, { 114 | projectId: "proj-1", 115 | initialPrompt: "open project", 116 | projectPlan: "test", 117 | tasks: [{ 118 | id: "task-1", 119 | title: "open task", 120 | description: "test", 121 | status: "not started", 122 | approved: false, 123 | completedDetails: "" 124 | }] 125 | }); 126 | 127 | const result = await context.client.callTool({ 128 | name: "finalize_project", 129 | arguments: { 130 | projectId: project.projectId 131 | } 132 | }) as CallToolResult; 133 | 134 | verifyToolExecutionError(result, /Not all tasks are done/); 135 | 136 | // Verify project remains incomplete 137 | await verifyProjectInFile(context.testFilePath, project.projectId, { 138 | completed: false 139 | }); 140 | }); 141 | 142 | it('should return error when project has unapproved tasks', async () => { 143 | const project = await createTestProjectInFile(context.testFilePath, { 144 | projectId: "proj-2", 145 | initialPrompt: "pending approval project", 146 | projectPlan: "test", 147 | tasks: [{ 148 | id: "task-2", 149 | title: "pending approval task", 150 | description: "test", 151 | status: "done", 152 | approved: false, 153 | completedDetails: "completed" 154 | }] 155 | }); 156 | 157 | const result = await context.client.callTool({ 158 | name: "finalize_project", 159 | arguments: { 160 | projectId: project.projectId 161 | } 162 | }) as CallToolResult; 163 | 164 | verifyToolExecutionError(result, /Not all done tasks are approved/); 165 | 166 | await verifyProjectInFile(context.testFilePath, project.projectId, { 167 | completed: false 168 | }); 169 | }); 170 | 171 | it('should return error when project is already completed', async () => { 172 | const project = await createTestProjectInFile(context.testFilePath, { 173 | projectId: "proj-3", 174 | initialPrompt: "completed project", 175 | projectPlan: "test", 176 | completed: true, 177 | tasks: [{ 178 | id: "task-3", 179 | title: "completed task", 180 | description: "test", 181 | status: "done", 182 | approved: true, 183 | completedDetails: "completed" 184 | }] 185 | }); 186 | 187 | const result = await context.client.callTool({ 188 | name: "finalize_project", 189 | arguments: { 190 | projectId: project.projectId 191 | } 192 | }) as CallToolResult; 193 | 194 | verifyToolExecutionError(result, /Project is already completed/); 195 | }); 196 | 197 | it('should return error for non-existent project', async () => { 198 | const result = await context.client.callTool({ 199 | name: "finalize_project", 200 | arguments: { 201 | projectId: "non_existent_project" 202 | } 203 | }) as CallToolResult; 204 | 205 | verifyToolExecutionError(result, /Project non_existent_project not found/); 206 | }); 207 | 208 | it('should return error for invalid project ID format', async () => { 209 | const result = await context.client.callTool({ 210 | name: "finalize_project", 211 | arguments: { 212 | projectId: "invalid-format" 213 | } 214 | }) as CallToolResult; 215 | 216 | verifyToolExecutionError(result, /Project invalid-format not found/); 217 | }); 218 | }); 219 | }); -------------------------------------------------------------------------------- /tests/mcp/tools/generate-project-plan.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from '@jest/globals'; 2 | import { 3 | setupTestContext, 4 | teardownTestContext, 5 | verifyCallToolResult, 6 | verifyToolExecutionError, 7 | } from '../test-helpers.js'; 8 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 9 | import * as path from 'node:path'; 10 | import * as fs from 'node:fs/promises'; 11 | 12 | describe('generate_project_plan Tool', () => { 13 | describe('OpenAI Provider', () => { 14 | // Skip by default as it requires OpenAI API key 15 | it.skip('should generate a project plan using OpenAI', async () => { 16 | // Create context with default API keys 17 | const context = await setupTestContext(); 18 | 19 | try { 20 | // Skip if no OpenAI API key is set 21 | const openaiApiKey = process.env.OPENAI_API_KEY; 22 | if (!openaiApiKey) { 23 | console.error('Skipping test: OPENAI_API_KEY not set'); 24 | return; 25 | } 26 | 27 | // Create a temporary requirements file 28 | const requirementsPath = path.join(context.tempDir, 'requirements.md'); 29 | const requirements = `# Project Plan Requirements 30 | 31 | - This is a test of whether we are correctly attaching files to our prompt 32 | - Return a JSON project plan with one task 33 | - Task title must be 'AmazingTask' 34 | - Task description must be AmazingDescription 35 | - Project plan attribute should be AmazingPlan`; 36 | 37 | await fs.writeFile(requirementsPath, requirements, 'utf-8'); 38 | 39 | // Test prompt and context 40 | const testPrompt = "Create a step-by-step project plan to build a simple TODO app with React"; 41 | 42 | // Generate project plan 43 | const result = await context.client.callTool({ 44 | name: "generate_project_plan", 45 | arguments: { 46 | prompt: testPrompt, 47 | provider: "openai", 48 | model: "gpt-4o-mini", 49 | attachments: [requirementsPath] 50 | } 51 | }) as CallToolResult; 52 | 53 | verifyCallToolResult(result); 54 | expect(result.isError).toBeFalsy(); 55 | 56 | const planData = JSON.parse((result.content[0] as { text: string }).text); 57 | 58 | // Verify the generated plan structure 59 | expect(planData).toHaveProperty('tasks'); 60 | expect(Array.isArray(planData.tasks)).toBe(true); 61 | expect(planData.tasks.length).toBeGreaterThan(0); 62 | 63 | // Verify task structure 64 | const firstTask = planData.tasks[0]; 65 | expect(firstTask).toHaveProperty('title'); 66 | expect(firstTask).toHaveProperty('description'); 67 | 68 | // Verify that the generated task adheres to the requirements file context 69 | expect(firstTask.title).toBe('AmazingTask'); 70 | expect(firstTask.description).toBe('AmazingDescription'); 71 | } finally { 72 | await teardownTestContext(context); 73 | } 74 | }); 75 | 76 | it('should handle OpenAI API errors gracefully', async () => { 77 | // Create a new context without the OpenAI API key 78 | const context = await setupTestContext(undefined, false, { 79 | OPENAI_API_KEY: '', 80 | GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? '' 81 | }); 82 | 83 | try { 84 | const result = await context.client.callTool({ 85 | name: "generate_project_plan", 86 | arguments: { 87 | prompt: "Test prompt", 88 | provider: "openai", 89 | model: "gpt-4o-mini", 90 | // Invalid/missing API key should cause an error 91 | } 92 | }) as CallToolResult; 93 | 94 | verifyToolExecutionError(result, /Tool execution failed: Missing API key environment variable required for openai/); 95 | } finally { 96 | await teardownTestContext(context); 97 | } 98 | }); 99 | }); 100 | 101 | describe('Google Provider', () => { 102 | // Skip by default as it requires Google API key 103 | it.skip('should generate a project plan using Google Gemini', async () => { 104 | // Create context with default API keys 105 | const context = await setupTestContext(); 106 | 107 | try { 108 | // Skip if no Google API key is set 109 | const googleApiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; 110 | if (!googleApiKey) { 111 | console.error('Skipping test: GOOGLE_GENERATIVE_AI_API_KEY not set'); 112 | return; 113 | } 114 | 115 | // Create a temporary requirements file 116 | const requirementsPath = path.join(context.tempDir, 'google-requirements.md'); 117 | const requirements = `# Project Plan Requirements (Google Test) 118 | 119 | - This is a test of whether we are correctly attaching files to our prompt for Google models 120 | - Return a JSON project plan with one task 121 | - Task title must be 'GeminiTask' 122 | - Task description must be 'GeminiDescription' 123 | - Project plan attribute should be 'GeminiPlan'`; 124 | 125 | await fs.writeFile(requirementsPath, requirements, 'utf-8'); 126 | 127 | // Test prompt and context 128 | const testPrompt = "Create a step-by-step project plan to develop a cloud-native microservice using Go"; 129 | 130 | // Generate project plan using Google Gemini 131 | const result = await context.client.callTool({ 132 | name: "generate_project_plan", 133 | arguments: { 134 | prompt: testPrompt, 135 | provider: "google", 136 | model: "gemini-2.0-flash-001", 137 | attachments: [requirementsPath] 138 | } 139 | }) as CallToolResult; 140 | 141 | verifyCallToolResult(result); 142 | expect(result.isError).toBeFalsy(); 143 | 144 | const planData = JSON.parse((result.content[0] as { text: string }).text); 145 | 146 | // Verify the generated plan structure 147 | expect(planData).toHaveProperty('tasks'); 148 | expect(Array.isArray(planData.tasks)).toBe(true); 149 | expect(planData.tasks.length).toBeGreaterThan(0); 150 | 151 | // Verify task structure 152 | const firstTask = planData.tasks[0]; 153 | expect(firstTask).toHaveProperty('title'); 154 | expect(firstTask).toHaveProperty('description'); 155 | 156 | // Verify that the generated task adheres to the requirements file context 157 | expect(firstTask.title).toBe('GeminiTask'); 158 | expect(firstTask.description).toBe('GeminiDescription'); 159 | } finally { 160 | await teardownTestContext(context); 161 | } 162 | }); 163 | 164 | it('should handle Google API errors gracefully', async () => { 165 | // Create a new context without the Google API key 166 | const context = await setupTestContext(undefined, false, { 167 | OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? '', 168 | GOOGLE_GENERATIVE_AI_API_KEY: '' 169 | }); 170 | 171 | try { 172 | const result = await context.client.callTool({ 173 | name: "generate_project_plan", 174 | arguments: { 175 | prompt: "Test prompt", 176 | provider: "google", 177 | model: "gemini-1.5-flash-latest", 178 | // Invalid/missing API key should cause an error 179 | } 180 | }) as CallToolResult; 181 | 182 | verifyToolExecutionError(result, /Tool execution failed: Missing API key environment variable required for google/); 183 | } finally { 184 | await teardownTestContext(context); 185 | } 186 | }); 187 | }); 188 | 189 | describe('Deepseek Provider', () => { 190 | // Skip by default as it requires Deepseek API key 191 | it.skip('should generate a project plan using Deepseek', async () => { 192 | // Create context with default API keys 193 | const context = await setupTestContext(); 194 | 195 | try { 196 | // Skip if no Deepseek API key is set 197 | const deepseekApiKey = process.env.DEEPSEEK_API_KEY; 198 | if (!deepseekApiKey) { 199 | console.error('Skipping test: DEEPSEEK_API_KEY not set'); 200 | return; 201 | } 202 | 203 | // Create a temporary requirements file 204 | const requirementsPath = path.join(context.tempDir, 'deepseek-requirements.md'); 205 | const requirements = `# Project Plan Requirements (Deepseek Test) 206 | 207 | - This is a test of whether we are correctly attaching files to our prompt for Deepseek models 208 | - Return a JSON project plan with one task 209 | - Task title must be 'DeepseekTask' 210 | - Task description must be 'DeepseekDescription' 211 | - Project plan attribute should be 'DeepseekPlan'`; 212 | 213 | await fs.writeFile(requirementsPath, requirements, 'utf-8'); 214 | 215 | // Test prompt and context 216 | const testPrompt = "Create a step-by-step project plan to build a machine learning pipeline"; 217 | 218 | // Generate project plan using Deepseek 219 | const result = await context.client.callTool({ 220 | name: "generate_project_plan", 221 | arguments: { 222 | prompt: testPrompt, 223 | provider: "deepseek", 224 | model: "deepseek-chat", 225 | attachments: [requirementsPath] 226 | } 227 | }) as CallToolResult; 228 | verifyCallToolResult(result); 229 | expect(result.isError).toBeFalsy(); 230 | 231 | const planData = JSON.parse((result.content[0] as { text: string }).text); 232 | 233 | // Verify the generated plan structure 234 | expect(planData).toHaveProperty('data'); 235 | expect(planData).toHaveProperty('tasks'); 236 | expect(Array.isArray(planData.tasks)).toBe(true); 237 | expect(planData.tasks.length).toBeGreaterThan(0); 238 | 239 | // Verify task structure 240 | const firstTask = planData.tasks[0]; 241 | expect(firstTask).toHaveProperty('title'); 242 | expect(firstTask).toHaveProperty('description'); 243 | 244 | // Verify that the generated task adheres to the requirements file context 245 | expect(firstTask.title).toBe('DeepseekTask'); 246 | expect(firstTask.description).toBe('DeepseekDescription'); 247 | } finally { 248 | await teardownTestContext(context); 249 | } 250 | }); 251 | 252 | it('should handle Deepseek API errors gracefully', async () => { 253 | // Create a new context without the Deepseek API key 254 | const context = await setupTestContext(undefined, false, { 255 | OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? '', 256 | GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? '', 257 | DEEPSEEK_API_KEY: '' 258 | }); 259 | 260 | try { 261 | const result = await context.client.callTool({ 262 | name: "generate_project_plan", 263 | arguments: { 264 | prompt: "Test prompt", 265 | provider: "deepseek", 266 | model: "deepseek-chat", 267 | // Invalid/missing API key should cause an error 268 | } 269 | }) as CallToolResult; 270 | 271 | verifyToolExecutionError(result, /Tool execution failed: Missing API key environment variable required for deepseek/); 272 | } finally { 273 | await teardownTestContext(context); 274 | } 275 | }); 276 | }); 277 | 278 | describe('Error Cases', () => { 279 | it('should return error for invalid provider', async () => { 280 | const context = await setupTestContext(); 281 | 282 | try { 283 | const result = await context.client.callTool({ 284 | name: "generate_project_plan", 285 | arguments: { 286 | prompt: "Test prompt", 287 | provider: "invalid_provider", 288 | model: "some-model" 289 | } 290 | }) as CallToolResult; 291 | 292 | verifyToolExecutionError(result, /Tool execution failed: Invalid provider: invalid_provider/); 293 | } finally { 294 | await teardownTestContext(context); 295 | } 296 | }); 297 | 298 | // Skip by default as it requires OpenAI API key 299 | it.skip('should return error for invalid model', async () => { 300 | const context = await setupTestContext(); 301 | 302 | try { 303 | const result = await context.client.callTool({ 304 | name: "generate_project_plan", 305 | arguments: { 306 | prompt: "Test prompt", 307 | provider: "openai", 308 | model: "invalid-model" 309 | } 310 | }) as CallToolResult; 311 | 312 | verifyToolExecutionError(result, /Tool execution failed: Invalid model: invalid-model is not available for openai/); 313 | } finally { 314 | await teardownTestContext(context); 315 | } 316 | }); 317 | 318 | it('should return error for non-existent attachment file', async () => { 319 | const context = await setupTestContext(); 320 | 321 | try { 322 | const result = await context.client.callTool({ 323 | name: "generate_project_plan", 324 | arguments: { 325 | prompt: "Test prompt", 326 | provider: "openai", 327 | model: "gpt-4o-mini", 328 | attachments: ["/non/existent/file.md"] 329 | } 330 | }) as CallToolResult; 331 | 332 | verifyToolExecutionError(result, /Tool execution failed: Failed to read attachment file/); 333 | } finally { 334 | await teardownTestContext(context); 335 | } 336 | }); 337 | }); 338 | }); -------------------------------------------------------------------------------- /tests/mcp/tools/get-next-task.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; 2 | import { 3 | setupTestContext, 4 | teardownTestContext, 5 | verifyToolExecutionError, 6 | verifyToolSuccessResponse, 7 | createTestProjectInFile, 8 | createTestTaskInFile, 9 | readTaskManagerFile, 10 | TestContext 11 | } from '../test-helpers.js'; 12 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 13 | import { Task } from "../../../src/types/data.js"; 14 | 15 | interface GetNextTaskResponse { 16 | task: Task; 17 | projectId: string; 18 | } 19 | 20 | describe('get_next_task Tool', () => { 21 | let context: TestContext; 22 | 23 | beforeAll(async () => { 24 | context = await setupTestContext(); 25 | }); 26 | 27 | afterAll(async () => { 28 | await teardownTestContext(context); 29 | }); 30 | 31 | describe('Success Cases', () => { 32 | it('should get first task when no tasks are started', async () => { 33 | // Create a project with multiple unstarted tasks 34 | const project = await createTestProjectInFile(context.testFilePath, { 35 | initialPrompt: "Test Project" 36 | }); 37 | 38 | // Create tasks sequentially to ensure order 39 | const task1 = await createTestTaskInFile(context.testFilePath, project.projectId, { 40 | title: "Task 1", 41 | description: "First task", 42 | status: "not started" 43 | }); 44 | const task2 = await createTestTaskInFile(context.testFilePath, project.projectId, { 45 | title: "Task 2", 46 | description: "Second task", 47 | status: "not started" 48 | }); 49 | const tasks = [task1, task2]; 50 | 51 | // Verify tasks are in expected order in the file 52 | const fileData = await readTaskManagerFile(context.testFilePath); 53 | const projectInFile = fileData.projects.find((p: { projectId: string }) => p.projectId === project.projectId); 54 | expect(projectInFile?.tasks[0].title).toBe("Task 1"); 55 | expect(projectInFile?.tasks[1].title).toBe("Task 2"); 56 | 57 | // Get next task 58 | const result = await context.client.callTool({ 59 | name: "get_next_task", 60 | arguments: { 61 | projectId: project.projectId 62 | } 63 | }) as CallToolResult; 64 | 65 | const responseData = verifyToolSuccessResponse(result); 66 | expect(responseData.task).toMatchObject({ 67 | id: tasks[0].id, 68 | title: "Task 1", 69 | status: "not started" 70 | }); 71 | }); 72 | 73 | it('should get next incomplete task after completed tasks', async () => { 74 | const project = await createTestProjectInFile(context.testFilePath, { 75 | initialPrompt: "Sequential Tasks" 76 | }); 77 | 78 | // Create tasks with first one completed 79 | await createTestTaskInFile(context.testFilePath, project.projectId, { 80 | title: "Done Task", 81 | description: "Already completed", 82 | status: "done", 83 | approved: true, 84 | completedDetails: "Completed first" 85 | }); 86 | const nextTask = await createTestTaskInFile(context.testFilePath, project.projectId, { 87 | title: "Next Task", 88 | description: "Should be next", 89 | status: "not started" 90 | }); 91 | 92 | const result = await context.client.callTool({ 93 | name: "get_next_task", 94 | arguments: { 95 | projectId: project.projectId 96 | } 97 | }) as CallToolResult; 98 | 99 | const responseData = verifyToolSuccessResponse(result); 100 | expect(responseData.task).toMatchObject({ 101 | id: nextTask.id, 102 | title: "Next Task", 103 | status: "not started" 104 | }); 105 | }); 106 | 107 | it('should get in-progress task if one exists', async () => { 108 | const project = await createTestProjectInFile(context.testFilePath, { 109 | initialPrompt: "Project with In-progress Task" 110 | }); 111 | 112 | // Create multiple tasks with one in progress 113 | await createTestTaskInFile(context.testFilePath, project.projectId, { 114 | title: "Done Task", 115 | description: "Already completed", 116 | status: "done", 117 | approved: true, 118 | completedDetails: "Completed" 119 | }); 120 | const inProgressTask = await createTestTaskInFile(context.testFilePath, project.projectId, { 121 | title: "Current Task", 122 | description: "In progress", 123 | status: "in progress" 124 | }); 125 | await createTestTaskInFile(context.testFilePath, project.projectId, { 126 | title: "Future Task", 127 | description: "Not started yet", 128 | status: "not started" 129 | }); 130 | 131 | const result = await context.client.callTool({ 132 | name: "get_next_task", 133 | arguments: { 134 | projectId: project.projectId 135 | } 136 | }) as CallToolResult; 137 | 138 | const responseData = verifyToolSuccessResponse(result); 139 | expect(responseData.task).toMatchObject({ 140 | id: inProgressTask.id, 141 | title: "Current Task", 142 | status: "in progress" 143 | }); 144 | }); 145 | 146 | it('should return error when all tasks are completed', async () => { 147 | const project = await createTestProjectInFile(context.testFilePath, { 148 | initialPrompt: "Completed Project", 149 | completed: true 150 | }); 151 | 152 | // Create only completed tasks 153 | await Promise.all([ 154 | createTestTaskInFile(context.testFilePath, project.projectId, { 155 | title: "Task 1", 156 | description: "First done", 157 | status: "done", 158 | approved: true, 159 | completedDetails: "Done" 160 | }), 161 | createTestTaskInFile(context.testFilePath, project.projectId, { 162 | title: "Task 2", 163 | description: "Second done", 164 | status: "done", 165 | approved: true, 166 | completedDetails: "Done" 167 | }) 168 | ]); 169 | 170 | const result = await context.client.callTool({ 171 | name: "get_next_task", 172 | arguments: { 173 | projectId: project.projectId 174 | } 175 | }) as CallToolResult; 176 | 177 | verifyToolExecutionError(result, /Tool execution failed: Project is already completed/); 178 | }); 179 | }); 180 | 181 | describe('Error Cases', () => { 182 | it('should return error for non-existent project', async () => { 183 | const result = await context.client.callTool({ 184 | name: "get_next_task", 185 | arguments: { 186 | projectId: "non_existent_project" 187 | } 188 | }) as CallToolResult; 189 | 190 | verifyToolExecutionError(result, /Tool execution failed: Project non_existent_project not found/); 191 | }); 192 | 193 | it('should return error for invalid project ID format', async () => { 194 | const result = await context.client.callTool({ 195 | name: "get_next_task", 196 | arguments: { 197 | projectId: "invalid-format" 198 | } 199 | }) as CallToolResult; 200 | 201 | verifyToolExecutionError(result, /Tool execution failed: Project invalid-format not found/); 202 | }); 203 | 204 | it('should return error for project with no tasks', async () => { 205 | const project = await createTestProjectInFile(context.testFilePath, { 206 | initialPrompt: "Empty Project", 207 | tasks: [] 208 | }); 209 | 210 | const result = await context.client.callTool({ 211 | name: "get_next_task", 212 | arguments: { 213 | projectId: project.projectId 214 | } 215 | }) as CallToolResult; 216 | 217 | verifyToolExecutionError(result, /Tool execution failed: Project has no tasks/); 218 | }); 219 | }); 220 | }); -------------------------------------------------------------------------------- /tests/mcp/tools/list-projects.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; 2 | import { 3 | setupTestContext, 4 | teardownTestContext, 5 | verifyCallToolResult, 6 | verifyToolExecutionError, 7 | createTestProject, 8 | getFirstTaskId, 9 | TestContext 10 | } from '../test-helpers.js'; 11 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 12 | import path from 'path'; 13 | import os from 'os'; 14 | 15 | describe('list_projects Tool', () => { 16 | describe('Success Cases', () => { 17 | let context: TestContext; 18 | 19 | beforeAll(async () => { 20 | context = await setupTestContext(); 21 | }); 22 | 23 | afterAll(async () => { 24 | await teardownTestContext(context); 25 | }); 26 | 27 | it('should list projects with no filters', async () => { 28 | // Create a test project first 29 | const projectId = await createTestProject(context.client); 30 | 31 | // Test list_projects 32 | const result = await context.client.callTool({ 33 | name: "list_projects", 34 | arguments: {} 35 | }) as CallToolResult; 36 | 37 | // Verify response format 38 | verifyCallToolResult(result); 39 | expect(result.isError).toBeFalsy(); 40 | 41 | // Parse and verify response data 42 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 43 | expect(responseData).toHaveProperty('message'); 44 | expect(responseData).toHaveProperty('projects'); 45 | expect(Array.isArray(responseData.projects)).toBe(true); 46 | 47 | // Verify our test project is in the list 48 | const projects = responseData.projects; 49 | const testProject = projects.find((p: any) => p.projectId === projectId); 50 | expect(testProject).toBeDefined(); 51 | expect(testProject).toHaveProperty('initialPrompt'); 52 | expect(testProject).toHaveProperty('totalTasks'); 53 | expect(testProject).toHaveProperty('completedTasks'); 54 | expect(testProject).toHaveProperty('approvedTasks'); 55 | }); 56 | 57 | it('should filter projects by state', async () => { 58 | // Create two projects with different states 59 | const openProjectId = await createTestProject(context.client, { 60 | initialPrompt: "Open Project", 61 | tasks: [{ title: "Open Task", description: "This task will remain open" }] 62 | }); 63 | 64 | const completedProjectId = await createTestProject(context.client, { 65 | initialPrompt: "Completed Project", 66 | tasks: [{ title: "Done Task", description: "This task will be completed" }], 67 | autoApprove: true 68 | }); 69 | 70 | // Complete the second project's task 71 | const taskId = await getFirstTaskId(context.client, completedProjectId); 72 | await context.client.callTool({ 73 | name: "update_task", 74 | arguments: { 75 | projectId: completedProjectId, 76 | taskId, 77 | status: "done", 78 | completedDetails: "Task completed in test" 79 | } 80 | }); 81 | 82 | // Approve and finalize the project 83 | await context.client.callTool({ 84 | name: "approve_task", 85 | arguments: { 86 | projectId: completedProjectId, 87 | taskId 88 | } 89 | }); 90 | 91 | await context.client.callTool({ 92 | name: "finalize_project", 93 | arguments: { 94 | projectId: completedProjectId 95 | } 96 | }); 97 | 98 | // Test filtering by 'open' state 99 | const openResult = await context.client.callTool({ 100 | name: "list_projects", 101 | arguments: { state: "open" } 102 | }) as CallToolResult; 103 | 104 | verifyCallToolResult(openResult); 105 | const openData = JSON.parse((openResult.content[0] as { text: string }).text); 106 | const openProjects = openData.projects; 107 | expect(openProjects.some((p: any) => p.projectId === openProjectId)).toBe(true); 108 | expect(openProjects.some((p: any) => p.projectId === completedProjectId)).toBe(false); 109 | 110 | // Test filtering by 'completed' state 111 | const completedResult = await context.client.callTool({ 112 | name: "list_projects", 113 | arguments: { state: "completed" } 114 | }) as CallToolResult; 115 | 116 | verifyCallToolResult(completedResult); 117 | const completedData = JSON.parse((completedResult.content[0] as { text: string }).text); 118 | const completedProjects = completedData.projects; 119 | expect(completedProjects.some((p: any) => p.projectId === completedProjectId)).toBe(true); 120 | expect(completedProjects.some((p: any) => p.projectId === openProjectId)).toBe(false); 121 | }); 122 | }); 123 | 124 | describe('Error Cases', () => { 125 | describe('Validation Errors', () => { 126 | let context: TestContext; 127 | 128 | beforeAll(async () => { 129 | context = await setupTestContext(); 130 | }); 131 | 132 | afterAll(async () => { 133 | await teardownTestContext(context); 134 | }); 135 | 136 | it('should handle invalid state parameter', async () => { 137 | const result = await context.client.callTool({ 138 | name: "list_projects", 139 | arguments: { state: "invalid_state" } 140 | }) as CallToolResult; 141 | 142 | verifyToolExecutionError(result, /Invalid state parameter. Must be one of: open, pending_approval, completed, all/); 143 | }); 144 | }); 145 | 146 | describe('File System Errors', () => { 147 | let errorContext: TestContext; 148 | const invalidPathDir = path.join(os.tmpdir(), 'nonexistent-dir'); 149 | const invalidFilePath = path.join(invalidPathDir, 'invalid-file.json'); 150 | 151 | beforeAll(async () => { 152 | // Set up test context with invalid file path, skipping file initialization 153 | errorContext = await setupTestContext(invalidFilePath, true); 154 | }); 155 | 156 | afterAll(async () => { 157 | await teardownTestContext(errorContext); 158 | }); 159 | 160 | it('should handle server errors gracefully', async () => { 161 | const result = await errorContext.client.callTool({ 162 | name: "list_projects", 163 | arguments: {} 164 | }) as CallToolResult; 165 | 166 | verifyToolExecutionError(result, /Failed to reload tasks from disk/); 167 | }); 168 | }); 169 | }); 170 | }); -------------------------------------------------------------------------------- /tests/mcp/tools/list-tasks.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; 2 | import { 3 | setupTestContext, 4 | teardownTestContext, 5 | verifyCallToolResult, 6 | verifyToolExecutionError, 7 | createTestProject, 8 | getFirstTaskId, 9 | TestContext 10 | } from '../test-helpers.js'; 11 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 12 | import path from 'path'; 13 | import os from 'os'; 14 | 15 | describe('list_tasks Tool', () => { 16 | describe('Success Cases', () => { 17 | let context: TestContext; 18 | 19 | beforeAll(async () => { 20 | context = await setupTestContext(); 21 | }); 22 | 23 | afterAll(async () => { 24 | await teardownTestContext(context); 25 | }); 26 | 27 | it('should list all tasks with no filters', async () => { 28 | // Create a test project with tasks 29 | const projectId = await createTestProject(context.client, { 30 | initialPrompt: "Test Project", 31 | tasks: [ 32 | { title: "Task 1", description: "First test task" }, 33 | { title: "Task 2", description: "Second test task" } 34 | ] 35 | }); 36 | 37 | // Test list_tasks with no filters 38 | const result = await context.client.callTool({ 39 | name: "list_tasks", 40 | arguments: {} 41 | }) as CallToolResult; 42 | 43 | // Verify response format 44 | verifyCallToolResult(result); 45 | expect(result.isError).toBeFalsy(); 46 | 47 | // Parse and verify response data 48 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 49 | expect(responseData).toHaveProperty('message'); 50 | expect(responseData).toHaveProperty('tasks'); 51 | expect(Array.isArray(responseData.tasks)).toBe(true); 52 | expect(responseData.tasks.length).toBe(2); 53 | 54 | // Verify task properties 55 | const tasks = responseData.tasks; 56 | tasks.forEach((task: any) => { 57 | expect(task).toHaveProperty('id'); 58 | expect(task).toHaveProperty('title'); 59 | expect(task).toHaveProperty('description'); 60 | expect(task).toHaveProperty('status'); 61 | expect(task).toHaveProperty('approved'); 62 | }); 63 | }); 64 | 65 | it('should filter tasks by project ID', async () => { 66 | // Create two projects with different tasks 67 | const project1Id = await createTestProject(context.client, { 68 | initialPrompt: "Project 1", 69 | tasks: [{ title: "P1 Task", description: "Project 1 task" }] 70 | }); 71 | 72 | const project2Id = await createTestProject(context.client, { 73 | initialPrompt: "Project 2", 74 | tasks: [{ title: "P2 Task", description: "Project 2 task" }] 75 | }); 76 | 77 | // Test filtering by project1 78 | const result1 = await context.client.callTool({ 79 | name: "list_tasks", 80 | arguments: { projectId: project1Id } 81 | }) as CallToolResult; 82 | 83 | verifyCallToolResult(result1); 84 | const data1 = JSON.parse((result1.content[0] as { text: string }).text); 85 | expect(data1.tasks.length).toBe(1); 86 | expect(data1.tasks[0].title).toBe("P1 Task"); 87 | 88 | // Test filtering by project2 89 | const result2 = await context.client.callTool({ 90 | name: "list_tasks", 91 | arguments: { projectId: project2Id } 92 | }) as CallToolResult; 93 | 94 | verifyCallToolResult(result2); 95 | const data2 = JSON.parse((result2.content[0] as { text: string }).text); 96 | expect(data2.tasks.length).toBe(1); 97 | expect(data2.tasks[0].title).toBe("P2 Task"); 98 | }); 99 | 100 | it('should filter tasks by state', async () => { 101 | // Create a project with tasks in different states 102 | const projectId = await createTestProject(context.client, { 103 | initialPrompt: "Mixed States Project", 104 | tasks: [ 105 | { title: "Not Started Task", description: "This task will remain not started" }, 106 | { title: "Done But Not Approved Task", description: "This task will be done but not approved" }, 107 | { title: "Completed And Approved Task", description: "This task will be completed and approved" } 108 | ] 109 | }); 110 | 111 | // Get task IDs for each task 112 | const tasks = (await context.client.callTool({ 113 | name: "list_tasks", 114 | arguments: { projectId } 115 | }) as CallToolResult); 116 | const [notStartedTaskId, doneNotApprovedTaskId, completedTaskId] = JSON.parse((tasks.content[0] as { text: string }).text) 117 | .tasks.map((t: any) => t.id); 118 | 119 | // Set up task states: 120 | // 1. Leave first task as is (not started) 121 | // 2. Mark second task as done (but not approved) 122 | await context.client.callTool({ 123 | name: "update_task", 124 | arguments: { 125 | projectId, 126 | taskId: doneNotApprovedTaskId, 127 | status: "done", 128 | completedDetails: "Task completed in test" 129 | } 130 | }); 131 | 132 | // 3. Mark third task as done and approved 133 | await context.client.callTool({ 134 | name: "update_task", 135 | arguments: { 136 | projectId, 137 | taskId: completedTaskId, 138 | status: "done", 139 | completedDetails: "Task completed in test" 140 | } 141 | }); 142 | 143 | await context.client.callTool({ 144 | name: "approve_task", 145 | arguments: { 146 | projectId, 147 | taskId: completedTaskId 148 | } 149 | }); 150 | 151 | // Test filtering by 'open' state - should include both not started and done-but-not-approved tasks 152 | const openResult = await context.client.callTool({ 153 | name: "list_tasks", 154 | arguments: { 155 | projectId, 156 | state: "open" 157 | } 158 | }) as CallToolResult; 159 | 160 | verifyCallToolResult(openResult); 161 | const openData = JSON.parse((openResult.content[0] as { text: string }).text); 162 | expect(openData.tasks.some((t: any) => t.title === "Not Started Task")).toBe(true); 163 | expect(openData.tasks.some((t: any) => t.title === "Done But Not Approved Task")).toBe(true); 164 | expect(openData.tasks.some((t: any) => t.title === "Completed And Approved Task")).toBe(false); 165 | expect(openData.tasks.length).toBe(2); // Should have both non-approved tasks 166 | 167 | // Test filtering by 'pending_approval' state 168 | const pendingResult = await context.client.callTool({ 169 | name: "list_tasks", 170 | arguments: { 171 | projectId, 172 | state: "pending_approval" 173 | } 174 | }) as CallToolResult; 175 | 176 | verifyCallToolResult(pendingResult); 177 | const pendingData = JSON.parse((pendingResult.content[0] as { text: string }).text); 178 | expect(pendingData.tasks.some((t: any) => t.title === "Done But Not Approved Task")).toBe(true); 179 | expect(pendingData.tasks.some((t: any) => t.title === "Not Started Task")).toBe(false); 180 | expect(pendingData.tasks.some((t: any) => t.title === "Completed And Approved Task")).toBe(false); 181 | expect(pendingData.tasks.length).toBe(1); // Should only have the done-but-not-approved task 182 | 183 | // Test filtering by 'completed' state 184 | const completedResult = await context.client.callTool({ 185 | name: "list_tasks", 186 | arguments: { 187 | projectId, 188 | state: "completed" 189 | } 190 | }) as CallToolResult; 191 | 192 | verifyCallToolResult(completedResult); 193 | const completedData = JSON.parse((completedResult.content[0] as { text: string }).text); 194 | expect(completedData.tasks.some((t: any) => t.title === "Completed And Approved Task")).toBe(true); 195 | expect(completedData.tasks.some((t: any) => t.title === "Not Started Task")).toBe(false); 196 | expect(completedData.tasks.some((t: any) => t.title === "Done But Not Approved Task")).toBe(false); 197 | expect(completedData.tasks.length).toBe(1); // Should only have the completed and approved task 198 | }); 199 | 200 | it('should combine project ID and state filters', async () => { 201 | // Create two projects with tasks in different states 202 | const project1Id = await createTestProject(context.client, { 203 | initialPrompt: "Project 1", 204 | tasks: [ 205 | { title: "P1 Not Started Task", description: "Project 1 not started task" }, 206 | { title: "P1 Completed Task", description: "Project 1 completed task" } 207 | ] 208 | }); 209 | 210 | const project2Id = await createTestProject(context.client, { 211 | initialPrompt: "Project 2", 212 | tasks: [ 213 | { title: "P2 Not Started Task", description: "Project 2 not started task" }, 214 | { title: "P2 Completed Task", description: "Project 2 completed task" } 215 | ] 216 | }); 217 | 218 | // Get task IDs for each project 219 | const p1Tasks = (await context.client.callTool({ 220 | name: "list_tasks", 221 | arguments: { projectId: project1Id } 222 | }) as CallToolResult); 223 | const [p1OpenTaskId, p1CompletedTaskId] = JSON.parse((p1Tasks.content[0] as { text: string }).text) 224 | .tasks.map((t: any) => t.id); 225 | 226 | const p2Tasks = (await context.client.callTool({ 227 | name: "list_tasks", 228 | arguments: { projectId: project2Id } 229 | }) as CallToolResult); 230 | const [p2OpenTaskId, p2CompletedTaskId] = JSON.parse((p2Tasks.content[0] as { text: string }).text) 231 | .tasks.map((t: any) => t.id); 232 | 233 | // Complete and approve one task in each project 234 | await context.client.callTool({ 235 | name: "update_task", 236 | arguments: { 237 | projectId: project1Id, 238 | taskId: p1CompletedTaskId, 239 | status: "done", 240 | completedDetails: "Task completed in test" 241 | } 242 | }); 243 | 244 | await context.client.callTool({ 245 | name: "approve_task", 246 | arguments: { 247 | projectId: project1Id, 248 | taskId: p1CompletedTaskId 249 | } 250 | }); 251 | 252 | await context.client.callTool({ 253 | name: "update_task", 254 | arguments: { 255 | projectId: project2Id, 256 | taskId: p2CompletedTaskId, 257 | status: "done", 258 | completedDetails: "Task completed in test" 259 | } 260 | }); 261 | 262 | await context.client.callTool({ 263 | name: "approve_task", 264 | arguments: { 265 | projectId: project2Id, 266 | taskId: p2CompletedTaskId 267 | } 268 | }); 269 | 270 | // Test combined filtering - should only show non-approved tasks from project1 271 | const result = await context.client.callTool({ 272 | name: "list_tasks", 273 | arguments: { 274 | projectId: project1Id, 275 | state: "open" 276 | } 277 | }) as CallToolResult; 278 | 279 | verifyCallToolResult(result); 280 | const data = JSON.parse((result.content[0] as { text: string }).text); 281 | expect(data.tasks.length).toBe(1); 282 | expect(data.tasks[0].title).toBe("P1 Not Started Task"); 283 | }); 284 | }); 285 | 286 | describe('Error Cases', () => { 287 | describe('Validation Errors', () => { 288 | let context: TestContext; 289 | 290 | beforeAll(async () => { 291 | context = await setupTestContext(); 292 | }); 293 | 294 | afterAll(async () => { 295 | await teardownTestContext(context); 296 | }); 297 | 298 | it('should handle invalid state parameter', async () => { 299 | const result = await context.client.callTool({ 300 | name: "list_tasks", 301 | arguments: { state: "invalid_state" } 302 | }) as CallToolResult; 303 | 304 | verifyToolExecutionError(result, /Invalid state parameter. Must be one of: open, pending_approval, completed, all/); 305 | }); 306 | 307 | it('should handle invalid project ID', async () => { 308 | const result = await context.client.callTool({ 309 | name: "list_tasks", 310 | arguments: { projectId: "non-existent-project" } 311 | }) as CallToolResult; 312 | 313 | verifyToolExecutionError(result, /Project non-existent-project not found/); 314 | }); 315 | }); 316 | 317 | describe('File System Errors', () => { 318 | let errorContext: TestContext; 319 | const invalidPathDir = path.join(os.tmpdir(), 'nonexistent-dir'); 320 | const invalidFilePath = path.join(invalidPathDir, 'invalid-file.json'); 321 | 322 | beforeAll(async () => { 323 | // Set up test context with invalid file path, skipping file initialization 324 | errorContext = await setupTestContext(invalidFilePath, true); 325 | }); 326 | 327 | afterAll(async () => { 328 | await teardownTestContext(errorContext); 329 | }); 330 | 331 | it('should handle server errors gracefully', async () => { 332 | const result = await errorContext.client.callTool({ 333 | name: "list_tasks", 334 | arguments: {} 335 | }) as CallToolResult; 336 | 337 | verifyToolExecutionError(result, /Failed to reload tasks from disk/); 338 | }); 339 | }); 340 | }); 341 | }); -------------------------------------------------------------------------------- /tests/mcp/tools/read-project.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; 2 | import { 3 | setupTestContext, 4 | teardownTestContext, 5 | verifyCallToolResult, 6 | createTestProjectInFile, 7 | createTestTaskInFile, 8 | TestContext, 9 | verifyToolExecutionError 10 | } from '../test-helpers.js'; 11 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 12 | 13 | describe('read_project Tool', () => { 14 | let context: TestContext; 15 | 16 | beforeAll(async () => { 17 | context = await setupTestContext(); 18 | }); 19 | 20 | afterAll(async () => { 21 | await teardownTestContext(context); 22 | }); 23 | 24 | describe('Success Cases', () => { 25 | it('should read a project with minimal data', async () => { 26 | // Create a test project 27 | const project = await createTestProjectInFile(context.testFilePath, { 28 | initialPrompt: "Test Project", 29 | projectPlan: "", 30 | completed: false 31 | }); 32 | await createTestTaskInFile(context.testFilePath, project.projectId, { 33 | title: "Test Task", 34 | description: "Test Description" 35 | }); 36 | 37 | // Read the project 38 | const result = await context.client.callTool({ 39 | name: "read_project", 40 | arguments: { 41 | projectId: project.projectId 42 | } 43 | }) as CallToolResult; 44 | 45 | // Verify response 46 | verifyCallToolResult(result); 47 | expect(result.isError).toBeFalsy(); 48 | 49 | // Verify project data 50 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 51 | expect(responseData).toMatchObject({ 52 | projectId: project.projectId, 53 | initialPrompt: "Test Project", 54 | completed: false, 55 | tasks: [{ 56 | title: "Test Task", 57 | description: "Test Description", 58 | status: "not started", 59 | approved: false 60 | }] 61 | }); 62 | }); 63 | 64 | it('should read a project with all optional fields', async () => { 65 | const project = await createTestProjectInFile(context.testFilePath, { 66 | initialPrompt: "Full Project", 67 | projectPlan: "Detailed project plan", 68 | completed: false, 69 | autoApprove: true 70 | }); 71 | await createTestTaskInFile(context.testFilePath, project.projectId, { 72 | title: "Full Task", 73 | description: "Task with all fields", 74 | status: "done", 75 | approved: true, 76 | completedDetails: "Task completed", 77 | toolRecommendations: "Use these tools", 78 | ruleRecommendations: "Follow these rules" 79 | }); 80 | 81 | const result = await context.client.callTool({ 82 | name: "read_project", 83 | arguments: { 84 | projectId: project.projectId 85 | } 86 | }) as CallToolResult; 87 | 88 | verifyCallToolResult(result); 89 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 90 | expect(responseData).toMatchObject({ 91 | projectId: project.projectId, 92 | initialPrompt: "Full Project", 93 | projectPlan: "Detailed project plan", 94 | completed: false, 95 | autoApprove: true, 96 | tasks: [{ 97 | title: "Full Task", 98 | description: "Task with all fields", 99 | status: "done", 100 | approved: true, 101 | completedDetails: "Task completed", 102 | toolRecommendations: "Use these tools", 103 | ruleRecommendations: "Follow these rules" 104 | }] 105 | }); 106 | }); 107 | 108 | it('should read a completed project', async () => { 109 | const project = await createTestProjectInFile(context.testFilePath, { 110 | initialPrompt: "Completed Project", 111 | completed: true 112 | }); 113 | await createTestTaskInFile(context.testFilePath, project.projectId, { 114 | title: "Completed Task", 115 | description: "This task is done", 116 | status: "done", 117 | approved: true, 118 | completedDetails: "Task completed" 119 | }); 120 | 121 | const result = await context.client.callTool({ 122 | name: "read_project", 123 | arguments: { 124 | projectId: project.projectId 125 | } 126 | }) as CallToolResult; 127 | 128 | verifyCallToolResult(result); 129 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 130 | expect(responseData).toMatchObject({ 131 | projectId: project.projectId, 132 | completed: true, 133 | tasks: [{ 134 | status: "done", 135 | approved: true 136 | }] 137 | }); 138 | }); 139 | 140 | it('should read a project with multiple tasks', async () => { 141 | const project = await createTestProjectInFile(context.testFilePath, { 142 | initialPrompt: "Multi-task Project" 143 | }); 144 | 145 | // Create tasks in different states 146 | await Promise.all([ 147 | createTestTaskInFile(context.testFilePath, project.projectId, { 148 | title: "Task 1", 149 | description: "Not started", 150 | status: "not started" 151 | }), 152 | createTestTaskInFile(context.testFilePath, project.projectId, { 153 | title: "Task 2", 154 | description: "In progress", 155 | status: "in progress" 156 | }), 157 | createTestTaskInFile(context.testFilePath, project.projectId, { 158 | title: "Task 3", 159 | description: "Completed", 160 | status: "done", 161 | approved: true, 162 | completedDetails: "Done and approved" 163 | }) 164 | ]); 165 | 166 | const result = await context.client.callTool({ 167 | name: "read_project", 168 | arguments: { 169 | projectId: project.projectId 170 | } 171 | }) as CallToolResult; 172 | 173 | verifyCallToolResult(result); 174 | const responseData = JSON.parse((result.content[0] as { text: string }).text); 175 | expect(responseData.tasks).toHaveLength(3); 176 | expect(responseData.tasks.map((t: any) => t.status)).toEqual([ 177 | "not started", 178 | "in progress", 179 | "done" 180 | ]); 181 | }); 182 | }); 183 | 184 | describe('Error Cases', () => { 185 | it('should return error for non-existent project', async () => { 186 | const result = await context.client.callTool({ 187 | name: "read_project", 188 | arguments: { 189 | projectId: "non_existent_project" 190 | } 191 | }) as CallToolResult; 192 | 193 | verifyToolExecutionError(result, /Project non_existent_project not found/); 194 | }); 195 | 196 | it('should return error for invalid project ID format', async () => { 197 | const result = await context.client.callTool({ 198 | name: "read_project", 199 | arguments: { 200 | projectId: "invalid-format" 201 | } 202 | }) as CallToolResult; 203 | 204 | verifyToolExecutionError(result, /Project invalid-format not found/); 205 | }); 206 | }); 207 | }); -------------------------------------------------------------------------------- /tests/mcp/tools/read-task.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; 2 | import { 3 | setupTestContext, 4 | teardownTestContext, 5 | verifyToolExecutionError, 6 | verifyToolSuccessResponse, 7 | createTestProjectInFile, 8 | createTestTaskInFile, 9 | TestContext 10 | } from '../test-helpers.js'; 11 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 12 | import { Task } from "../../../src/types/data.js"; 13 | 14 | describe('read_task Tool', () => { 15 | let context: TestContext; 16 | 17 | beforeAll(async () => { 18 | context = await setupTestContext(); 19 | }); 20 | 21 | afterAll(async () => { 22 | await teardownTestContext(context); 23 | }); 24 | 25 | describe('Success Cases', () => { 26 | it('should successfully read an existing task', async () => { 27 | // Create a project with a task 28 | const project = await createTestProjectInFile(context.testFilePath, { 29 | initialPrompt: "Test Project" 30 | }); 31 | 32 | const task = await createTestTaskInFile(context.testFilePath, project.projectId, { 33 | title: "Test Task", 34 | description: "Task description", 35 | status: "not started" 36 | }); 37 | 38 | const result = await context.client.callTool({ 39 | name: "read_task", 40 | arguments: { 41 | projectId: project.projectId, 42 | taskId: task.id 43 | } 44 | }) as CallToolResult; 45 | 46 | const responseData = verifyToolSuccessResponse<{ task: Task }>(result); 47 | expect(responseData.task).toMatchObject({ 48 | id: task.id, 49 | title: "Test Task", 50 | description: "Task description", 51 | status: "not started" 52 | }); 53 | }); 54 | 55 | it('should read a completed task with all details', async () => { 56 | const project = await createTestProjectInFile(context.testFilePath, { 57 | initialPrompt: "Project with Completed Task" 58 | }); 59 | 60 | const task = await createTestTaskInFile(context.testFilePath, project.projectId, { 61 | title: "Completed Task", 62 | description: "A finished task", 63 | status: "done", 64 | approved: true, 65 | completedDetails: "Task was completed successfully", 66 | toolRecommendations: "Used tool X and Y", 67 | ruleRecommendations: "Applied rule Z" 68 | }); 69 | 70 | const result = await context.client.callTool({ 71 | name: "read_task", 72 | arguments: { 73 | projectId: project.projectId, 74 | taskId: task.id 75 | } 76 | }) as CallToolResult; 77 | 78 | const responseData = verifyToolSuccessResponse<{ task: Task }>(result); 79 | expect(responseData.task).toMatchObject({ 80 | id: task.id, 81 | title: "Completed Task", 82 | description: "A finished task", 83 | status: "done", 84 | approved: true, 85 | completedDetails: "Task was completed successfully", 86 | toolRecommendations: "Used tool X and Y", 87 | ruleRecommendations: "Applied rule Z" 88 | }); 89 | }); 90 | }); 91 | 92 | describe('Error Cases', () => { 93 | it('should return error for non-existent project', async () => { 94 | const result = await context.client.callTool({ 95 | name: "read_task", 96 | arguments: { 97 | projectId: "non_existent_project", 98 | taskId: "task-1" 99 | } 100 | }) as CallToolResult; 101 | 102 | verifyToolExecutionError(result, /Tool execution failed: Project non_existent_project not found/); 103 | }); 104 | 105 | it('should return error for non-existent task in existing project', async () => { 106 | const project = await createTestProjectInFile(context.testFilePath, { 107 | initialPrompt: "Test Project" 108 | }); 109 | 110 | const result = await context.client.callTool({ 111 | name: "read_task", 112 | arguments: { 113 | projectId: project.projectId, 114 | taskId: "non-existent-task" 115 | } 116 | }) as CallToolResult; 117 | 118 | verifyToolExecutionError(result, /Tool execution failed: Task non-existent-task not found/); 119 | }); 120 | 121 | it('should return error for invalid project ID format', async () => { 122 | const result = await context.client.callTool({ 123 | name: "read_task", 124 | arguments: { 125 | projectId: "invalid-format", 126 | taskId: "task-1" 127 | } 128 | }) as CallToolResult; 129 | 130 | verifyToolExecutionError(result, /Tool execution failed: Project invalid-format not found/); 131 | }); 132 | 133 | it('should return error for invalid task ID format', async () => { 134 | const project = await createTestProjectInFile(context.testFilePath, { 135 | initialPrompt: "Test Project" 136 | }); 137 | 138 | const result = await context.client.callTool({ 139 | name: "read_task", 140 | arguments: { 141 | projectId: project.projectId, 142 | taskId: "invalid-task-id" 143 | } 144 | }) as CallToolResult; 145 | 146 | verifyToolExecutionError(result, /Tool execution failed: Task invalid-task-id not found/); 147 | }); 148 | }); 149 | }); -------------------------------------------------------------------------------- /tests/mcp/tools/update-task.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; 2 | import { 3 | setupTestContext, 4 | teardownTestContext, 5 | verifyCallToolResult, 6 | createTestProjectInFile, 7 | createTestTaskInFile, 8 | verifyTaskInFile, 9 | TestContext, 10 | verifyProtocolError 11 | } from '../test-helpers.js'; 12 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 13 | import { verifyToolExecutionError } from '../test-helpers.js'; 14 | 15 | describe('update_task Tool', () => { 16 | let context: TestContext; 17 | 18 | beforeAll(async () => { 19 | context = await setupTestContext(); 20 | }); 21 | 22 | afterAll(async () => { 23 | await teardownTestContext(context); 24 | }); 25 | 26 | describe('Success Cases', () => { 27 | it('should update task status to in progress', async () => { 28 | // Create test data directly in file 29 | const project = await createTestProjectInFile(context.testFilePath, { 30 | initialPrompt: "Test Project" 31 | }); 32 | const task = await createTestTaskInFile(context.testFilePath, project.projectId, { 33 | title: "Test Task", 34 | status: "not started" 35 | }); 36 | 37 | // Update task status 38 | const result = await context.client.callTool({ 39 | name: "update_task", 40 | arguments: { 41 | projectId: project.projectId, 42 | taskId: task.id, 43 | status: "in progress" 44 | } 45 | }) as CallToolResult; 46 | 47 | // Verify response 48 | verifyCallToolResult(result); 49 | expect(result.isError).toBeFalsy(); 50 | 51 | // Verify file was updated 52 | await verifyTaskInFile(context.testFilePath, project.projectId, task.id, { 53 | status: "in progress" 54 | }); 55 | }); 56 | 57 | it('should update task to done with completedDetails', async () => { 58 | const project = await createTestProjectInFile(context.testFilePath, { 59 | initialPrompt: "Test Project" 60 | }); 61 | const task = await createTestTaskInFile(context.testFilePath, project.projectId, { 62 | title: "Test Task", 63 | status: "in progress" 64 | }); 65 | 66 | const result = await context.client.callTool({ 67 | name: "update_task", 68 | arguments: { 69 | projectId: project.projectId, 70 | taskId: task.id, 71 | status: "done", 72 | completedDetails: "Task completed in test" 73 | } 74 | }) as CallToolResult; 75 | 76 | verifyCallToolResult(result); 77 | expect(result.isError).toBeFalsy(); 78 | 79 | await verifyTaskInFile(context.testFilePath, project.projectId, task.id, { 80 | status: "done", 81 | completedDetails: "Task completed in test" 82 | }); 83 | }); 84 | 85 | it('should return reminder message when marking task done in a project requiring approval', async () => { 86 | // Create a project that requires approval 87 | const project = await createTestProjectInFile(context.testFilePath, { 88 | initialPrompt: "Project Requiring Approval", 89 | autoApprove: false // Explicitly set for clarity 90 | }); 91 | const task = await createTestTaskInFile(context.testFilePath, project.projectId, { 92 | title: "Task to be Approved", 93 | status: "in progress" 94 | }); 95 | 96 | // Mark the task as done 97 | const result = await context.client.callTool({ 98 | name: "update_task", 99 | arguments: { 100 | projectId: project.projectId, 101 | taskId: task.id, 102 | status: "done", 103 | completedDetails: "Task finished, awaiting approval." 104 | } 105 | }) as CallToolResult; 106 | 107 | // Verify the response includes the approval reminder within the JSON structure 108 | verifyCallToolResult(result); // Basic verification 109 | expect(result.isError).toBeFalsy(); 110 | const responseText = (result.content[0] as { text: string }).text; 111 | // Parse the JSON response 112 | const responseData = JSON.parse(responseText); 113 | 114 | // Check the message property 115 | expect(responseData).toHaveProperty('message'); 116 | const expectedMessage = `Task marked as done but requires human approval.\nTo approve, user should run: npx taskqueue approve-task -- ${project.projectId} ${task.id}`; 117 | expect(responseData.message).toBe(expectedMessage); 118 | 119 | // Check that the core task data is present under the 'task' key 120 | expect(responseData).toHaveProperty('task'); 121 | expect(responseData.task.id).toBe(task.id); 122 | expect(responseData.task.status).toBe('done'); 123 | expect(responseData.task.completedDetails).toBe("Task finished, awaiting approval."); 124 | 125 | // Also verify the task state in the file 126 | await verifyTaskInFile(context.testFilePath, project.projectId, task.id, { 127 | status: "done", 128 | completedDetails: "Task finished, awaiting approval.", 129 | approved: false // Should not be approved yet 130 | }); 131 | }); 132 | 133 | it('should update task title and description', async () => { 134 | const project = await createTestProjectInFile(context.testFilePath, { 135 | initialPrompt: "Test Project" 136 | }); 137 | const task = await createTestTaskInFile(context.testFilePath, project.projectId, { 138 | title: "Original Title", 139 | description: "Original Description" 140 | }); 141 | 142 | const result = await context.client.callTool({ 143 | name: "update_task", 144 | arguments: { 145 | projectId: project.projectId, 146 | taskId: task.id, 147 | title: "Updated Title", 148 | description: "Updated Description" 149 | } 150 | }) as CallToolResult; 151 | 152 | verifyCallToolResult(result); 153 | expect(result.isError).toBeFalsy(); 154 | 155 | await verifyTaskInFile(context.testFilePath, project.projectId, task.id, { 156 | title: "Updated Title", 157 | description: "Updated Description" 158 | }); 159 | }); 160 | }); 161 | 162 | describe('Error Cases', () => { 163 | it('should return error for invalid status value', async () => { 164 | const project = await createTestProjectInFile(context.testFilePath, { 165 | initialPrompt: "Test Project" 166 | }); 167 | const task = await createTestTaskInFile(context.testFilePath, project.projectId, { 168 | title: "Test Task" 169 | }); 170 | 171 | try { 172 | await context.client.callTool({ 173 | name: "update_task", 174 | arguments: { 175 | projectId: project.projectId, 176 | taskId: task.id, 177 | status: "invalid_status" // Invalid status value 178 | } 179 | }); 180 | fail('Expected error was not thrown'); 181 | } catch (error) { 182 | verifyProtocolError(error, -32602, "Invalid status: must be one of 'not started', 'in progress', 'done'"); 183 | } 184 | }); 185 | 186 | it('should return error when marking task as done without completedDetails', async () => { 187 | const project = await createTestProjectInFile(context.testFilePath, { 188 | initialPrompt: "Test Project" 189 | }); 190 | const task = await createTestTaskInFile(context.testFilePath, project.projectId, { 191 | title: "Test Task", 192 | status: "in progress" 193 | }); 194 | 195 | try { 196 | await context.client.callTool({ 197 | name: "update_task", 198 | arguments: { 199 | projectId: project.projectId, 200 | taskId: task.id, 201 | status: "done" 202 | // Missing required completedDetails 203 | } 204 | }); 205 | fail('Expected error was not thrown'); 206 | } catch (error) { 207 | verifyProtocolError(error, -32602, "Invalid or missing required parameter: completedDetails (required when status = 'done') (Expected string)"); 208 | } 209 | }); 210 | 211 | it('should return error for non-existent project', async () => { 212 | const result = await context.client.callTool({ 213 | name: "update_task", 214 | arguments: { 215 | projectId: "non_existent_project", 216 | taskId: "task-1", 217 | status: "in progress" 218 | } 219 | }) as CallToolResult; 220 | 221 | verifyToolExecutionError(result, /Project non_existent_project not found/); 222 | }); 223 | 224 | it('should return error for non-existent task', async () => { 225 | const project = await createTestProjectInFile(context.testFilePath, { 226 | initialPrompt: "Test Project" 227 | }); 228 | 229 | const result = await context.client.callTool({ 230 | name: "update_task", 231 | arguments: { 232 | projectId: project.projectId, 233 | taskId: "non_existent_task", 234 | status: "in progress" 235 | } 236 | }) as CallToolResult; 237 | 238 | verifyToolExecutionError(result, /Task non_existent_task not found/); 239 | }); 240 | 241 | it('should return error when updating approved task', async () => { 242 | const project = await createTestProjectInFile(context.testFilePath, { 243 | initialPrompt: "Test Project" 244 | }); 245 | const task = await createTestTaskInFile(context.testFilePath, project.projectId, { 246 | title: "Test Task", 247 | status: "done", 248 | approved: true, 249 | completedDetails: "Already completed" 250 | }); 251 | 252 | const result = await context.client.callTool({ 253 | name: "update_task", 254 | arguments: { 255 | projectId: project.projectId, 256 | taskId: task.id, 257 | title: "New Title" 258 | } 259 | }) as CallToolResult; 260 | 261 | verifyToolExecutionError(result, /Cannot modify an approved task/); 262 | }); 263 | }); 264 | }); -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | // This file provides global declarations for Jest functions 2 | // It allows TypeScript to recognize Jest globals without explicit imports 3 | 4 | import { jest, describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from '@jest/globals'; 5 | 6 | // Export the Jest globals for reuse 7 | export { 8 | jest, 9 | describe, 10 | it, 11 | expect, 12 | beforeEach, 13 | afterEach, 14 | beforeAll, 15 | afterAll 16 | }; 17 | 18 | // Add global teardown 19 | afterAll(async () => { 20 | // Allow time for any open handles to close 21 | await new Promise(resolve => setTimeout(resolve, 500)); 22 | }); -------------------------------------------------------------------------------- /tests/version-consistency.test.js: -------------------------------------------------------------------------------- 1 | import { readFile } from 'fs/promises'; 2 | import { fileURLToPath } from 'url'; 3 | import path from 'path'; 4 | 5 | // Get the directory name equivalent in ESM 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = path.dirname(__filename); 8 | 9 | describe('Package Version Consistency', () => { 10 | test('package.json and package-lock.json versions match', async () => { 11 | try { 12 | // Read files from the project root 13 | const packageJson = JSON.parse( 14 | await readFile(path.resolve(__dirname, '../package.json'), 'utf8') 15 | ); 16 | 17 | const packageLockJson = JSON.parse( 18 | await readFile(path.resolve(__dirname, '../package-lock.json'), 'utf8') 19 | ); 20 | 21 | // Get the package version from both files 22 | const packageVersion = packageJson.version; 23 | const packageLockVersion = packageLockJson.version; 24 | 25 | // Assert that both versions match 26 | expect(packageLockVersion).toBe(packageVersion); 27 | } catch (error) { 28 | // Ensure errors are properly caught and reported 29 | console.error('Error during version comparison:', error); 30 | throw error; 31 | } 32 | }); 33 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2022", "DOM"], 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "esModuleInterop": true, 8 | "isolatedModules": true, 9 | "strict": true, 10 | "outDir": "dist", 11 | "declaration": true, 12 | "sourceMap": true, 13 | "types": ["jest", "node"] 14 | }, 15 | "include": ["src/server/index.ts", "src/**/*", "tests/**/*"], 16 | "exclude": ["node_modules", "dist"] 17 | } 18 | --------------------------------------------------------------------------------